Add trusted forwarded-header config and tests

This commit is contained in:
2026-02-07 00:51:36 +01:00
parent c672802469
commit 9c1eb63084
3 changed files with 103 additions and 1 deletions

View File

@@ -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<string, string?>
{
["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<string, string?>
{
["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");

3
IIS.md
View File

@@ -13,6 +13,9 @@
- `ASPNETCORE_ENVIRONMENT=Production`
- `ADMIN_PASSWORD=<your-secret>`
- `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 `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root).

View File

@@ -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<string[]>() ?? [])
{
if (IPAddress.TryParse(proxy, out var parsedProxy))
options.KnownProxies.Add(parsedProxy);
}
foreach (var network in config.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>() ?? [])
{
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