Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/BuildingBlocks/Web/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,18 @@
using FSH.Framework.Web.RateLimiting;
using FSH.Framework.Web.Realtime;
using FSH.Framework.Web.Security;
using FSH.Framework.Web.TrustedProxy;
using FSH.Framework.Web.Versioning;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Mediator;
using System.Net;

namespace FSH.Framework.Web;

Expand Down Expand Up @@ -63,6 +66,62 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild
}

builder.Services.AddHttpContextAccessor();

// The app runs behind a reverse proxy (e.g. cloudflared → Caddy → app), so the real client IP
// and scheme arrive via X-Forwarded-*. Without this, RemoteIpAddress is the proxy's container
// IP, which collapses the rate-limit partition into one bucket and records useless audit IPs.
// Trust is bound to the configured ingress CIDRs/proxies (see TrustedProxyOptions): forwarded
// headers from any other source are ignored, so a client reaching the app directly cannot forge
// its IP/scheme. With nothing configured, the framework default (loopback only) stands.
var trustedProxy = builder.Configuration
.GetSection(nameof(TrustedProxyOptions)).Get<TrustedProxyOptions>() ?? new TrustedProxyOptions();
builder.Services.Configure<ForwardedHeadersOptions>(forwarded =>
{
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
forwarded.ForwardLimit = trustedProxy.ForwardLimit;

// The trust list is always rebuilt from scratch, never appended to. Whatever is in the
// options when this runs depends on who configured them first, and with
// ASPNETCORE_FORWARDEDHEADERS_ENABLED=true that is ForwardedHeadersOptionsSetup, which
// empties both lists. An empty list is not "trust nobody" in ForwardedHeadersMiddleware:
// it only validates the peer when at least one entry exists, so empty means the app
// rewrites RemoteIpAddress from an X-Forwarded-For sent by anyone at all.
forwarded.KnownProxies.Clear();
forwarded.KnownIPNetworks.Clear();

if (trustedProxy.KnownProxies.Length == 0 && trustedProxy.KnownNetworks.Length == 0)
{
// Nothing configured: restate the framework's own default rather than inherit it,
// for the same reason. Local development runs behind Kestrel on loopback and still
// needs its forwarded headers honoured.
forwarded.KnownProxies.Add(IPAddress.IPv6Loopback);
forwarded.KnownIPNetworks.Add(new System.Net.IPNetwork(IPAddress.Loopback, 8));
return;
}

foreach (var proxy in trustedProxy.KnownProxies)
{
if (!IPAddress.TryParse(proxy, out var address))
{
throw new InvalidOperationException(
$"{nameof(TrustedProxyOptions)}:{nameof(TrustedProxyOptions.KnownProxies)} contains \"{proxy}\", which is not a valid IP address (for example \"10.0.0.5\").");
}

forwarded.KnownProxies.Add(address);
}

foreach (var network in trustedProxy.KnownNetworks)
{
if (!System.Net.IPNetwork.TryParse(network, out var parsedNetwork))
{
throw new InvalidOperationException(
$"{nameof(TrustedProxyOptions)}:{nameof(TrustedProxyOptions.KnownNetworks)} contains \"{network}\", which is not a valid CIDR network (for example \"10.0.0.0/8\").");
}

forwarded.KnownIPNetworks.Add(parsedNetwork);
}
});

builder.Services.AddHeroDatabaseOptions(builder.Configuration);
builder.Services.AddHeroRateLimiting(builder.Configuration);

