Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
19 changes: 19 additions & 0 deletions src/BuildingBlocks/Web/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
using FSH.Framework.Web.Security;
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;
Expand Down Expand Up @@ -63,6 +64,19 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild
}

builder.Services.AddHttpContextAccessor();

// The app runs behind a reverse proxy (Caddy / cloudflared), 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. Known
// networks/proxies are cleared to trust the immediate upstream; lock them down via config
// when the ingress topology is fixed.
builder.Services.Configure<ForwardedHeadersOptions>(forwarded =>
{
forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
forwarded.KnownIPNetworks.Clear();
forwarded.KnownProxies.Clear();
});

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

Expand Down Expand Up @@ -150,6 +164,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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). Token issuance persists a UserSession whose IpAddress comes from
/// RequestContextService.IpAddress => Connection.RemoteIpAddress. With a trusted-proxy
/// forwarded-headers config, a request carrying X-Forwarded-For should surface the real client IP;
/// because UseHeroPlatform never calls UseForwardedHeaders, the header is ignored.
/// </summary>
[Collection(FshCollectionDefinition.Name)]
public sealed class ForwardedHeadersIpTests
{
private const string ForwardedIp = "203.0.113.7";

private readonly FshWebApplicationFactory _factory;

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

[Fact]
public async Task TokenIssue_Should_RecordForwardedClientIp_When_RequestCarriesXForwardedFor()
{
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("X-Forwarded-For", ForwardedIp);
request.Content = JsonContent.Create(new
{
email = TestConstants.RootAdminEmail,
password = TestConstants.DefaultPassword,
});

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

var recordedIp = await GetNewestSessionIpAsync();

recordedIp.ShouldBe(
ForwardedIp,
"behind a trusted proxy the persisted session IP should be the real client IP from " +
"X-Forwarded-For; without UseForwardedHeaders the app records the connection/loopback IP instead.");
}

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