using System.Net; using System.Net.Http.Headers; using System.Reflection; using GameList.Infrastructure; using GameList.Endpoints; using GameList.Tests.Support; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using System.Text.Json; using System.Net.Http.Json; namespace GameList.Tests; public class HelperTests { [Fact] public void PasswordHasher_roundtrip_and_empty_guard() { var (hash, salt) = PasswordHasher.HashPassword("secret"); Assert.True(PasswordHasher.Verify("secret", hash, salt)); Assert.False(PasswordHasher.Verify("other", hash, salt)); Assert.Throws(() => PasswordHasher.HashPassword("")); } [Fact] public void Program_does_not_include_runtime_index_rewrite_hook() { var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal)); Assert.False(hasRewriteMethod); } [Fact] public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image() { Assert.True(await EndpointHelpers.IsReachableImageAsync(null, new StubHttpClientFactory(new StubHttpMessageHandler()))); Assert.False(await EndpointHelpers.IsReachableImageAsync("http://127.0.0.1/img.png", new StubHttpClientFactory(new StubHttpMessageHandler()))); } [Fact] public async Task IsReachableImageAsync_handles_head_success_redirect_and_size_guard() { var handler = new StubHttpMessageHandler(); handler.SetResponder(req => { if (req.Method == HttpMethod.Head) { var resp = new HttpResponseMessage(HttpStatusCode.OK); resp.Content = new ByteArrayContent([]); resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); resp.Content.Headers.ContentLength = 100; return resp; } return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent([]) { Headers = { ContentType = new MediaTypeHeaderValue("image/png"), ContentLength = 100 } } }; }); var ok = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); Assert.True(ok); handler.SetResponder(_ => { var resp = new HttpResponseMessage(HttpStatusCode.Redirect); resp.Headers.Location = new Uri("http://example.com/other"); return resp; }); var redirect = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); Assert.False(redirect); handler.SetResponder(_ => { var resp = new HttpResponseMessage(HttpStatusCode.OK); resp.Content = new ByteArrayContent(new byte[10]); resp.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); resp.Content.Headers.ContentLength = 6 * 1024 * 1024; // over 5 MB return resp; }); var tooLarge = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); Assert.False(tooLarge); } [Fact] public async Task IsReachableImageAsync_rejects_followed_redirect_chain() { var handler = new RedirectFollowingHandler(); var reachable = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(new StubHttpMessageHandler()), handler); Assert.False(reachable); } [Fact] public async Task IsReachableImageAsync_rejects_non_image_content() { var handler = new StubHttpMessageHandler(); handler.SetResponder(_ => { var resp = new HttpResponseMessage(HttpStatusCode.OK); resp.Content = new ByteArrayContent("not image"u8.ToArray()); resp.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); resp.Content.Headers.ContentLength = 9; return resp; }); var result = await EndpointHelpers.IsReachableImageAsync("http://example.com/img.png", new StubHttpClientFactory(handler), handler); Assert.False(result); } [Fact] public async Task IsReachableImageAsync_rejects_private_and_reserved_ranges() { var factory = new StubHttpClientFactory(new StubHttpMessageHandler()); var blockedUrls = new[] { "http://0.0.0.1/img.png", "http://10.0.0.1/img.png", "http://100.64.1.1/img.png", "http://169.254.169.254/img.png", "http://192.168.0.20/img.png", "http://198.51.100.2/img.png", "http://203.0.113.8/img.png", "http://[::1]/img.png", "http://[fc00::1]/img.png", "http://[::ffff:127.0.0.1]/img.png" }; foreach (var url in blockedUrls) { var reachable = await EndpointHelpers.IsReachableImageAsync(url, factory); Assert.False(reachable); } } [Fact] public void Link_root_helpers_handle_groups() { var roots = EndpointHelpers.BuildLinkRoots([(1, null), (2, 1), (3, null)]); Assert.Equal(1, roots[1]); Assert.Equal(1, roots[2]); Assert.Equal(3, roots[3]); var linked = EndpointHelpers.LinkedIdsFor(2, roots); Assert.Contains(1, linked); Assert.Contains(2, linked); } [Fact] public void Url_validation_rules() { Assert.True(EndpointHelpers.IsValidImageUrl("https://x.com/img.png")); Assert.False(EndpointHelpers.IsValidImageUrl("ftp://x/img.png")); Assert.True(EndpointHelpers.IsValidHttpUrl("http://x")); Assert.False(EndpointHelpers.IsValidHttpUrl("file://x")); } [Fact] public void Find_root_handles_cycles() { var parentMap = new Dictionary { { 1, 2 }, { 2, 3 }, { 3, 1 } }; var root = EndpointHelpers.FindRootId(1, parentMap); Assert.Equal(3, root); // cycle breaks on revisit } [Fact] public async Task Global_exception_handler_returns_json_error() { await using var factory = new TestWebApplicationFactory().WithWebHostBuilder(builder => { builder.Configure(app => { app.UseGlobalExceptionLogging(); app.UseRouting(); app.UseEndpoints(endpoints => { endpoints.MapGet("/boom", _ => throw new InvalidOperationException("boom")); }); }); }); var client = factory.CreateClient(); var resp = await client.GetAsync("/boom"); Assert.Equal(HttpStatusCode.InternalServerError, resp.StatusCode); var json = await resp.Content.ReadFromJsonAsync(); Assert.Equal("Unexpected server error", json.GetProperty("error").GetString()); } [Fact] public void BuildForwardedHeadersOptions_reads_only_valid_proxies_and_networks() { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ForwardedHeaders:KnownProxies:0"] = "203.0.113.10", ["ForwardedHeaders:KnownProxies:1"] = "not-an-ip", ["ForwardedHeaders:KnownNetworks:0"] = "10.20.0.0/16", ["ForwardedHeaders:KnownNetworks:1"] = "invalid" }).Build(); var options = BuildForwardedHeadersOptionsForTest(config); Assert.Single(options.KnownProxies); Assert.Single(options.KnownIPNetworks); } [Fact] public async Task Forwarded_headers_are_ignored_for_untrusted_proxy() { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["ForwardedHeaders:KnownProxies:0"] = "203.0.113.10" }).Build(); var options = BuildForwardedHeadersOptionsForTest(config); var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); await using var app = builder.Build(); app.Use((ctx, next) => { ctx.Connection.RemoteIpAddress = IPAddress.Parse("198.51.100.25"); return next(); }); app.UseForwardedHeaders(options); app.MapGet("/scheme", (HttpContext ctx) => ctx.Request.Scheme); await app.StartAsync(); try { var request = new HttpRequestMessage(HttpMethod.Get, "/scheme"); request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https"); var response = await app.GetTestClient().SendAsync(request); var scheme = await response.Content.ReadAsStringAsync(); Assert.Equal("http", scheme); } finally { await app.StopAsync(); } } [Fact] public async Task Security_headers_are_applied_to_responses() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClient(); var response = await client.GetAsync("/health"); response.EnsureSuccessStatusCode(); Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single()); Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single()); Assert.Equal("no-referrer", response.Headers.GetValues("Referrer-Policy").Single()); Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single()); } [Fact] public async Task Auth_endpoints_are_rate_limited() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("ratelimit-user"); HttpResponseMessage? last = null; for (var i = 0; i < 8; i++) { last = await client.PostAsJsonAsync("/api/auth/login", new { Username = "ratelimit-user", Password = "wrong-pass" }); } Assert.NotNull(last); Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode); } [Fact] public async Task Admin_endpoints_are_rate_limited() { await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("ratelimit-admin", admin: true); HttpResponseMessage? last = null; for (var i = 0; i < 25; i++) { last = await admin.GetAsync("/api/admin/vote-status"); if (last.StatusCode == HttpStatusCode.TooManyRequests) break; } Assert.NotNull(last); Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode); } [Fact] public void Frontend_regressions_prevent_modal_html_interpolation_for_untrusted_values() { var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); var modalJsPath = Path.Combine(root, "wwwroot", "js", "modals-ui.js"); var adminJsPath = Path.Combine(root, "wwwroot", "js", "admin-ui.js"); var modalJs = File.ReadAllText(modalJsPath); var adminJs = File.ReadAllText(adminJsPath); Assert.DoesNotContain("

${title}

", modalJs, StringComparison.Ordinal); Assert.DoesNotContain("

${body}

", modalJs, StringComparison.Ordinal); Assert.Contains("heading.textContent = title ?? \"\";", modalJs, StringComparison.Ordinal); Assert.Contains("bodyText.textContent = body ?? \"\";", modalJs, StringComparison.Ordinal); Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal); } private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config) { var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions")); return (ForwardedHeadersOptions)method.Invoke(null, [config])!; } private sealed class RedirectFollowingHandler : HttpMessageHandler { private static readonly Uri Source = new("http://example.com/img.png"); private static readonly Uri Destination = new("http://example.com/final.png"); protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (request.RequestUri != Source) { return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { RequestMessage = request }); } var redirectedRequest = new HttpRequestMessage(request.Method, Destination); var response = new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = redirectedRequest, Content = new ByteArrayContent("PNG"u8.ToArray()) }; response.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png"); response.Content.Headers.ContentLength = 3; return Task.FromResult(response); } } }