Expand Down Expand Up @@ -150,6 +209,11 @@ public static WebApplication UseHeroPlatform(this WebApplication app, Action<Fsh
var openApiEnabled = options.UseOpenApi && IsOpenApiEnabled(app.Configuration);

app.UseExceptionHandler();

// Apply forwarded headers before anything reads the client IP or scheme (HTTPS redirect,
// rate limiting, auth, audit) so they all see the real client, not the reverse proxy.
app.UseForwardedHeaders();

app.UseResponseCompression();

// CORS MUST run before UseHttpsRedirection: preflight OPTIONS can't follow an HTTP→HTTPS redirect, so
Expand Down
25 changes: 25 additions & 0 deletions src/BuildingBlocks/Web/TrustedProxy/TrustedProxyOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace FSH.Framework.Web.TrustedProxy;

/// <summary>
/// Trusted reverse-proxy configuration for X-Forwarded-* processing. Behind an ingress
/// (e.g. cloudflared → Caddy → app) the real client IP and scheme arrive via forwarded headers;
/// these settings bound which upstream sources are trusted so a client reaching the app from
/// outside the proxy network cannot forge its own IP/scheme. When no proxies or networks are
/// configured, the framework default (loopback only) stands and forwarded headers from any other
/// source are ignored.
/// </summary>
public sealed class TrustedProxyOptions
{
/// <summary>Individual upstream proxy IP addresses whose X-Forwarded-* headers are trusted.</summary>
public string[] KnownProxies { get; init; } = [];

/// <summary>Trusted upstream networks in CIDR notation (e.g. "10.0.0.0/8", "172.16.0.0/12").</summary>
public string[] KnownNetworks { get; init; } = [];

/// <summary>
/// Number of proxy hops to unwind from X-Forwarded-For. Must match the real ingress hop count
/// (cloudflared → Caddy → app is 2). The framework default of 1 reads only the rightmost hop,
/// which yields the nearest proxy's IP (or an attacker-injected value) in a multi-hop topology.
/// </summary>
public int ForwardLimit { get; init; } = 1;
}
5 changes: 5 additions & 0 deletions src/Host/FSH.Starter.Api/appsettings.Production.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@
"Ip": { "PermitLimit": 300, "WindowSeconds": 60, "QueueLimit": 0 },
"Auth": { "PermitLimit": 10, "WindowSeconds": 60, "QueueLimit": 0 }
},
"TrustedProxyOptions": {
"KnownProxies": [],
"KnownNetworks": [],
"ForwardLimit": 1
},
"Storage": {
"Provider": "local"
}
Expand Down
5 changes: 5 additions & 0 deletions src/Host/FSH.Starter.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@
"QueueLimit": 0
}
},
"TrustedProxyOptions": {
"KnownProxies": [],
"KnownNetworks": [],
"ForwardLimit": 1
},
"Storage": {
"Provider": "local"
},
Expand Down
64 changes: 64 additions & 0 deletions src/Tests/Framework.Tests/Web/ForwardedHeadersHostDefaultsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using FSH.Framework.Web;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;

namespace Framework.Tests.Web;

/// <summary>
/// The sibling of <see cref="TrustedProxyOptionsBindingTests"/> for the one host shape that class cannot
/// reach. It builds through <c>Host.CreateApplicationBuilder</c>, which never runs
/// ConfigureWebDefaults, so the framework's loopback defaults are always still in place when
/// AddHeroPlatform looks at them. A web host started with FORWARDEDHEADERS_ENABLED registers
/// ForwardedHeadersOptionsSetup, which empties both trust lists — and an empty trust list is not
/// "trust nobody" in ForwardedHeadersMiddleware, it is "check nobody": the middleware only validates
/// the peer when at least one entry exists. That is the configuration this pins.
/// </summary>
public sealed class ForwardedHeadersHostDefaultsTests
{
private static ForwardedHeadersOptions ResolveWithAspNetForwarding()
{
// Passed as a command-line arg rather than an environment variable: host configuration reads
// both, and an env var would leak into every other test running in this process.
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = ["--FORWARDEDHEADERS_ENABLED=true"],
EnvironmentName = "Development",
});
builder.AddHeroPlatform();

