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("<title>Loopback</title><p id='Value'>Before</p><script>document.getElementById('Value').textContent = 'Network JS ' + (6 * 7);</script>"));
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("<p id='Value'>Original</p><script>document.getElementById('Value').textContent='Forbidden'</script>",
"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("<p>Must not be trusted</p>")), 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<string, int, string> 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();
}
}
}
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("<title>Loopback</title><p id='Value'>Before</p><script>document.getElementById('Value').textContent = 'Network JS ' + (6 * 7);</script>"));
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("<p id='Value'>Original</p><script>document.getElementById('Value').textContent='Forbidden'</script>",
"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("<p>Must not be trusted</p>")), 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<string, int, string> 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();
}
}
}