Skip to content

Commit 99c96f3

Browse files
authored
Merge pull request #13 from carldebilly/dev/guard-handler
Add GuardHandler for basic request filtering and DI extensions
2 parents 49ef9f6 + d06db20 commit 99c96f3

File tree

7 files changed

+639
-2
lines changed

7 files changed

+639
-2
lines changed

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ A small, self-contained HTTP server for desktop, mobile, and embedded apps that
8585
- Supports dependency injection and configuration via `IOptions<ServerOptions>`
8686
- Configurable bind addresses and hostnames for IPv4/IPv6
8787
- Supports dynamic port assignment
88+
- Basic request filtering with GuardHandler (best-effort limits, method/host allow-lists, DI-friendly)
8889

8990
## Common use cases
9091

@@ -111,6 +112,74 @@ If you need to expose it on a public or untrusted network:
111112

112113
Note: Authentication/authorization is not built-in; implement it in your handlers or at the proxy layer as needed.
113114

115+
### GuardHandler (basic request filtering)
116+
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.
117+
118+
What it enforces (configurable):
119+
- MaxUrlLength: 414 URI TOO LONG when exceeded.
120+
- MaxHeadersCount: 431 REQUEST HEADER FIELDS TOO LARGE when too many headers.
121+
- MaxHeadersTotalSize: 431 when cumulative header key+value sizes are too large.
122+
- MaxBodyBytes: 413 PAYLOAD TOO LARGE based on Content-Length.
123+
- AllowedMethods: 405 METHOD NOT ALLOWED for methods outside the allow-list.
124+
- RequireHostHeader: 400 BAD REQUEST if Host header is missing.
125+
- AllowedHosts: 403 FORBIDDEN when Host (with or without port) is not in allow-list.
126+
127+
Basic usage:
128+
```csharp
129+
var server = new Server();
130+
server.RegisterHandler(new GuardHandler(
131+
maxUrlLength: 2048,
132+
maxHeadersCount: 100,
133+
maxHeadersTotalSize: 32 * 1024,
134+
maxBodyBytes: 10 * 1024 * 1024,
135+
allowedMethods: new[] { "GET", "POST" },
136+
allowedHosts: null, // any
137+
requireHostHeader: true));
138+
server.RegisterHandler(new StaticHandler("/", "text/plain", "OK"));
139+
server.Start();
140+
```
141+
142+
Wrapping another handler (next handler pattern):
143+
```csharp
144+
var hello = new StaticHandler("/", "text/plain", "Hello");
145+
var guard = new GuardHandler(allowedMethods: new[] { "GET" }, inner: hello);
146+
server.RegisterHandler(guard); // guard calls hello only if checks pass
147+
```
148+
149+
Using Microsoft DI elegantly:
150+
```csharp
151+
var services = new ServiceCollection();
152+
services.AddYllibedHttpServer();
153+
services.AddGuardHandler(opts =>
154+
{
155+
opts.MaxUrlLength = 2048;
156+
opts.MaxHeadersCount = 100;
157+
opts.AllowedMethods = new[] { "GET", "POST" };
158+
opts.RequireHostHeader = true;
159+
opts.AllowedHosts = new[] { "127.0.0.1", "localhost" };
160+
});
161+
162+
// Easiest: auto-register into Server without manual resolution
163+
services.AddGuardHandlerAndRegister(opts =>
164+
{
165+
opts.MaxUrlLength = 2048;
166+
opts.MaxHeadersCount = 100;
167+
opts.AllowedMethods = new[] { "GET", "POST" };
168+
opts.RequireHostHeader = true;
169+
opts.AllowedHosts = new[] { "127.0.0.1", "localhost" };
170+
});
171+
172+
var sp = services.BuildServiceProvider();
173+
var server = sp.GetRequiredService<Server>();
174+
server.RegisterHandler(new StaticHandler("/", "text/plain", "OK"));
175+
server.Start();
176+
```
177+
178+
Notes:
179+
- Place GuardHandler first. If it rejects, it sets the response and stops further processing.
180+
- AllowedHosts matches either the full Host header (may include port) or the parsed HostName without port.
181+
- Null for any limit means "no limit" for that dimension.
182+
114183
## Configuration and Dependency Injection
115184

