mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
261 lines
9.0 KiB
C#
261 lines
9.0 KiB
C#
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<InvalidDataException>(() =>
|
|
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<InvalidDataException>(() =>
|
|
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<ParserLimitException>(() =>
|
|
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<ParserLimitException>(() =>
|
|
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<ParserLimitException>(() =>
|
|
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<InvalidDataException>(() =>
|
|
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<ParserLimitException>(() =>
|
|
response.Compose(HttpComposeOption.DataOnly));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Session_TimerStartsAndServerRemovesExpiredSession()
|
|
{
|
|
var server = new HttpServer();
|
|
var ended = new TaskCompletionSource<bool>(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("<script>alert('x')</script>&");
|
|
|
|
Assert.DoesNotContain("<script>", page, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("<script>", page, StringComparison.Ordinal);
|
|
Assert.Contains("&", page, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static void AssertFormLimit(string body, Action<HttpRequestPacket> configure)
|
|
{
|
|
var data = RequestBytes(
|
|
"POST",
|
|
$"Content-Type: application/x-www-form-urlencoded\r\nContent-Length: {body.Length}\r\n",
|
|
body);
|
|
var packet = new HttpRequestPacket();
|
|
configure(packet);
|
|
|
|
Assert.Throws<ParserLimitException>(() =>
|
|
packet.Parse(data, 0, (uint)data.Length));
|
|
}
|
|
|
|
private static byte[] RequestBytes(string method, string headers, string body)
|
|
=> Encoding.ASCII.GetBytes($"{method} / HTTP/1.1\r\n{headers}\r\n{body}");
|
|
}
|