// The premise of this whole test: ASP.NET registered its own setup for these options. If the
// flag ever stops reaching host configuration, the assertions below would pass for the wrong
// reason — nothing cleared the lists, so nothing had to restore them.
builder.Services.Any(d =>
d.ServiceType == typeof(IConfigureOptions<ForwardedHeadersOptions>) &&
d.ImplementationType?.Name == "ForwardedHeadersOptionsSetup")
.ShouldBeTrue("FORWARDEDHEADERS_ENABLED did not reach host configuration");

// Not builder.Build(): the host validates the whole container, and the modules that supply
// ICurrentUser and friends are not registered here. Only the options matter.
using var provider = builder.Services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value;
}

[Fact]
public void ForwardedHeaders_Should_TrustSomeone_When_NothingConfiguredAndAspNetForwardingEnabled()
{
// Act
var options = ResolveWithAspNetForwarding();

// Assert — with both lists empty the middleware skips the peer check entirely and rewrites
// RemoteIpAddress from X-Forwarded-For sent by anyone at all.
(options.KnownProxies.Count + options.KnownIPNetworks.Count).ShouldBeGreaterThan(
0,
"an empty trust list makes ForwardedHeadersMiddleware accept X-Forwarded-For from any peer");

// And what is restored is the framework's own default, not a trust policy of our own
// invention: with nothing configured the app must trust exactly loopback, no wider.
var frameworkDefaults = new ForwardedHeadersOptions();
options.KnownProxies.ShouldBe(frameworkDefaults.KnownProxies);
options.KnownIPNetworks.ShouldBe(frameworkDefaults.KnownIPNetworks);
}
}
97 changes: 97 additions & 0 deletions src/Tests/Framework.Tests/Web/TrustedProxyOptionsBindingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using FSH.Framework.Web;
using FSH.Framework.Web.TrustedProxy;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

namespace Framework.Tests.Web;

/// <summary>
/// Pins the TrustedProxyOptions -> ForwardedHeadersOptions binding that AddHeroPlatform registers: which
/// upstreams end up trusted, that an unconfigured section keeps the framework's loopback-only default, and
/// that a malformed entry surfaces a message naming the offending setting rather than a bare FormatException.
/// </summary>
public sealed class TrustedProxyOptionsBindingTests
{
private const string ProxyIp = "192.0.2.10";

private static ForwardedHeadersOptions Resolve(Dictionary<string, string?> settings)
{
// DisableDefaults keeps the host's environment-variable and appsettings providers out, so an ambient
// TrustedProxyOptions__* on the machine or CI runner can't change what "nothing configured" resolves to.
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
DisableDefaults = true,
});
builder.Configuration.AddInMemoryCollection(settings);
builder.AddHeroPlatform();

using var provider = builder.Services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value;
}

#region Trust boundary

[Fact]
public void ForwardedHeaders_Should_KeepFrameworkLoopbackDefault_When_NothingConfigured()
{
// Act
var options = Resolve([]);

// Assert - clearing the framework default here would make every caller a trusted proxy.
options.KnownProxies.ShouldNotBeEmpty();
options.KnownIPNetworks.ShouldNotBeEmpty();
}

[Fact]
public void ForwardedHeaders_Should_TrustOnlyConfiguredProxy_When_KnownProxiesSet()
{
// Act
var options = Resolve(new Dictionary<string, string?>
{
[$"{nameof(TrustedProxyOptions)}:{nameof(TrustedProxyOptions.KnownProxies)}:0"] = ProxyIp,
[$"{nameof(TrustedProxyOptions)}:{nameof(TrustedProxyOptions.ForwardLimit)}"] = "2",
});

// Assert
options.KnownProxies.ShouldBe([System.Net.IPAddress.Parse(ProxyIp)]);
options.KnownIPNetworks.ShouldBeEmpty();
options.ForwardLimit.ShouldBe(2);
}

#endregion

#region Malformed configuration

