From 9c1eb63084468e8101ebb7587c59301bce9aba7d Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 7 Feb 2026 00:51:36 +0100 Subject: [PATCH] Add trusted forwarded-header config and tests --- GameList.Tests/HelperTests.cs | 64 +++++++++++++++++++++++++++++++++++ IIS.md | 3 ++ Program.cs | 37 +++++++++++++++++++- 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 8afefa8..698c6f0 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -4,9 +4,13 @@ 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.FileProviders; +using Microsoft.Extensions.Configuration; using System.Text.Json; using System.Net.Http.Json; @@ -194,6 +198,60 @@ public class HelperTests 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(); + } + } + private class FakeEnv : IWebHostEnvironment { public string ApplicationName { get; set; } = ""; @@ -204,6 +262,12 @@ public class HelperTests public IFileProvider ContentRootFileProvider { get; set; } = null!; } + 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"); diff --git a/IIS.md b/IIS.md index 0824cbb..5914b35 100644 --- a/IIS.md +++ b/IIS.md @@ -13,6 +13,9 @@ - `ASPNETCORE_ENVIRONMENT=Production` - `ADMIN_PASSWORD=` - `BasePath=/picknplay` (only if the site is under a subfolder; omit for root) +- Configure trusted reverse proxies/networks for forwarded headers (do not trust all sources): +- `ForwardedHeaders__KnownProxies__0=10.0.0.10` +- `ForwardedHeaders__KnownNetworks__0=10.0.0.0/24` - Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward. - Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles. - Frontend base path: set `` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root). diff --git a/Program.cs b/Program.cs index 39b3062..3b75f9e 100644 --- a/Program.cs +++ b/Program.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; +using System.Net; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); @@ -66,7 +67,7 @@ builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityE var app = builder.Build(); -app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }); +app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration)); var basePath = builder.Configuration["BasePath"]; if (!string.IsNullOrWhiteSpace(basePath)) @@ -100,6 +101,40 @@ app.MapAdminEndpoints(); app.Run(); +static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config) +{ + var options = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost + }; + + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + + foreach (var proxy in config.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []) + { + if (IPAddress.TryParse(proxy, out var parsedProxy)) + options.KnownProxies.Add(parsedProxy); + } + + foreach (var network in config.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? []) + { + var parts = network.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) + continue; + + if (!IPAddress.TryParse(parts[0], out var prefix)) + continue; + + if (!int.TryParse(parts[1], out var prefixLength)) + continue; + + options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength)); + } + + return options; +} + static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath) { try