116185
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>`.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
namespace Yllibed.HttpServer.Tests;
2+
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Options;
5+
using Yllibed.HttpServer.Extensions;
6+
using Yllibed.HttpServer.Handlers;
7+
8+
[TestClass]
9+
public class GenericDiHandlersFixture : FixtureBase
10+
{
11+
private sealed class EchoOptions
12+
{
13+
public string? Message { get; set; }
14+
}
15+
16+
private sealed class EchoHandler : IHttpHandler
17+
{
18+
private readonly string _message;
19+
public EchoHandler(IOptions<EchoOptions> options)
20+
{
21+
_message = options.Value.Message ?? "(null)";
22+
}
23+
24+
public Task HandleRequest(CancellationToken ct, IHttpServerRequest request, string relativePath)
25+
{
26+
if (string.Equals(relativePath, "/echo", StringComparison.OrdinalIgnoreCase))
27+
{
28+
request.SetResponse("text/plain", _message);
29+
}
30+
return Task.CompletedTask;
31+
}
32+
}
33+
34+
[TestMethod]
35+
public async Task AddHttpHandlerAndRegister_RegistersSimpleHandler()
36+
{
37+
var services = new ServiceCollection();
38+
services.AddYllibedHttpServer(_ => { });
39+
40+
// Register a simple preconfigured StaticHandler via DI factory and ensure generic method can still wire it:
41+
services.AddSingleton(new StaticHandler("/ping", "text/plain", "pong"));
42+
services.AddHttpHandlerAndRegister<StaticHandler>(); // Remove the duplicate call
43+
44+
using var sp = services.BuildServiceProvider();
45+
var server = sp.GetRequiredService<Server>();
46+
var (uri4, _) = server.Start();
47+
48+
using var client = new HttpClient();
49+
var res = await client.GetAsync(new Uri(uri4, "/ping"), CT).ConfigureAwait(false);
50+
res.StatusCode.Should().Be(HttpStatusCode.OK);
51+
(await res.Content.ReadAsStringAsync(CT).ConfigureAwait(false)).Should().Be("pong");
52+
}
53+
54+
[TestMethod]
55+
public async Task AddHttpHandlerAndRegister_WithOptions_ConfiguresAndRegisters()
56+
{
57+
var services = new ServiceCollection();
58+
services.AddYllibedHttpServer(_ => { });
59+
services.AddHttpHandlerAndRegister<EchoHandler, EchoOptions>(o => o.Message = "hello from options");
60+
61+
using var sp = services.BuildServiceProvider();
62+
var server = sp.GetRequiredService<Server>();
63+
var (uri4, _) = server.Start();
64+
65+
using var client = new HttpClient();
66+
var res = await client.GetAsync(new Uri(uri4, "/echo"), CT).ConfigureAwait(false);
67+
res.StatusCode.Should().Be(HttpStatusCode.OK);
68+
(await res.Content.ReadAsStringAsync(CT).ConfigureAwait(false)).Should().Be("hello from options");
69+
}
70+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace Yllibed.HttpServer.Tests;
2+
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Yllibed.HttpServer.Extensions;
5+
using Yllibed.HttpServer.Handlers;
6+
7+
[TestClass]
8+
public class GuardHandlerDiFixture : FixtureBase
9+
{
10+
[TestMethod]
11+
public async Task AddGuardHandlerAndRegister_RegistersIntoServerPipeline()
12+
{
13+
var services = new ServiceCollection();
14+
services.AddYllibedHttpServer();
15+
services.AddGuardHandlerAndRegister(opts =>
16+
{
17+
opts.AllowedMethods = new[] { "GET" };
18+
});
19+
20+
using var sp = services.BuildServiceProvider();
21+
var server = sp.GetRequiredService<Server>();
22+
server.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK"));
23+
var (uri4, _) = server.Start();
24+
25+
var requestUri = new Uri(uri4, "/ok");
26+
27+
using var client = new HttpClient();
28+
// DELETE should be blocked by protection without manually resolving the handler
29+
var blocked = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, requestUri), CT).ConfigureAwait(false);
30+
blocked.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed);
31+
32+
// GET passes
33+
var allowed = await client.GetAsync(requestUri, CT).ConfigureAwait(false);
34+
allowed.StatusCode.Should().Be(HttpStatusCode.OK);
35+
}
36+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
namespace Yllibed.HttpServer.Tests;
2+
3+
using Yllibed.HttpServer.Handlers;
4+
5+
[TestClass]
6+
public class GuardHandlerFixture : FixtureBase
7+
{
8+
[TestMethod]
9+
public async Task GuardHandler_UrlTooLong_Returns414()
10+
{
11+
using var sut = new Server();
12+
// Very small limit for test
13+
sut.RegisterHandler(new GuardHandler(maxUrlLength: 10));
14+
sut.RegisterHandler(new StaticHandler("ok", "text/plain", "OK"));
15+
16+
var (uri4, _) = sut.Start();
17+
var longPath = new string('a', 50);
18+
var requestUri = new Uri(uri4, $"{longPath}");
19+
20+
using var client = new HttpClient();
21+
var response = await client.GetAsync(requestUri, CT).ConfigureAwait(false);
22+
response.StatusCode.Should().Be(HttpStatusCode.RequestUriTooLong);
23+
}
24+
25+
[TestMethod]
26+
public async Task GuardHandler_ContentLengthTooLarge_Returns413()
27+
{
28+
using var sut = new Server();
29+
// Limit to 16 bytes
30+
sut.RegisterHandler(new GuardHandler(maxBodyBytes: 16));
31+
sut.RegisterHandler(new StaticHandler("ok", "text/plain", "OK"));
32+
33+
var (uri4, _) = sut.Start();
34+
var requestUri = new Uri(uri4, "ok");
35+
36+
using var client = new HttpClient();
37+
var content = new string('x', 100); // > 16 bytes
38+
var response = await client.PostAsync(requestUri, new StringContent(content), CT).ConfigureAwait(false);
39+
response.StatusCode.Should().Be(HttpStatusCode.RequestEntityTooLarge);
40+
}
41+
42+
[TestMethod]
43+
public async Task GuardHandler_HeadersTooLarge_Returns431()
44+
{
45+
using var sut = new Server();
46+
// Keep overall header size tiny to force 431
47+
sut.RegisterHandler(new GuardHandler(maxHeadersTotalSize: 100));
48+
sut.RegisterHandler(new StaticHandler("ok", "text/plain", "OK"));
49+
50+
var (uri4, _) = sut.Start();
51+
var requestUri = new Uri(uri4, "ok");
52+
53+
using var client = new HttpClient();
54+
var msg = new HttpRequestMessage(HttpMethod.Get, requestUri);
55+
msg.Headers.Add("X-Long", new string('z', 1024));
56+
var response = await client.SendAsync(msg, CT).ConfigureAwait(false);
57+
response.StatusCode.Should().Be((HttpStatusCode)431);
58+
}
59+
[TestMethod]
60+
public async Task GuardHandler_MethodNotAllowed_Returns405()
61+
{
62+
using var sut = new Server();
63+
sut.RegisterHandler(new GuardHandler(allowedMethods: new[] { "GET" }));
64+
sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK"));
65+
66+
var (uri4, _) = sut.Start();
67+
var requestUri = new Uri(uri4, "/ok");
68+
69+
using var client = new HttpClient();
70+
var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, requestUri), CT).ConfigureAwait(false);
71+
response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed);
72+
}
73+
74+
[TestMethod]
75+
public async Task GuardHandler_ForbiddenHost_Returns403()
76+
{
77+
using var sut = new Server();
78+
sut.RegisterHandler(new GuardHandler(allowedHosts: new[] { "localhost" }));
79+
sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK"));
80+
81+
var (uri4, _) = sut.Start();
82+
var requestUri = new Uri(uri4, "/ok");
83+
84+
using var client = new HttpClient();
85+
var msg = new HttpRequestMessage(HttpMethod.Get, requestUri);
86+
// Force a different Host header value to trigger 403: use machine name if not localhost
87+
msg.Headers.Host = "example.com";
88+
var response = await client.SendAsync(msg, CT).ConfigureAwait(false);
89+
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
90+
}
91+
92+
[TestMethod]
93+
public async Task GuardHandler_TooManyHeaders_Returns431()
94+
{
95+
using var sut = new Server();
96+
// Set very low header count limit to trigger 431 (Host header alone is 1; we'll add another)
97+
sut.RegisterHandler(new GuardHandler(maxHeadersCount: 1));
98+
sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK"));
99+
100+
var (uri4, _) = sut.Start();
101+
var requestUri = new Uri(uri4, "/ok");
102+
103+
using var client = new HttpClient();
104+
var msg = new HttpRequestMessage(HttpMethod.Get, requestUri);
105+
msg.Headers.Add("X-One", "1");
106+
var response = await client.SendAsync(msg, CT).ConfigureAwait(false);
107+
response.StatusCode.Should().Be((HttpStatusCode)431);
108+
}
109+
110+
[TestMethod]
111+
public async Task GuardHandler_PassesThrough_ToNextHandler_WhenValid()
112+
{
113+
using var sut = new Server();
114+
sut.RegisterHandler(new GuardHandler(allowedMethods: new[] { "GET" }));
115+
sut.RegisterHandler(new StaticHandler("/ok", "text/plain", "OK"));
116+
117+
var (uri4, _) = sut.Start();
118+
var requestUri = new Uri(uri4, "/ok");
119+
120+
using var client = new HttpClient();
121+
var response = await client.GetAsync(requestUri, CT).ConfigureAwait(false);
122+
response.StatusCode.Should().Be(HttpStatusCode.OK);
123+
(await response.Content.ReadAsStringAsync(CT).ConfigureAwait(false)).Should().Be("OK");
124+
}
125+
126+
[TestMethod]
127+
public async Task GuardHandler_AsWrapper_CallsInnerOnlyOnPass()
128+
{
129+
using var sut = new Server();
130+
var inner = new StaticHandler("/ok", "text/plain", "OK");
131+
var guard = new GuardHandler(allowedMethods: new[] { "GET" }, inner: inner);
132+
sut.RegisterHandler(guard);
133+
134+
var (uri4, _) = sut.Start();
135+
var requestUri = new Uri(uri4, "/ok");
136+
137+
using var client = new HttpClient();
138+
// DELETE should be blocked by protection and inner should not run
139+
var blocked = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, requestUri), CT).ConfigureAwait(false);
140+
blocked.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed);
141+
142+
// GET should pass and inner returns OK
143+
var allowed = await client.GetAsync(requestUri, CT).ConfigureAwait(false);
144+
allowed.StatusCode.Should().Be(HttpStatusCode.OK);
145+
}
146+
}

0 commit comments

Comments
 (0)