using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; namespace XFESystem.Browser.Tests; public sealed class HttpWebTransportTests { [Fact] public async Task ActualNosniffHeaderAndBrowserIdentityReachTheTransportBoundary() { string requestText = ""; using var server = new TestHttpServer((request, _) => { requestText = request; return Response("actual", "X-Content-Type-Options: NoSniff\r\n"); }); using var transport = new HttpWebTransport(); Assert.True(transport.Get(new(server.Address), new()).NoSniff); Assert.Contains("User-Agent: " + BrowserIdentity.UserAgent, requestText, StringComparison.Ordinal); var page = new BrowserService(); try { page.Navigate("about:Start"); Assert.Equal(BrowserIdentity.UserAgent, page.ExecuteJavaScript("navigator.userAgent")); } finally { page.Release(); } await server.WaitForRequests(1); } [Fact] public async Task ScriptHeadersReachTheWireButForbiddenFieldsAndCrossOriginRedirectsDoNot() { string captured = ""; bool escaped = false; using var other = new TestHttpServer((_, _) => { escaped = true; return Response("foreign"); }); using var server = new TestHttpServer((request, _) => { captured = request; return request.StartsWith("GET /Redirect ", StringComparison.Ordinal) ? "HTTP/1.1 302 Found\r\nLocation: " + other.Address + "\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" : Response("actual"); }); using var transport = new HttpWebTransport(); transport.Get(new(server.Address), new(), new Dictionary { ["X-Autosuggest-ContentWidth"] = "800", ["Content-Type"] = "application/json", ["Cookie"] = "forged", ["Host"] = "forged.test" }); Assert.Contains("X-Autosuggest-ContentWidth: 800", captured, StringComparison.OrdinalIgnoreCase); Assert.Contains("Content-Type: application/json", captured, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("forged", captured, StringComparison.Ordinal); Assert.Throws(() => transport.Get(new(server.Address + "Redirect"), new(), new Dictionary { ["Authorization"] = "test-only" })); await server.WaitForRequests(2); Assert.False(escaped); Assert.Throws(() => BrowserRequestHeaders.Copy(new Dictionary { ["X-Test"] = "a\r\nInjected: value" })); } [Fact] public async Task ActualLoopbackHttpRedirectAndHtmlJavaScriptWorkThroughTheExplicitTransport() { using var server = new TestHttpServer((request, _) => request.StartsWith("GET /Start ", StringComparison.Ordinal) ? "HTTP/1.1 302 Found\r\nLocation: /Page\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" : Response("Loopback

Before

")); using var transport = new HttpWebTransport(); var page = new BrowserService(transport).Navigate(server.Address + "Start"); Assert.Equal("Loopback", page.Title); Assert.Equal("Network JS 42", page.Text); Assert.EndsWith("/Page", page.Address, StringComparison.Ordinal); await server.WaitForRequests(2); } [Fact] public async Task RedirectLoopsAndOversizedBodiesAreRejected() { using var redirect = new TestHttpServer((_, _) => "HTTP/1.1 302 Found\r\nLocation: /Again\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); using var transport = new HttpWebTransport(); var page = new BrowserService(transport, new BrowserLimits { MaximumRedirects = 1 }).Navigate(redirect.Address); Assert.Contains("redirect limit", page.Text, StringComparison.Ordinal); await redirect.WaitForRequests(2); using var oversized = new TestHttpServer((_, _) => Response(new string('X', 2_000))); var limited = new BrowserService(transport, new BrowserLimits { MaximumHtmlCharacters = 100 }); Assert.Equal("Navigation failed", limited.Navigate(oversized.Address).Title); await oversized.WaitForRequests(1); } [Fact] public async Task HttpCspHeaderPreventsAutomaticInlineScriptExecution() { using var server = new TestHttpServer((_, _) => Response("

Original

", "Content-Security-Policy: script-src 'none'\r\n")); using var transport = new HttpWebTransport(); var page = new BrowserService(transport).Navigate(server.Address); Assert.Contains("Original", page.Text, StringComparison.Ordinal); Assert.DoesNotContain("Forbidden", page.Text, StringComparison.Ordinal); Assert.Contains("Automatic scripts blocked", page.Text, StringComparison.Ordinal); await server.WaitForRequests(1); } [Fact] public async Task ActualTlsConnectionRejectsAnUntrustedCertificate() { using var key = RSA.Create(2048); var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var san = new SubjectAlternativeNameBuilder(); san.AddDnsName("localhost"); san.AddIpAddress(IPAddress.Loopback); request.CertificateExtensions.Add(san.Build()); using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddMinutes(5)); var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var serving = Task.Run(async () => { using var client = await listener.AcceptTcpClientAsync(cancellation.Token); await using var tls = new SslStream(client.GetStream()); try { await tls.AuthenticateAsServerAsync(new SslServerAuthenticationOptions { ServerCertificate = certificate, EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13 }, cancellation.Token); await tls.WriteAsync(Encoding.UTF8.GetBytes(Response("

Must not be trusted

")), cancellation.Token); } catch (Exception exception) when (exception is AuthenticationException or IOException) { } }, cancellation.Token); try { using var transport = new HttpWebTransport(); var page = new BrowserService(transport, new BrowserLimits { NetworkTimeout = TimeSpan.FromSeconds(5) }) .Navigate($"https://127.0.0.1:{port}/"); Assert.Equal("Navigation failed", page.Title); Assert.DoesNotContain("Must not be trusted", page.Text, StringComparison.Ordinal); await serving.WaitAsync(TimeSpan.FromSeconds(8)); } finally { listener.Stop(); } } private static string Response(string html, string headers = "") => "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n" + headers + $"Content-Length: {Encoding.UTF8.GetByteCount(html)}\r\nConnection: close\r\n\r\n" + html; private sealed class TestHttpServer : IDisposable { private readonly TcpListener listener = new(IPAddress.Loopback, 0); private readonly CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); private readonly Task serving; private int requests; public TestHttpServer(Func response) { listener.Start(); Address = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}/"; serving = Task.Run(async () => { while (!cancellation.IsCancellationRequested) { try { using var client = await listener.AcceptTcpClientAsync(cancellation.Token); await using var stream = client.GetStream(); var header = new StringBuilder(); var one = new byte[1]; while (header.Length < 8_192 && !header.ToString().EndsWith("\r\n\r\n", StringComparison.Ordinal)) { if (await stream.ReadAsync(one, cancellation.Token) == 0) break; header.Append((char)one[0]); } var count = Interlocked.Increment(ref requests); var body = Encoding.UTF8.GetBytes(response(header.ToString(), count)); await stream.WriteAsync(body, cancellation.Token); } catch (Exception exception) when (exception is OperationCanceledException or SocketException or IOException) { break; } // Stop() may dispose the Windows accept socket after the // loop condition but before AcceptAsync registers its wait. catch (ObjectDisposedException) when (cancellation.IsCancellationRequested) { break; } } }, cancellation.Token); } public string Address { get; } public async Task WaitForRequests(int count) { var deadline = DateTime.UtcNow.AddSeconds(5); while (Volatile.Read(ref requests) < count && DateTime.UtcNow < deadline) await Task.Delay(10); Assert.Equal(count, Volatile.Read(ref requests)); } public void Dispose() { cancellation.Cancel(); listener.Stop(); try { serving.GetAwaiter().GetResult(); } catch (OperationCanceledException) { } cancellation.Dispose(); } } }