[Fact]
public void ForwardedHeaders_Should_NameTheSetting_When_KnownProxyMalformed()
{
// Act
var exception = Should.Throw<InvalidOperationException>(() => Resolve(new Dictionary<string, string?>
{
[$"{nameof(TrustedProxyOptions)}:{nameof(TrustedProxyOptions.KnownProxies)}:0"] = "not-an-ip",
}));

// Assert
exception.Message.ShouldContain("TrustedProxyOptions:KnownProxies");
exception.Message.ShouldContain("not-an-ip");
}

[Fact]
public void ForwardedHeaders_Should_NameTheSetting_When_KnownNetworkMalformed()
{
// Act
var exception = Should.Throw<InvalidOperationException>(() => Resolve(new Dictionary<string, string?>
{
[$"{nameof(TrustedProxyOptions)}:{nameof(TrustedProxyOptions.KnownNetworks)}:0"] = "10.0.0.0/999",
}));

// Assert
exception.Message.ShouldContain("TrustedProxyOptions:KnownNetworks");
exception.Message.ShouldContain("10.0.0.0/999");
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,26 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)

builder.ConfigureServices(services =>
{
// Stamp the connection IP from a test header so forwarded-headers trust checks are testable.
services.AddSingleton<IStartupFilter, TestRemoteIpStartupFilter>();

// The production TrustedProxyOptions read happens eagerly, before the test config overlay
// applies (same quirk as storage below), so bind the trusted upstream here instead. Note what
// that leaves these tests covering: overwriting the flags, the forward limit and both trust
// lists wholesale replaces whatever the TrustedProxyOptions binding produced, so what runs
// against TestConstants.TrustedProxyIp is the real middleware and the real placement of
// UseForwardedHeaders, not the binding that feeds them in production. That binding is gated
// separately, by Framework.Tests/Web/TrustedProxyOptionsBindingTests.
services.PostConfigure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(forwarded =>
{
forwarded.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
| Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto;
forwarded.ForwardLimit = 1;
forwarded.KnownProxies.Clear();
forwarded.KnownIPNetworks.Clear();
forwarded.KnownProxies.Add(System.Net.IPAddress.Parse(TestConstants.TrustedProxyIp));
});

// Remove hosted services that need unavailable infra or race migrations (RolePermissionSync,
// Hangfire server + stale-lock cleanup, OutboxDispatcher); we register our own InMemory server below.
var hostedServicesToRemove = services
Expand Down
4 changes: 4 additions & 0 deletions src/Tests/Integration.Tests/Infrastructure/TestConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ public static class TestConstants
public const string RootAdminEmail = "admin@root.com";
public const string DefaultPassword = "123Pa$$word!";

// Documentation IP ranges (RFC 5737) so the trusted-proxy fixture never collides with a real host.
public const string TrustedProxyIp = "192.0.2.10";
public const string UntrustedSourceIp = "198.51.100.9";

public const string JwtIssuer = "fsh.local";
public const string JwtAudience = "fsh.clients";
public const string JwtSigningKey = "integration-test-signing-key-that-is-at-least-32-chars-long!!";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;

namespace Integration.Tests.Infrastructure;

/// <summary>
/// TestServer has no real socket, so <c>Connection.RemoteIpAddress</c> is null and the
/// forwarded-headers trust check (known proxies/networks) can't be exercised. This filter runs
/// before the app pipeline (hence before UseForwardedHeaders) and stamps the connection IP from the
/// <c>X-Test-Remote-Ip</c> header so a test can present itself as a trusted or untrusted upstream.
/// Inert for requests that don't carry the header.
/// </summary>
public sealed class TestRemoteIpStartupFilter : IStartupFilter
{
public const string RemoteIpHeader = "X-Test-Remote-Ip";

public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) =>
app =>
{
app.Use(async (context, nextMiddleware) =>
{
var header = context.Request.Headers[RemoteIpHeader].FirstOrDefault();
if (!string.IsNullOrEmpty(header) && IPAddress.TryParse(header, out var ip))
{
context.Connection.RemoteIpAddress = ip;
}

await nextMiddleware();
});

next(app);
};
}
Loading
Loading