using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; namespace ItWorx.KnowledgeHub.Api.Tests; /// /// Proves the reverse-proxy trust boundary documented in /// docs/23-deployment-unraid.md "Reverse proxy" and EVIDENCE.md's "Production /// configuration" item: forwarded headers (X-Forwarded-Proto) are only honored when /// they arrive from an explicitly configured KnowledgeHub:TrustedProxies entry, never /// by default and never from an untrusted source. The CSRF cookie's /// CookieSecurePolicy.SameAsRequest makes the resulting "Secure" attribute a direct, /// observable proxy for whether ASP.NET Core actually treated the request as HTTPS. /// public sealed class ForwardedHeadersTrustTests { private const string SimulatedRemoteIp = "203.0.113.7"; // WebApplicationFactory.CreateClient() defaults its base address to https://localhost, // which alone already makes TestServer report Request.IsHttps == true — independent of // any forwarded header. Every test below forces an http:// base address instead, so the // "Secure" cookie attribute genuinely reflects whether the ForwardedHeaders middleware // honored (or correctly ignored) X-Forwarded-Proto, not this client default. private static readonly WebApplicationFactoryClientOptions HttpClientOptions = new() { BaseAddress = new Uri("http://localhost"), }; [Fact] public async Task An_untrusted_X_Forwarded_Proto_header_is_ignored_by_default() { using var factory = new KnowledgeHubApiFactory(); using var client = factory.CreateClient(HttpClientOptions); var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp); Assert.DoesNotContain("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task An_X_Forwarded_Proto_header_from_a_configured_trusted_proxy_is_honored() { using var factory = new KnowledgeHubApiFactory().WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configuration) => { configuration.AddInMemoryCollection(new Dictionary { ["KnowledgeHub:TrustedProxies"] = $"{SimulatedRemoteIp};127.0.0.1", }); }); }); using var client = factory.CreateClient(HttpClientOptions); var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp); Assert.Contains("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task A_trusted_proxy_list_that_does_not_include_the_caller_still_ignores_the_header() { using var factory = new KnowledgeHubApiFactory().WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configuration) => { // A real trusted proxy is configured, but it is not the address this // request actually arrives from — the header must still be ignored. configuration.AddInMemoryCollection(new Dictionary { ["KnowledgeHub:TrustedProxies"] = "198.51.100.9", }); }); }); using var client = factory.CreateClient(HttpClientOptions); var csrfCookie = await GetCsrfCookieAsync(client, SimulatedRemoteIp); Assert.DoesNotContain("Secure", csrfCookie, StringComparison.OrdinalIgnoreCase); } private static async Task GetCsrfCookieAsync(HttpClient client, string simulatedRemoteIp) { using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/auth/csrf"); // TestServer never simulates a real socket, so Connection.RemoteIpAddress is null // unless something sets it. Program.cs sets it from this header in the Test // environment only (never reachable in Production), giving the KnownProxies // trust check a deterministic "direct caller" address — the same role a real // kernel-level TCP accept plays in production. request.Headers.Add("X-Test-Remote-Ip", simulatedRemoteIp); request.Headers.Add("X-Forwarded-Proto", "https"); request.Headers.Add("X-Forwarded-For", "198.51.100.50"); using var response = await client.SendAsync(request); var csrfCookie = response.Headers.TryGetValues("Set-Cookie", out var values) ? values.FirstOrDefault(v => v.StartsWith("ikh_csrf=", StringComparison.Ordinal)) : null; Assert.NotNull(csrfCookie); return csrfCookie; } }