diff --git a/README.md b/README.md index e7c8327..681a4cb 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ A small, self-contained HTTP server for desktop, mobile, and embedded apps that - Supports dependency injection and configuration via `IOptions` - Configurable bind addresses and hostnames for IPv4/IPv6 - Supports dynamic port assignment +- Basic request filtering with GuardHandler (best-effort limits, method/host allow-lists, DI-friendly) ## Common use cases @@ -111,6 +112,74 @@ If you need to expose it on a public or untrusted network: Note: Authentication/authorization is not built-in; implement it in your handlers or at the proxy layer as needed. +### GuardHandler (basic request filtering) +GuardHandler provides best-effort filtering of incoming requests, rejecting obviously problematic or oversized requests early. This is lightweight filtering against unsophisticated attacks, not comprehensive security. + +What it enforces (configurable): +- MaxUrlLength: 414 URI TOO LONG when exceeded. +- MaxHeadersCount: 431 REQUEST HEADER FIELDS TOO LARGE when too many headers. +- MaxHeadersTotalSize: 431 when cumulative header key+value sizes are too large. +- MaxBodyBytes: 413 PAYLOAD TOO LARGE based on Content-Length. +- AllowedMethods: 405 METHOD NOT ALLOWED for methods outside the allow-list. +- RequireHostHeader: 400 BAD REQUEST if Host header is missing. +- AllowedHosts: 403 FORBIDDEN when Host (with or without port) is not in allow-list. + +Basic usage: +```csharp +var server = new Server(); +server.RegisterHandler(new GuardHandler( + maxUrlLength: 2048, + maxHeadersCount: 100, + maxHeadersTotalSize: 32 * 1024, + maxBodyBytes: 10 * 1024 * 1024, + allowedMethods: new[] { "GET", "POST" }, + allowedHosts: null, // any + requireHostHeader: true)); +server.RegisterHandler(new StaticHandler("/", "text/plain", "OK")); +server.Start(); +``` + +Wrapping another handler (next handler pattern): +```csharp +var hello = new StaticHandler("/", "text/plain", "Hello"); +var guard = new GuardHandler(allowedMethods: new[] { "GET" }, inner: hello); +server.RegisterHandler(guard); // guard calls hello only if checks pass +``` + +Using Microsoft DI elegantly: +```csharp +var services = new ServiceCollection(); +services.AddYllibedHttpServer(); +services.AddGuardHandler(opts => +{ + opts.MaxUrlLength = 2048; + opts.MaxHeadersCount = 100; + opts.AllowedMethods = new[] { "GET", "POST" }; + opts.RequireHostHeader = true; + opts.AllowedHosts = new[] { "127.0.0.1", "localhost" }; +}); + +// Easiest: auto-register into Server without manual resolution +services.AddGuardHandlerAndRegister(opts => +{ + opts.MaxUrlLength = 2048; + opts.MaxHeadersCount = 100; + opts.AllowedMethods = new[] { "GET", "POST" }; + opts.RequireHostHeader = true; + opts.AllowedHosts = new[] { "127.0.0.1", "localhost" }; +}); + +var sp = services.BuildServiceProvider(); +var server = sp.GetRequiredService(); +server.RegisterHandler(new StaticHandler("/", "text/plain", "OK")); +server.Start(); +``` + +Notes: +- Place GuardHandler first. If it rejects, it sets the response and stops further processing. +- AllowedHosts matches either the full Host header (may include port) or the parsed HostName without port. +- Null for any limit means "no limit" for that dimension. + ## Configuration and Dependency Injection The server can now be configured via a `ServerOptions` POCO. You can construct `Server` directly with a `ServerOptions` instance, or register it with Microsoft.Extensions.DependencyInjection using `IOptions`. diff --git a/Yllibed.HttpServer.Tests/GenericDiHandlersFixture.cs b/Yllibed.HttpServer.Tests/GenericDiHandlersFixture.cs new file mode 100644 index 0000000..fa4c2bf --- /dev/null +++ b/Yllibed.HttpServer.Tests/GenericDiHandlersFixture.cs @@ -0,0 +1,70 @@ +namespace Yllibed.HttpServer.Tests; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Yllibed.HttpServer.Extensions; +using Yllibed.HttpServer.Handlers; + +[TestClass] +public class GenericDiHandlersFixture : FixtureBase +{ + private sealed class EchoOptions + { + public string? Message { get; set; } + } + + private sealed class EchoHandler : IHttpHandler + { + private readonly string _message; + public EchoHandler(IOptions options) + { + _message = options.Value.Message ?? "(null)"; + } + + public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) + { + if (string.Equals(relativePath, "/echo", StringComparison.OrdinalIgnoreCase)) + { + request.SetResponse("text/plain", _message); + } + return Task.CompletedTask; + } + } + + [TestMethod] + public async Task AddHttpHandlerAndRegister_RegistersSimpleHandler() + { + var services = new ServiceCollection(); + services.AddYllibedHttpServer(_ => { }); + + // Register a simple preconfigured StaticHandler via DI factory and ensure generic method can still wire it: + services.AddSingleton(new StaticHandler("/ping", "text/plain", "pong")); + services.AddHttpHandlerAndRegister(); // Remove the duplicate call + + using var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + var (uri4, _) = server.Start(); + + using var client = new HttpClient(); + var res = await client.GetAsync(new Uri(uri4, "/ping"), CT).ConfigureAwait(false); + res.StatusCode.Should().Be(HttpStatusCode.OK); + (await res.Content.ReadAsStringAsync(CT).ConfigureAwait(false)).Should().Be("pong"); + } + + [TestMethod] + public async Task AddHttpHandlerAndRegister_WithOptions_ConfiguresAndRegisters() + { + var services = new ServiceCollection(); + services.AddYllibedHttpServer(_ => { }); + services.AddHttpHandlerAndRegister(o => o.Message = "hello from options"); + + using var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + var (uri4, _) = server.Start(); + + using var client = new HttpClient(); + var res = await client.GetAsync(new Uri(uri4, "/echo"), CT).ConfigureAwait(false); + res.StatusCode.Should().Be(HttpStatusCode.OK); + (await res.Content.ReadAsStringAsync(CT).ConfigureAwait(false)).Should().Be("hello from options"); + } +} diff --git a/Yllibed.HttpServer.Tests/GuardHandlerDiFixture.cs b/Yllibed.HttpServer.Tests/GuardHandlerDiFixture.cs new file mode 100644 index 0000000..28540bd --- /dev/null +++ b/Yllibed.HttpServer.Tests/GuardHandlerDiFixture.cs @@ -0,0 +1,36 @@ +namespace Yllibed.HttpServer.Tests; + +using Microsoft.Extensions.DependencyInjection; +using Yllibed.HttpServer.Extensions; +using Yllibed.HttpServer.Handlers; + +[TestClass] +public class GuardHandlerDiFixture : FixtureBase +{ + [TestMethod] + public async Task AddGuardHandlerAndRegister_RegistersIntoServerPipeline() + { + var services = new ServiceCollection(); + services.AddYllibedHttpServer(); + services.AddGuardHandlerAndRegister(opts => + { + opts.AllowedMethods = new[] { "GET" }; + }); + + using var sp = services.BuildServiceProvider(); + var server = sp.GetRequiredService(); + server.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK")); + var (uri4, _) = server.Start(); + + var requestUri = new Uri(uri4, "/ok"); + + using var client = new HttpClient(); + // DELETE should be blocked by protection without manually resolving the handler + var blocked = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, requestUri), CT).ConfigureAwait(false); + blocked.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + + // GET passes + var allowed = await client.GetAsync(requestUri, CT).ConfigureAwait(false); + allowed.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/Yllibed.HttpServer.Tests/GuardHandlerFixture.cs b/Yllibed.HttpServer.Tests/GuardHandlerFixture.cs new file mode 100644 index 0000000..e961105 --- /dev/null +++ b/Yllibed.HttpServer.Tests/GuardHandlerFixture.cs @@ -0,0 +1,146 @@ +namespace Yllibed.HttpServer.Tests; + +using Yllibed.HttpServer.Handlers; + +[TestClass] +public class GuardHandlerFixture : FixtureBase +{ + [TestMethod] + public async Task GuardHandler_UrlTooLong_Returns414() + { + using var sut = new Server(); + // Very small limit for test + sut.RegisterHandler(new GuardHandler(maxUrlLength: 10)); + sut.RegisterHandler(new StaticHandler("ok", "text/plain", "OK")); + + var (uri4, _) = sut.Start(); + var longPath = new string('a', 50); + var requestUri = new Uri(uri4, $"{longPath}"); + + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT).ConfigureAwait(false); + response.StatusCode.Should().Be(HttpStatusCode.RequestUriTooLong); + } + + [TestMethod] + public async Task GuardHandler_ContentLengthTooLarge_Returns413() + { + using var sut = new Server(); + // Limit to 16 bytes + sut.RegisterHandler(new GuardHandler(maxBodyBytes: 16)); + sut.RegisterHandler(new StaticHandler("ok", "text/plain", "OK")); + + var (uri4, _) = sut.Start(); + var requestUri = new Uri(uri4, "ok"); + + using var client = new HttpClient(); + var content = new string('x', 100); // > 16 bytes + var response = await client.PostAsync(requestUri, new StringContent(content), CT).ConfigureAwait(false); + response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge); + } + + [TestMethod] + public async Task GuardHandler_HeadersTooLarge_Returns431() + { + using var sut = new Server(); + // Keep overall header size tiny to force 431 + sut.RegisterHandler(new GuardHandler(maxHeadersTotalSize: 100)); + sut.RegisterHandler(new StaticHandler("ok", "text/plain", "OK")); + + var (uri4, _) = sut.Start(); + var requestUri = new Uri(uri4, "ok"); + + using var client = new HttpClient(); + var msg = new HttpRequestMessage(HttpMethod.Get, requestUri); + msg.Headers.Add("X-Long", new string('z', 1024)); + var response = await client.SendAsync(msg, CT).ConfigureAwait(false); + response.StatusCode.Should().Be((HttpStatusCode)431); + } + [TestMethod] + public async Task GuardHandler_MethodNotAllowed_Returns405() + { + using var sut = new Server(); + sut.RegisterHandler(new GuardHandler(allowedMethods: new[] { "GET" })); + sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK")); + + var (uri4, _) = sut.Start(); + var requestUri = new Uri(uri4, "/ok"); + + using var client = new HttpClient(); + var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, requestUri), CT).ConfigureAwait(false); + response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + } + + [TestMethod] + public async Task GuardHandler_ForbiddenHost_Returns403() + { + using var sut = new Server(); + sut.RegisterHandler(new GuardHandler(allowedHosts: new[] { "localhost" })); + sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK")); + + var (uri4, _) = sut.Start(); + var requestUri = new Uri(uri4, "/ok"); + + using var client = new HttpClient(); + var msg = new HttpRequestMessage(HttpMethod.Get, requestUri); + // Force a different Host header value to trigger 403: use machine name if not localhost + msg.Headers.Host = "example.com"; + var response = await client.SendAsync(msg, CT).ConfigureAwait(false); + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [TestMethod] + public async Task GuardHandler_TooManyHeaders_Returns431() + { + using var sut = new Server(); + // Set very low header count limit to trigger 431 (Host header alone is 1; we'll add another) + sut.RegisterHandler(new GuardHandler(maxHeadersCount: 1)); + sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK")); + + var (uri4, _) = sut.Start(); + var requestUri = new Uri(uri4, "/ok"); + + using var client = new HttpClient(); + var msg = new HttpRequestMessage(HttpMethod.Get, requestUri); + msg.Headers.Add("X-One", "1"); + var response = await client.SendAsync(msg, CT).ConfigureAwait(false); + response.StatusCode.Should().Be((HttpStatusCode)431); + } + + [TestMethod] + public async Task GuardHandler_PassesThrough_ToNextHandler_WhenValid() + { + using var sut = new Server(); + sut.RegisterHandler(new GuardHandler(allowedMethods: new[] { "GET" })); + sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK")); + + var (uri4, _) = sut.Start(); + var requestUri = new Uri(uri4, "/ok"); + + using var client = new HttpClient(); + var response = await client.GetAsync(requestUri, CT).ConfigureAwait(false); + response.StatusCode.Should().Be(HttpStatusCode.OK); + (await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false)).Should().Be("OK"); + } + + [TestMethod] + public async Task GuardHandler_AsWrapper_CallsInnerOnlyOnPass() + { + using var sut = new Server(); + var inner = new StaticHandler("/ok", "text/plain", "OK"); + var guard = new GuardHandler(allowedMethods: new[] { "GET" }, inner: inner); + sut.RegisterHandler(guard); + + var (uri4, _) = sut.Start(); + var requestUri = new Uri(uri4, "/ok"); + + using var client = new HttpClient(); + // DELETE should be blocked by protection and inner should not run + var blocked = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, requestUri), CT).ConfigureAwait(false); + blocked.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + + // GET should pass and inner returns OK + var allowed = await client.GetAsync(requestUri, CT).ConfigureAwait(false); + allowed.StatusCode.Should().Be(HttpStatusCode.OK); + } +} diff --git a/Yllibed.HttpServer/Extensions/GuardExtensions.cs b/Yllibed.HttpServer/Extensions/GuardExtensions.cs new file mode 100644 index 0000000..bdfc819 --- /dev/null +++ b/Yllibed.HttpServer/Extensions/GuardExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Yllibed.HttpServer.Handlers; + +namespace Yllibed.HttpServer.Extensions; + +/// +/// Extensions to configure GuardHandler via Microsoft DI. +/// +public static class GuardExtensions +{ + /// + /// Registers GuardHandler with options support and exposes it as both its concrete type and as IHttpHandler. + /// + public static IServiceCollection AddGuardHandler(this IServiceCollection services) + { + services.AddSingleton(sp => new GuardHandler(sp.GetRequiredService>())); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } + + /// + /// Registers GuardHandler with configuration delegate. + /// + public static IServiceCollection AddGuardHandler(this IServiceCollection services, Action configure) + { + services.Configure(configure); + return services.AddGuardHandler(); + } + + /// + /// Registers GuardHandler and automatically registers it into the Server pipeline. + /// This avoids having to resolve the handler manually just to call Server.RegisterHandler. + /// + public static IServiceCollection AddGuardHandlerAndRegister(this IServiceCollection services, Action? configure = null) + { + if (configure != null) + { + services.Configure(configure); + } + services.AddGuardHandler(); + // Register a singleton that wires the handler into the server on construction + services.AddSingleton(); + return services; + } + + private sealed class GuardRegistration : IDisposable + { + private readonly IDisposable _registration; + + public GuardRegistration(Server server, GuardHandler handler) + { + // Place first by registering now; Server keeps order of registration + _registration = server.RegisterHandler(handler); + } + + public void Dispose() + { + _registration.Dispose(); + } + } +} diff --git a/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs b/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs index 3d301f5..9c36d60 100644 --- a/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs +++ b/Yllibed.HttpServer/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,9 @@ +using System; +using System.Collections.Generic; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; +using Yllibed.HttpServer.Handlers; namespace Yllibed.HttpServer.Extensions; @@ -15,7 +19,14 @@ public static class ServiceCollectionExtensions /// The service collection for chaining. public static IServiceCollection AddYllibedHttpServer(this IServiceCollection services) { - services.AddSingleton(sp => new Server(sp.GetRequiredService>())); + services.TryAddSingleton(); + services.TryAddSingleton(sp => + { + var server = new Server(sp.GetRequiredService>()); + var registrationService = sp.GetRequiredService(); + registrationService.RegisterHandlers(server, sp); + return server; + }); return services; } @@ -28,7 +39,77 @@ public static IServiceCollection AddYllibedHttpServer(this IServiceCollection se public static IServiceCollection AddYllibedHttpServer(this IServiceCollection services, Action configureOptions) { services.Configure(configureOptions); - services.AddSingleton(sp => new Server(sp.GetRequiredService>())); + + // Avoid calling the parameterless overload to prevent confusion/recursion; register the same singletons directly. + services.TryAddSingleton(); + services.TryAddSingleton(sp => + { + var server = new Server(sp.GetRequiredService>()); + var registrationService = sp.GetRequiredService(); + registrationService.RegisterHandlers(server, sp); + return server; + }); + + return services; + } + + /// + /// Generic: registers a handler THandler and automatically wires it into the Server pipeline. + /// The handler is resolved from DI and registered when the ServiceProvider is built; disposal unregisters it. + /// + public static IServiceCollection AddHttpHandlerAndRegister(this IServiceCollection services) + where THandler : class, IHttpHandler + { + // Ensure handler is registered + services.TryAddSingleton(); + + // Ensure the HandlerRegistrationService exists + services.TryAddSingleton(); + + // Register this handler type to be auto-registered + services.Configure(options => + { + options.HandlerTypes.Add(typeof(THandler)); + }); + return services; } + + /// + /// Generic with options: configures TOptions and registers THandler that consumes IOptions<TOptions> (if applicable), + /// and auto-wires it into the Server pipeline. This removes the need to resolve it manually for registration. + /// + public static IServiceCollection AddHttpHandlerAndRegister(this IServiceCollection services, Action configure) + where THandler : class, IHttpHandler + where TOptions : class, new() + { + services.Configure(configure); + return services.AddHttpHandlerAndRegister(); + } + + private sealed class HandlerRegistrationOptions + { + public List HandlerTypes { get; } = new(); + } + + private sealed class HandlerRegistrationService + { + private readonly IOptions _options; + private readonly List _registrations = new(); + + public HandlerRegistrationService(IOptions options) + { + _options = options; + } + + public void RegisterHandlers(Server server, IServiceProvider serviceProvider) + { + foreach (var handlerType in _options.Value.HandlerTypes) + { + var handler = (IHttpHandler)serviceProvider.GetRequiredService(handlerType); + var registration = server.RegisterHandler(handler); + _registrations.Add(registration); + } + } + } } diff --git a/Yllibed.HttpServer/Handlers/GuardHandler.cs b/Yllibed.HttpServer/Handlers/GuardHandler.cs new file mode 100644 index 0000000..b8200f5 --- /dev/null +++ b/Yllibed.HttpServer/Handlers/GuardHandler.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Options; + +namespace Yllibed.HttpServer.Handlers; + +/// +/// A basic request guard that performs best-effort filtering of incoming requests, +/// rejecting them early when violating basic limits (URL length, headers size/count, payload size, etc.). +/// This provides lightweight filtering against unsophisticated attacks, not comprehensive security. +/// Place it first in the pipeline. Can optionally wrap an inner handler (next). +/// +public sealed class GuardHandler : IHttpHandler +{ + public sealed class Options + { + public int? MaxUrlLength { get; set; } = 2048; + public int? MaxHeadersCount { get; set; } = 100; + public int? MaxHeadersTotalSize { get; set; } = 32 * 1024; + public int? MaxBodyBytes { get; set; } = 10 * 1024 * 1024; + public string[]? AllowedMethods { get; set; } = new[] { "GET", "POST" }; + public string[]? AllowedHosts { get; set; } = null; // null = any + public bool RequireHostHeader { get; set; } = true; + } + + public int? MaxUrlLength { get; } + public int? MaxHeadersCount { get; } + public int? MaxHeadersTotalSize { get; } + public int? MaxBodyBytes { get; } + public bool RequireHostHeader { get; } + + private readonly HashSet? _allowedMethods; + private readonly HashSet? _allowedHosts; + private readonly IHttpHandler? _inner; + + /// + /// Create a new GuardHandler with optional limits. Null means no limit for that dimension. + /// Defaults are conservative and can be adjusted per app needs. + /// + /// Max allowed length of Path (including query). Default 2048. + /// Max allowed number of request headers. Default 100. + /// Max cumulative size of header keys+values (characters). Default 32k. + /// Max payload size from Content-Length. Default 10 MB. + /// Optional allowed HTTP methods (case-insensitive). Null = any. + /// Optional allowed Host header values (case-insensitive). Null = any. + /// Require Host header to be present (default true). + /// Optional inner (next) handler to call when checks pass. + public GuardHandler( + int? maxUrlLength = 2048, + int? maxHeadersCount = 100, + int? maxHeadersTotalSize = 32 * 1024, + int? maxBodyBytes = 10 * 1024 * 1024, + IEnumerable? allowedMethods = null, + IEnumerable? allowedHosts = null, + bool requireHostHeader = true, + IHttpHandler? inner = null) + { + MaxUrlLength = maxUrlLength; + MaxHeadersCount = maxHeadersCount; + MaxHeadersTotalSize = maxHeadersTotalSize; + MaxBodyBytes = maxBodyBytes; + _allowedMethods = allowedMethods != null ? new HashSet(allowedMethods, StringComparer.OrdinalIgnoreCase) : null; + _allowedHosts = allowedHosts != null ? new HashSet(allowedHosts, StringComparer.OrdinalIgnoreCase) : null; + RequireHostHeader = requireHostHeader; + _inner = inner; + } + + /// + /// DI-friendly constructor using Microsoft.Extensions.Options.IOptions. + /// + [Microsoft.Extensions.DependencyInjection.ActivatorUtilitiesConstructor] + public GuardHandler(Microsoft.Extensions.Options.IOptions options) + : this( + options?.Value?.MaxUrlLength, + options?.Value?.MaxHeadersCount, + options?.Value?.MaxHeadersTotalSize, + options?.Value?.MaxBodyBytes, + options?.Value?.AllowedMethods, + options?.Value?.AllowedHosts, + options?.Value?.RequireHostHeader ?? true, + inner: null) + { + } + + public async Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath) + { + // 0) Method allow-list + if (_allowedMethods is { Count: > 0 }) + { + var method = request.Method?.ToUpperInvariant() ?? string.Empty; + if (!_allowedMethods.Contains(method)) + { + Reject(request, 405, "METHOD NOT ALLOWED", System.FormattableString.Invariant($"Method '{request.Method}' not allowed")); + return; + } + } + + // 0.1) Host header presence and allow-list + if (RequireHostHeader && string.IsNullOrWhiteSpace(request.Host)) + { + Reject(request, 400, "BAD REQUEST", "Missing Host header"); + return; + } + if (_allowedHosts is { Count: > 0 } && request.Host is { Length: > 0 } host) + { + // Compare case-insensitively on full Host header (can include port) + var allowed = _allowedHosts.Contains(host) + || (request.HostName is { Length: > 0 } hn && _allowedHosts.Contains(hn)); + if (!allowed) + { + Reject(request, 403, "FORBIDDEN", "Host not allowed"); + return; + } + } + + // 1) URL length (Path already includes querystring per IHttpServerRequest contract) + if (MaxUrlLength is int maxUrl && request.Path is { Length: > 0 } path && path.Length > maxUrl) + { + Reject(request, 414, "URI TOO LONG", System.FormattableString.Invariant($"URI too long (limit: {maxUrl} chars)")); + return; + } + + // 2) Headers count / total size + var headers = request.Headers; + if (headers != null) + { + if (MaxHeadersCount is int maxCount && headers.Count > maxCount) + { + Reject(request, 431, "REQUEST HEADER FIELDS TOO LARGE", System.FormattableString.Invariant($"Too many headers (limit: {maxCount})")); + return; + } + + if (MaxHeadersTotalSize is int maxSize) + { + var total = 0; + foreach (var kvp in headers) + { + // Approximate header size as key length + sum of values length + total += kvp.Key.Length; + total += kvp.Value.Sum(v => v?.Length ?? 0); + if (total > maxSize) + { + break; + } + } + if (total > maxSize) + { + Reject(request, 431, "REQUEST HEADER FIELDS TOO LARGE", System.FormattableString.Invariant($"Headers too large (limit: {maxSize} chars)")); + return; + } + } + } + + // 3) Payload (uses Content-Length when provided; chunked transfer isn't supported by this server) + if (MaxBodyBytes is int maxBody && request.ContentLength is int len && len > maxBody) + { + Reject(request, 413, "PAYLOAD TOO LARGE", System.FormattableString.Invariant($"Payload too large (limit: {maxBody} bytes)")); + return; + } + + // 4) If wrapping another handler, pass-through + if (_inner is not null) + { + await _inner.HandleRequest(ct, request, relativePath); + } + } + + private static void Reject(IHttpServerRequest request, uint status, string reason, string message) + { + request.SetResponse("text/plain", message, status, reason); + } +}