using System.Collections; using System.Reflection; using System.Text; using Esiur.Data; using Esiur.Net.Http; using Esiur.Net.Packets.Http; namespace Esiur.Tests.Unit; public class HttpHardeningTests { [Theory] [InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")] [InlineData("Content-Length: 1\r\nContent-Length: 2\r\n")] public void Request_RejectsDuplicateContentLength(string framingHeaders) { var data = RequestBytes("POST", framingHeaders, "x"); Assert.Throws(() => new HttpRequestPacket().Parse(data, 0, (uint)data.Length)); } [Theory] [InlineData("Transfer-Encoding: chunked\r\n")] [InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")] public void Request_RejectsUnsupportedTransferEncoding(string framingHeaders) { var data = RequestBytes("POST", framingHeaders, "x"); Assert.Throws(() => new HttpRequestPacket().Parse(data, 0, (uint)data.Length)); } [Theory] [InlineData("PUT")] [InlineData("GET")] [InlineData("DELETE")] public void Request_ConsumesDeclaredBodiesForEveryMethod(string method) { var data = RequestBytes( method, "Content-Type: application/octet-stream\r\nContent-Length: 3\r\n", "abc"); var packet = new HttpRequestPacket(); Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); Assert.Equal("abc", Encoding.ASCII.GetString(packet.Message)); } [Fact] public void Request_AllowsPostWithoutABodyOrContentLength() { var data = RequestBytes("POST", string.Empty, string.Empty); var packet = new HttpRequestPacket(); Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); Assert.Empty(packet.PostForms); } [Fact] public void Request_EnforcesHeaderCountBeforeSplittingHeaders() { var data = RequestBytes("GET", "X-One: 1\r\nX-Two: 2\r\n", string.Empty); var packet = new HttpRequestPacket { MaximumHeaderCount = 1 }; Assert.Throws(() => packet.Parse(data, 0, (uint)data.Length)); } [Fact] public void UrlEncodedForm_EnforcesFieldKeyAndValueLimits() { AssertFormLimit("a=1&b=2", packet => packet.MaximumFormFields = 1); AssertFormLimit("long=1", packet => packet.MaximumFormKeyLength = 3); AssertFormLimit("a=long", packet => packet.MaximumFormValueLength = 3); } [Fact] public void UrlEncodedForm_CombinesUnkeyedFieldsWithinTheConfiguredBound() { const string body = "first&second&third"; var data = RequestBytes( "POST", $"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {body.Length}\r\n", body); var packet = new HttpRequestPacket { MaximumFormValueLength = body.Length }; Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); Assert.Equal(body, packet.PostForms["unknown"]); packet = new HttpRequestPacket { MaximumFormValueLength = body.Length - 1 }; Assert.Throws(() => packet.Parse(data, 0, (uint)data.Length)); } [Fact] public void MultipartForm_EnforcesPartLength() { const string boundary = "test-boundary"; const string body = "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"value\"\r\n\r\n" + "payload\r\n" + "--test-boundary--\r\n"; var data = RequestBytes( "POST", $"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n", body); var packet = new HttpRequestPacket { MaximumMultipartPartLength = 8 }; Assert.Throws(() => packet.Parse(data, 0, (uint)data.Length)); } [Fact] public void MultipartForm_ParsesQuotedBoundaryIncrementally() { const string boundary = "test-boundary"; const string body = "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"first\"\r\n\r\n" + "one\r\n" + "--test-boundary\r\n" + "Content-Disposition: form-data; name=\"second\"\r\n\r\n" + "two\r\n" + "--test-boundary--\r\n"; var data = RequestBytes( "POST", $"Content-Type: multipart/form-data; boundary=\"{boundary}\"\r\n" + $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n", body); var packet = new HttpRequestPacket(); Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length)); Assert.Equal("one", packet.PostForms["first"]); Assert.Equal("two", packet.PostForms["second"]); } [Theory] [InlineData("Content-Length: 1\r\nContent-Length: 1\r\n")] [InlineData("Transfer-Encoding: chunked\r\n")] [InlineData("Transfer-Encoding: identity\r\nContent-Length: 1\r\n")] public void Response_RejectsAmbiguousOrUnsupportedFraming(string framingHeaders) { var data = Encoding.ASCII.GetBytes( "HTTP/1.1 200 OK\r\n" + framingHeaders + "\r\nx"); Assert.Throws(() => new HttpResponsePacket().Parse(data, 0, (uint)data.Length)); } [Fact] public void Cookie_RoundTripsSecureAndSameSiteAttributes() { var cookie = new HttpCookie("sid", "value") { Path = "/", HttpOnly = true, Secure = true, SameSite = HttpCookieSameSite.Strict, }; var serialized = cookie.ToString(); Assert.Contains("; Secure", serialized, StringComparison.Ordinal); Assert.Contains("; SameSite=Strict", serialized, StringComparison.Ordinal); var data = Encoding.ASCII.GetBytes( "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nSet-Cookie: " + serialized + "\r\n\r\n"); var response = new HttpResponsePacket(); Assert.Equal(data.Length, response.Parse(data, 0, (uint)data.Length)); Assert.True(response.Cookies[0].Secure); Assert.Equal(HttpCookieSameSite.Strict, response.Cookies[0].SameSite); } [Fact] public void Response_ComposeUsesAnExplicitOutboundLimitOnly() { var response = new HttpResponsePacket { MaximumContentLength = 1, Message = new byte[] { 1, 2 }, }; Assert.True(response.Compose(HttpComposeOption.DataOnly)); Assert.Equal(response.Message, response.Data); response.MaximumComposedContentLength = 1; Assert.Throws(() => response.Compose(HttpComposeOption.DataOnly)); } [Fact] public async Task Session_TimerStartsAndServerRemovesExpiredSession() { var server = new HttpServer(); var ended = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var session = server.CreateSession("expiring", 1); session.OnEnd += _ => ended.TrySetResult(true); await ended.Task.WaitAsync(TimeSpan.FromSeconds(3)); var sessions = (IDictionary)typeof(HttpServer) .GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(server)!; Assert.Empty(sessions); // Destruction remains safe after the expiry callback has already disposed the timer. session.Destroy(); } [Fact] public void Session_WithoutATimerCanBeDestroyedIdempotently() { var session = new HttpSession(); session.Destroy(); session.Destroy(); } [Fact] public void ExplicitlyDestroyedSession_IsRemovedFromServer() { var server = new HttpServer(); var session = server.CreateSession("destroyed", 0); session.Destroy(); var sessions = (IDictionary)typeof(HttpServer) .GetField("sessions", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(server)!; Assert.Empty(sessions); } [Fact] public void InternalServerErrorPage_HtmlEncodesExceptionText() { var page = HttpConnection.FormatError500Page("&"); Assert.DoesNotContain("