Skip to content
Open
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
41 changes: 41 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,39 @@ 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;

if (trustedProxy.KnownProxies.Length == 0 && trustedProxy.KnownNetworks.Length == 0)
{
return;
}

forwarded.KnownProxies.Clear();
forwarded.KnownIPNetworks.Clear();

foreach (var proxy in trustedProxy.KnownProxies)
{
forwarded.KnownProxies.Add(IPAddress.Parse(proxy));
}

foreach (var network in trustedProxy.KnownNetworks)
{
forwarded.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(network));
}
});

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

Expand Down Expand Up @@ -150,6 +186,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
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ 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. This
// exercises the real UseForwardedHeaders trust boundary against TestConstants.TrustedProxyIp.
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);
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Identity.Data;
using Integration.Tests.Infrastructure;

namespace Integration.Tests.Tests.Security;

/// <summary>
/// Runtime repro for audit finding API-02 (no UseForwardedHeaders → proxy IP collapses the real
/// client IP) plus its security boundary. Token issuance persists a UserSession whose IpAddress comes
/// from RequestContextService.IpAddress => Connection.RemoteIpAddress. The forwarded-headers config
/// trusts only the configured upstream (TestConstants.TrustedProxyIp), so:
/// - a request arriving from the trusted proxy has its X-Forwarded-For honored (real client IP), and
/// - a request arriving from any other source has X-Forwarded-For ignored (spoofing is blocked).
/// TestServer has no socket, so the connection IP is stamped via the X-Test-Remote-Ip header (see
/// TestRemoteIpStartupFilter).
/// </summary>
[Collection(FshCollectionDefinition.Name)]
public sealed class ForwardedHeadersIpTests
{
private const string ForwardedClientIp = "203.0.113.7";

private readonly FshWebApplicationFactory _factory;

public ForwardedHeadersIpTests(FshWebApplicationFactory factory)
{
_factory = factory;
}

[Fact]
public async Task TokenIssue_Should_RecordForwardedClientIp_When_RequestArrivesFromTrustedProxy()
{
var recordedIp = await IssueTokenAndReadSessionIpAsync(
connectionIp: TestConstants.TrustedProxyIp,
forwardedFor: ForwardedClientIp);

recordedIp.ShouldBe(
ForwardedClientIp,
"behind a trusted proxy the persisted session IP should be the real client IP from " +
"X-Forwarded-For.");
}

[Fact]
public async Task TokenIssue_Should_IgnoreForwardedClientIp_When_RequestArrivesFromUntrustedSource()
{
var recordedIp = await IssueTokenAndReadSessionIpAsync(
connectionIp: TestConstants.UntrustedSourceIp,
forwardedFor: ForwardedClientIp);

recordedIp.ShouldBe(
TestConstants.UntrustedSourceIp,
"X-Forwarded-For from a source outside the trusted-proxy set must be ignored; the persisted " +
"IP should be the connection IP, never the attacker-supplied forwarded value.");
recordedIp.ShouldNotBe(ForwardedClientIp);
}

private async Task<string?> IssueTokenAndReadSessionIpAsync(string connectionIp, string forwardedFor)
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/issue");
request.Headers.Add("tenant", TestConstants.RootTenantId);
request.Headers.Add(TestRemoteIpStartupFilter.RemoteIpHeader, connectionIp);
request.Headers.Add("X-Forwarded-For", forwardedFor);
request.Content = JsonContent.Create(new
{
email = TestConstants.RootAdminEmail,
password = TestConstants.DefaultPassword,
});

using var response = await client.SendAsync(request);
response.StatusCode.ShouldBe(HttpStatusCode.OK);

return await GetNewestSessionIpAsync();
}

private async Task<string?> GetNewestSessionIpAsync()
{
using var scope = _factory.Services.CreateScope();

var tenantStore = scope.ServiceProvider.GetRequiredService<IMultiTenantStore<AppTenantInfo>>();
var tenant = await tenantStore.GetAsync(TestConstants.RootTenantId);
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>().MultiTenantContext =
new MultiTenantContext<AppTenantInfo>(tenant);

var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
var session = await db.UserSessions
.AsNoTracking()
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();

return session?.IpAddress;
}
}
Loading