Add trusted forwarded-header config and tests
This commit is contained in:
@@ -4,9 +4,13 @@ using System.Reflection;
|
|||||||
using GameList.Infrastructure;
|
using GameList.Infrastructure;
|
||||||
using GameList.Endpoints;
|
using GameList.Endpoints;
|
||||||
using GameList.Tests.Support;
|
using GameList.Tests.Support;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
@@ -194,6 +198,60 @@ public class HelperTests
|
|||||||
Assert.Equal("Unexpected server error", json.GetProperty("error").GetString());
|
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
|
private class FakeEnv : IWebHostEnvironment
|
||||||
{
|
{
|
||||||
public string ApplicationName { get; set; } = "";
|
public string ApplicationName { get; set; } = "";
|
||||||
@@ -204,6 +262,12 @@ public class HelperTests
|
|||||||
public IFileProvider ContentRootFileProvider { get; set; } = null!;
|
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 sealed class RedirectFollowingHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
private static readonly Uri Source = new("http://example.com/img.png");
|
private static readonly Uri Source = new("http://example.com/img.png");
|
||||||
|
|||||||
3
IIS.md
3
IIS.md
@@ -13,6 +13,9 @@
|
|||||||
- `ASPNETCORE_ENVIRONMENT=Production`
|
- `ASPNETCORE_ENVIRONMENT=Production`
|
||||||
- `ADMIN_PASSWORD=<your-secret>`
|
- `ADMIN_PASSWORD=<your-secret>`
|
||||||
- `BasePath=/picknplay` (only if the site is under a subfolder; omit for root)
|
- `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.
|
- 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.
|
- 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).
|
- 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).
|
||||||
|
|||||||
37
Program.cs
37
Program.cs
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.DataProtection;
|
|||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Net;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -66,7 +67,7 @@ builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityE
|
|||||||
|
|
||||||
var app = builder.Build();
|
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"];
|
var basePath = builder.Configuration["BasePath"];
|
||||||
if (!string.IsNullOrWhiteSpace(basePath))
|
if (!string.IsNullOrWhiteSpace(basePath))
|
||||||
@@ -100,6 +101,40 @@ app.MapAdminEndpoints();
|
|||||||
|
|
||||||
app.Run();
|
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)
|
static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
Reference in New Issue
Block a user