From 0acaaa51eb80975c78d359daee975f20a220c93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Thu, 10 Sep 2026 13:45:47 +0200 Subject: [PATCH 1/5] Prevent server no longe responding after probable DoS attack --- .../Infrastructure/Endpoints/EndPoint.cs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Engine/Internal/Infrastructure/Endpoints/EndPoint.cs b/Engine/Internal/Infrastructure/Endpoints/EndPoint.cs index d7cea197..a3106f0e 100644 --- a/Engine/Internal/Infrastructure/Endpoints/EndPoint.cs +++ b/Engine/Internal/Infrastructure/Endpoints/EndPoint.cs @@ -98,27 +98,39 @@ private async Task Listen() { if (Socket == null) throw new InvalidOperationException("The endpoint has not been started"); - try + while (!_shuttingDown) { - do + try { Handle(await Socket.AcceptAsync()); } - while (!_shuttingDown); - } - catch (Exception e) - { - if (!_shuttingDown && !ConnectionExceptions.IsGracefulDisconnect(e)) + catch (ObjectDisposedException) { - Logger.LogError(e, "Failed to accept incoming connection"); + break; + } + catch (OperationCanceledException) + { + break; + } + catch (Exception e) + { + if (_shuttingDown) + { + break; + } + + if (!ConnectionExceptions.IsGracefulDisconnect(e)) + { + Logger.LogError(e, "Failed to accept incoming connection"); + } + + await Task.Delay(500); } } } private void Handle(Socket client) { - using var _ = ExecutionContext.SuppressFlow(); - Task.Run(() => Accept(client)); } From 4189158fcd1ffdcdffc8e455c92ee887f6da590d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Thu, 10 Sep 2026 13:46:23 +0200 Subject: [PATCH 2/5] Prevent path traversal attacks in all file related modules --- .../Provider/ListingRouter.cs | 6 +- Modules/Files/Multi/AbstractAssetsHandler.cs | 4 +- .../Files/Multi/Ioxide/IoxideFilesHandler.cs | 2 + Modules/IO/Extensions.Request.Security.cs | 58 +++++++ .../Provider/SinglePageProvider.cs | 6 +- .../Provider/StaticWebsiteHandler.cs | 2 + .../Acceptance/Engine/Body/DrainBodyTests.cs | 1 - .../Modules/IO/PathTraversalTests.cs | 157 ++++++++++++++++++ 8 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 Modules/IO/Extensions.Request.Security.cs create mode 100644 Testing/Acceptance/Modules/IO/PathTraversalTests.cs diff --git a/Modules/DirectoryBrowsing/Provider/ListingRouter.cs b/Modules/DirectoryBrowsing/Provider/ListingRouter.cs index 7539fbb9..fde9c8f8 100644 --- a/Modules/DirectoryBrowsing/Provider/ListingRouter.cs +++ b/Modules/DirectoryBrowsing/Provider/ListingRouter.cs @@ -29,7 +29,11 @@ public ListingRouter(IResourceTree tree) public async ValueTask HandleAsync(IRequest request) { - var (node, resource) = await Tree.FindAsync(request.Header.Target); + var target = request.Header.Target; + + target.DenyPathTraversal(); + + var (node, resource) = await Tree.FindAsync(target); if (resource is not null) { diff --git a/Modules/Files/Multi/AbstractAssetsHandler.cs b/Modules/Files/Multi/AbstractAssetsHandler.cs index 5ae392cc..7c2d61fe 100644 --- a/Modules/Files/Multi/AbstractAssetsHandler.cs +++ b/Modules/Files/Multi/AbstractAssetsHandler.cs @@ -22,12 +22,14 @@ protected AbstractAssetsHandler(List algorithms, char sep public async ValueTask HandleAsync(IRequest request) { var target = request.Header.Target; - + if (target.HasTrailingSlash) { return null; } + target.DenyPathTraversal(); + if (_preCompression.Enabled) { var handled = await TryGetPreCompressed(request); diff --git a/Modules/Files/Multi/Ioxide/IoxideFilesHandler.cs b/Modules/Files/Multi/Ioxide/IoxideFilesHandler.cs index b742d718..ef2ada59 100644 --- a/Modules/Files/Multi/Ioxide/IoxideFilesHandler.cs +++ b/Modules/Files/Multi/Ioxide/IoxideFilesHandler.cs @@ -60,6 +60,8 @@ public ValueTask PrepareAsync(IServer server) var target = request.Header.Target; + target.DenyPathTraversal(); + if (target.HasTrailingSlash) { return default; // a directory request, not a file (no directory index) diff --git a/Modules/IO/Extensions.Request.Security.cs b/Modules/IO/Extensions.Request.Security.cs new file mode 100644 index 00000000..cf65f4df --- /dev/null +++ b/Modules/IO/Extensions.Request.Security.cs @@ -0,0 +1,58 @@ +using System.Runtime.CompilerServices; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; + +namespace GenHTTP.Modules.IO; + +public static class RequestSecurityExtensions +{ + + /// + /// Checks all remaining, non-routed segments of the request target for + /// path traversal attacks and throws a provider exception if one + /// is detected. + /// + /// The request target to be checked + public static void DenyPathTraversal(this IRequestTarget target) + { + var index = 0; + + PathSegment? segment; + + while ((segment = target.Next(index++)) != null) + { + if (IsDotSegment(segment.Value.Bytes.Span)) + { + throw new ProviderException(ResponseStatus.BadRequest, "Potential path traversal detected"); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDotSegment(ReadOnlySpan segment) + { + var dots = 0; + + for (var i = 0; i < segment.Length;) + { + if (segment[i] == (byte)'.') + { + dots++; + i++; + } + else if (segment[i] == (byte)'%' && i + 2 < segment.Length && segment[i + 1] == (byte)'2' && (segment[i + 2] == (byte)'e' || segment[i + 2] == (byte)'E')) + { + dots++; + i += 3; + } + else + { + return false; + } + } + + return dots is 1 or 2; + } + +} diff --git a/Modules/SinglePageApplications/Provider/SinglePageProvider.cs b/Modules/SinglePageApplications/Provider/SinglePageProvider.cs index 7c731003..0aca61cb 100644 --- a/Modules/SinglePageApplications/Provider/SinglePageProvider.cs +++ b/Modules/SinglePageApplications/Provider/SinglePageProvider.cs @@ -44,7 +44,9 @@ public SinglePageProvider(IResourceTree tree, bool serverSideRouting) public async ValueTask HandleAsync(IRequest request) { - if (request.Header.Target.Current == null) + var target = request.Header.Target; + + if (target.Current == null) { var index = await GetIndex(); @@ -55,6 +57,8 @@ public SinglePageProvider(IResourceTree tree, bool serverSideRouting) } else { + target.DenyPathTraversal(); + var result = await Resources.HandleAsync(request); if (result == null) diff --git a/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs b/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs index ab2f5544..998ce28a 100644 --- a/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs +++ b/Modules/StaticWebsites/Provider/StaticWebsiteHandler.cs @@ -38,6 +38,8 @@ public StaticWebsiteHandler(IResourceTree tree) public async ValueTask HandleAsync(IRequest request) { var target = request.Header.Target; + + target.DenyPathTraversal(); if (target.HasTrailingSlash) { diff --git a/Testing/Acceptance/Engine/Body/DrainBodyTests.cs b/Testing/Acceptance/Engine/Body/DrainBodyTests.cs index 075bf0ed..e65a6c28 100644 --- a/Testing/Acceptance/Engine/Body/DrainBodyTests.cs +++ b/Testing/Acceptance/Engine/Body/DrainBodyTests.cs @@ -2,7 +2,6 @@ using GenHTTP.Api.Content; using GenHTTP.Api.Infrastructure; using GenHTTP.Api.Protocol; -using Microsoft.VisualStudio.TestPlatform.CrossPlatEngine; namespace GenHTTP.Testing.Acceptance.Engine.Body; diff --git a/Testing/Acceptance/Modules/IO/PathTraversalTests.cs b/Testing/Acceptance/Modules/IO/PathTraversalTests.cs new file mode 100644 index 00000000..cb3c13b8 --- /dev/null +++ b/Testing/Acceptance/Modules/IO/PathTraversalTests.cs @@ -0,0 +1,157 @@ +using System.Net; + +using GenHTTP.Api.Infrastructure; + +using GenHTTP.Modules.DirectoryBrowsing; +using GenHTTP.Modules.Files; +using GenHTTP.Modules.IO; +using GenHTTP.Modules.SinglePageApplications; +using GenHTTP.Modules.StaticWebsites; + +namespace GenHTTP.Testing.Acceptance.Modules.IO; + +/// +/// Verifies that every file serving handler rejects path traversal attempts +/// via . +/// +/// +/// Each handler is probed with a different forged segment so that, taken +/// together, the tests exercise all branches of the detection logic (plain +/// dots, upper- and lower-case percent encoding, mixed forms and the negative +/// cases that must not be denied). +/// +[TestClass] +public sealed class PathTraversalTests +{ + + #region Denial (one case per affected handler) + + [TestMethod] + [MultiEngineTest] + public async Task TestAssetsTreeDeniesEncodedTraversal(ServerEngine engine) + { + // TreeAssetsHandler (via AbstractAssetsHandler) - percent encoded ".." ("%2e%2e") + await using var runner = await TestHost.RunAsync(Assets.From(ResourceTree.FromDirectory(CreateRoot())), engine: engine); + + using var response = await GetForgedAsync(runner, "/sub/%2e%2e/file.txt"); + + await response.AssertStatusAsync(HttpStatusCode.BadRequest); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestAssetsDirectoryDeniesLiteralTraversal(ServerEngine engine) + { + // BuiltInFileAssetHandler (via AbstractAssetsHandler) - literal ".." + await using var runner = await TestHost.RunAsync(Assets.From(CreateRoot()), engine: engine); + + using var response = await GetForgedAsync(runner, "/sub/../file.txt"); + + await response.AssertStatusAsync(HttpStatusCode.BadRequest); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestStaticWebsiteDeniesUppercaseEncodedTraversal(ServerEngine engine) + { + // StaticWebsiteHandler trailing slash branch (bypasses AbstractAssetsHandler) - upper case "%2E" + await using var runner = await TestHost.RunAsync(StaticWebsite.From(ResourceTree.FromDirectory(CreateRoot())), engine: engine); + + using var response = await GetForgedAsync(runner, "/sub/%2E/"); + + await response.AssertStatusAsync(HttpStatusCode.BadRequest); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestSinglePageDeniesMixedTraversal(ServerEngine engine) + { + // SinglePageProvider - mixed literal and encoded dot (".%2e") + await using var runner = await TestHost.RunAsync(SinglePageApplication.From(ResourceTree.FromDirectory(CreateRoot())), engine: engine); + + using var response = await GetForgedAsync(runner, "/app/.%2e/config.json"); + + await response.AssertStatusAsync(HttpStatusCode.BadRequest); + } + + [TestMethod] + [MultiEngineTest] + public async Task TestDirectoryBrowsingDeniesSingleDot(ServerEngine engine) + { + // ListingRouter (bypasses AbstractAssetsHandler) - single "." + await using var runner = await TestHost.RunAsync(Listing.From(ResourceTree.FromDirectory(CreateRoot())), engine: engine); + + using var response = await GetForgedAsync(runner, "/sub/./"); + + await response.AssertStatusAsync(HttpStatusCode.BadRequest); + } + + #endregion + + #region Non-traversal segments (completes detection coverage) + + [TestMethod] + [MultiEngineTest] + public async Task TestSuspiciousButValidSegmentsAreAllowed(ServerEngine engine) + { + await using var runner = await TestHost.RunAsync(Assets.From(ResourceTree.FromDirectory(CreateRoot())), engine: engine); + + // a genuine file is still served (first byte is not a dot - detection returns immediately) + using var served = await GetForgedAsync(runner, "/file.txt"); + await served.AssertStatusAsync(HttpStatusCode.OK); + + // the following segments look suspicious but are not "." or ".." - the guard must let them + // through (their resolved status is filesystem dependent, so we only assert they are not denied): + + // three dots - the dot count is neither 1 nor 2 + using var tripleDot = await GetForgedAsync(runner, "/.../file.txt"); + Assert.AreNotEqual(HttpStatusCode.BadRequest, tripleDot.StatusCode); + + // percent escape whose first digit is not '2' ("%41" = 'A') + using var notTwo = await GetForgedAsync(runner, "/%41/file.txt"); + Assert.AreNotEqual(HttpStatusCode.BadRequest, notTwo.StatusCode); + + // percent escape "%2x" where x is neither 'e' nor 'E' ("%2b" = '+') + using var notDot = await GetForgedAsync(runner, "/%2b/file.txt"); + Assert.AreNotEqual(HttpStatusCode.BadRequest, notDot.StatusCode); + + // trailing, incomplete percent escape - not enough characters to form "%2e" + using var incomplete = await GetForgedAsync(runner, "/.%/file.txt"); + Assert.AreNotEqual(HttpStatusCode.BadRequest, incomplete.StatusCode); + } + + #endregion + + #region Helpers + + private static string CreateRoot() + { + var root = Directory.CreateTempSubdirectory(); + + File.WriteAllText(Path.Combine(root.FullName, "file.txt"), "This is a file"); + + root.CreateSubdirectory("sub"); + + return root.FullName; + } + + /// + /// Sends a request without client side canonicalization, so that forged + /// segments such as ".." or "%2e%2e" actually reach the server instead of + /// being collapsed by . + /// + private static Task GetForgedAsync(TestHost runner, string rawPath) + { + var uri = new Uri(runner.GetUrl(rawPath), new UriCreationOptions + { + DangerousDisablePathAndQueryCanonicalization = true + }); + + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + return runner.GetResponseAsync(request); + } + + #endregion + +} From e1a4ea36ca17048264bee8724be2ac0b1ae2e869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Thu, 10 Sep 2026 14:05:09 +0200 Subject: [PATCH 3/5] Fix potential smuggling vector in RangedStream --- Modules/IO/Ranges/RangedStream.cs | 26 ++++--------- .../Modules/IO/RangedStreamTests.cs | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/Modules/IO/Ranges/RangedStream.cs b/Modules/IO/Ranges/RangedStream.cs index 0d988d58..4b93dc17 100644 --- a/Modules/IO/Ranges/RangedStream.cs +++ b/Modules/IO/Ranges/RangedStream.cs @@ -52,31 +52,19 @@ public RangedStream(Stream target, ulong start, ulong end) public override void Write(byte[] buffer, int offset, int count) { - if (Position > End) + if (Position > End || (Position + count) <= Start) { + Position += count; return; } - long actualOffset = offset; - long actualCount = count; + var writeStart = Math.Max(Position, Start); + var writeEnd = Math.Min(Position + count - 1, End); - if (Position < Start) - { - actualOffset += (int)(Start - Position); - actualCount -= (int)(Start - Position); - } - - if ((Start + actualCount) > (End + 1)) - { - actualCount = Math.Min(End - Start + 1, actualCount); - } - - if (actualOffset < buffer.Length) - { - var toWrite = Math.Min(buffer.Length - actualOffset, actualCount); + var actualOffset = offset + (writeStart - Position); + var actualCount = writeEnd - writeStart + 1; - Target.Write(buffer, (int)actualOffset, (int)toWrite); - } + Target.Write(buffer, (int)actualOffset, (int)actualCount); Position += count; } diff --git a/Testing/Acceptance/Modules/IO/RangedStreamTests.cs b/Testing/Acceptance/Modules/IO/RangedStreamTests.cs index 4b761c3f..4d113b0a 100644 --- a/Testing/Acceptance/Modules/IO/RangedStreamTests.cs +++ b/Testing/Acceptance/Modules/IO/RangedStreamTests.cs @@ -49,6 +49,44 @@ public void TestOutOfLargeFile() Assert.AreEqual("", GetRange(0, 10_000, 0, 10, 15_000)); } + [TestMethod] + public void TestEndClampsToPositionNotStart() + { + Assert.AreEqual("01", GetRange(2, 5, 0, 10, 4)); + } + + [TestMethod] + public void TestMultipleBuffersDoNotExceedRange() + { + using var target = new MemoryStream(); + + const int bufferSize = 8; + const ulong start = 6; + const ulong end = 17; // inclusive -> 12 bytes expected + + using var stream = new RangedStream(target, start, end); + + var source = new byte[32]; + for (var i = 0; i < source.Length; i++) + { + source[i] = (byte)i; + } + + for (var offset = 0; offset < source.Length; offset += bufferSize) + { + stream.Write(source, offset, bufferSize); + } + + var written = target.ToArray(); + + Assert.AreEqual((int)(end - start + 1), written.Length); + + for (var i = 0; i < written.Length; i++) + { + Assert.AreEqual((byte)(start + (ulong)i), written[i]); + } + } + [TestMethod] public void TestBasics() { From 611f7d261520ad3e2d440d28660376ad082bc4b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Thu, 10 Sep 2026 14:22:29 +0200 Subject: [PATCH 4/5] Fix a potential DoS vector with body.AsMemory() --- Engine/Shared/Types/Body/ChunkedBodyStream.cs | 2 +- .../Types/Body/MemoryConsumptionStrategy.cs | 22 +++++++---- .../Engine/Body/ChunkedBodyStreamTests.cs | 4 +- .../Acceptance/Engine/Body/MemoryBodyTests.cs | 37 +++++++++++++++++++ 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/Engine/Shared/Types/Body/ChunkedBodyStream.cs b/Engine/Shared/Types/Body/ChunkedBodyStream.cs index dc9d6f99..324f41a7 100644 --- a/Engine/Shared/Types/Body/ChunkedBodyStream.cs +++ b/Engine/Shared/Types/Body/ChunkedBodyStream.cs @@ -98,7 +98,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (result.IsCompleted) { - throw new InvalidDataException("Unexpected end of chunked body"); + throw new ProviderException(ResponseStatus.BadRequest, "Unexpected end of chunked body"); } continue; diff --git a/Engine/Shared/Types/Body/MemoryConsumptionStrategy.cs b/Engine/Shared/Types/Body/MemoryConsumptionStrategy.cs index 45c526fc..0d2a2b21 100644 --- a/Engine/Shared/Types/Body/MemoryConsumptionStrategy.cs +++ b/Engine/Shared/Types/Body/MemoryConsumptionStrategy.cs @@ -1,5 +1,7 @@ using System.Buffers; using System.IO.Pipelines; +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; namespace GenHTTP.Engine.Shared.Types.Body; @@ -41,7 +43,7 @@ public ValueTask> ObtainAsync() { return new(_memory.Value); } - + if (_length is not null) { return ReadLength(); @@ -55,7 +57,7 @@ private async ValueTask> ReadLength() var reader = Reader; var length = _length!.Value; - + while (true) { var result = await reader.ReadAsync(); @@ -63,6 +65,12 @@ private async ValueTask> ReadLength() if (result.Buffer.Length < length) { reader.AdvanceTo(result.Buffer.Start, result.Buffer.End); + + if (result.IsCompleted) + { + throw new ProviderException(ResponseStatus.BadRequest, "Unexpected end of body"); + } + continue; } @@ -83,15 +91,15 @@ private async ValueTask> ReadLength() return (_memory = linearized).Value; } } - + private async ValueTask> ReadChunked() { var chunkedStream = new ChunkedBodyStream(Reader); - + var writer = new ArrayBufferWriter(); var pool = ArrayPool.Shared; - + var buffer = pool.Rent(16 * 1024); try @@ -115,10 +123,10 @@ private async ValueTask> ReadChunked() } await chunkedStream.DrainAsync(); - + return (_memory = writer.WrittenMemory).Value; } - + public ValueTask DrainAsync() { if (_readResult is not null) diff --git a/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs b/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs index 5384a79f..8c442d75 100644 --- a/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs +++ b/Testing/Acceptance/Engine/Body/ChunkedBodyStreamTests.cs @@ -1,6 +1,6 @@ using System.IO.Pipelines; using System.Text; - +using GenHTTP.Api.Content; using GenHTTP.Engine.Shared.Types.Body; namespace GenHTTP.Testing.Acceptance.Engine.Body; @@ -87,7 +87,7 @@ public async Task TestTruncatedChunkThrows() var stream = await CreateAsync("5\r\nHel"); #pragma warning disable CA2022 // expected to throw before any byte count is returned - await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync(new byte[16])); + await Assert.ThrowsExactlyAsync(async () => await stream.ReadAsync(new byte[16])); #pragma warning restore CA2022 } diff --git a/Testing/Acceptance/Engine/Body/MemoryBodyTests.cs b/Testing/Acceptance/Engine/Body/MemoryBodyTests.cs index 3e6a8eb7..83a4afe4 100644 --- a/Testing/Acceptance/Engine/Body/MemoryBodyTests.cs +++ b/Testing/Acceptance/Engine/Body/MemoryBodyTests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Net.Sockets; +using System.Text; using GenHTTP.Api.Content; using GenHTTP.Api.Infrastructure; using GenHTTP.Api.Protocol; @@ -173,6 +175,41 @@ public async Task TestKeepAliveChunked(ServerEngine engine) CollectionAssert.AreEqual(second, await resp2.Content.ReadAsByteArrayAsync()); } + [TestMethod] + public async Task TestTruncatedContentLengthDoesNotSpin() + { + await using var runner = await TestHost.RunAsync(new BodyEchoHandler().Wrap()); + + using var client = new TcpClient("127.0.0.1", runner.Port) + { + ReceiveTimeout = 15000 + }; + + var stream = client.GetStream(); + + var request = "POST / HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Content-Length: 100\r\n" + + "\r\n" + + "short"; // only 5 of the promised 100 bytes + + var bytes = Encoding.ASCII.GetBytes(request); + + await stream.WriteAsync(bytes); + await stream.FlushAsync(); + + // signal end of the request body without ever sending the rest + client.Client.Shutdown(SocketShutdown.Send); + + using var reader = new StreamReader(stream, leaveOpen: true); + + var response = await reader.ReadToEndAsync(); + + // the connection is closed cleanly with an error instead of spinning; + // an empty response would mean the server never got past the body read + Assert.IsTrue(response.StartsWith("HTTP/1.1 400", StringComparison.Ordinal), response); + } + #endregion #region Supporting types From 1c66727c060bd059a39c2ce060e6dbe131b61c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Fri, 11 Sep 2026 15:36:23 +0200 Subject: [PATCH 5/5] Fix ioxide not sending responses for parser errors --- Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj | 4 ++- .../Protocol/Drivers/Tcp/Http1Driver.cs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj index 65c05ad7..aa0549a6 100644 --- a/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj +++ b/Engine/Ioxide/GenHTTP.Engine.Ioxide.csproj @@ -13,7 +13,9 @@ - + + + diff --git a/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs b/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs index cae1f623..ca72d014 100644 --- a/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs +++ b/Engine/Ioxide/Protocol/Drivers/Tcp/Http1Driver.cs @@ -7,6 +7,7 @@ using GenHTTP.Engine.Shared.Types; +using Glyph11; using Glyph11.Parser; using Glyph11.Parser.UltraHardened; using Glyph11.Pico; @@ -18,6 +19,8 @@ using IoConnection = ioxide.TcpConnection; using GenHTTP.Engine.Ioxide.Protocol.Responses; +using StringContent = GenHTTP.Modules.IO.Strings.StringContent; + namespace GenHTTP.Engine.Ioxide.Protocol.Drivers.Tcp; /// Serves HTTP/1.1 on one connection, request after request. @@ -105,6 +108,10 @@ internal static async Task RunAsync(IServer server, IEndPoint endPoint, IDuplexP } } } + catch (HttpParseException pe) + { + await SendErrorAsync(server, writer, pe, (ResponseStatus)pe.StatusCode); + } finally { WarnIfThreadHopped(server, reactorThreadId, "before-return"); @@ -115,6 +122,28 @@ internal static async Task RunAsync(IServer server, IEndPoint endPoint, IDuplexP } } + private static async ValueTask SendErrorAsync(IServer server, PipeWriter writer, Exception e, ResponseStatus status) + { + try + { + var message = server.Development ? e.ToString() : e.Message; + + var response = new ResponseBuilder() + .Status(status) + .Connection(Connection.Close) + .Content(new StringContent(message)) + .Build(); + + await Http1Responder.WriteAsync(writer, null, response, keepAlive: false, headRequest: false); + + await writer.FlushAsync(); + } + catch + { + /* no recovery here */ + } + } + // Parses one request head, through whichever parser this process was started with. private static bool TryParseRequest(ref ReadOnlySequence buffer, BinaryRequest into) => UsePico ? TryParseRequestPico(ref buffer, into) : TryParseRequestGlyph11(ref buffer, into);