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 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; } } }, 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(); } } }