Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ A small, self-contained HTTP server for desktop, mobile, and embedded apps that
- Supports dependency injection and configuration via `IOptions<ServerOptions>`
- 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

Expand All @@ -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>();
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<ServerOptions>`.
Expand Down
70 changes: 70 additions & 0 deletions Yllibed.HttpServer.Tests/GenericDiHandlersFixture.cs
Original file line number Diff line number Diff line change
@@ -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<EchoOptions> 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<StaticHandler>(); // Remove the duplicate call

using var sp = services.BuildServiceProvider();
var server = sp.GetRequiredService<Server>();
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<EchoHandler, EchoOptions>(o => o.Message = "hello from options");

using var sp = services.BuildServiceProvider();
var server = sp.GetRequiredService<Server>();
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");
}
}
36 changes: 36 additions & 0 deletions Yllibed.HttpServer.Tests/GuardHandlerDiFixture.cs
Original file line number Diff line number Diff line change
@@ -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>();
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);
}
}
146 changes: 146 additions & 0 deletions Yllibed.HttpServer.Tests/GuardHandlerFixture.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading