fix(web): honor X-Forwarded-* so the real client IP reaches the pipeline - #1334
marcelo-maciel wants to merge 6 commits into
Conversation
UseHeroPlatform never called UseForwardedHeaders, so behind the reverse proxy (Caddy / cloudflared) Connection.RemoteIpAddress was always the proxy container IP. That collapsed the rate-limit partitions into a single install-wide bucket (one anonymous spike throttles every tenant's login) and recorded a useless proxy IP on audit trails and user sessions. Register ForwardedHeadersOptions (X-Forwarded-For + X-Forwarded-Proto, known networks/proxies cleared to trust the immediate upstream) and call UseForwardedHeaders first in the pipeline, before HTTPS redirect / rate limiting / auth / audit read the client. Lock the trusted set down via ForwardedHeadersOptions when the ingress topology is fixed.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
iammukeshm
left a comment
There was a problem hiding this comment.
Thanks for tackling the real-client-IP problem — the middleware placement is correct (after UseExceptionHandler, before response compression / CORS / HTTPS redirect / auth / rate-limit). Two blockers before this can land, though, both security:
🔴 HIGH — BuildingBlocks/Web/Extensions.cs: clearing the known-proxy allow-list reopens an IP-spoofing hole. KnownIPNetworks.Clear() + KnownProxies.Clear() makes ForwardedHeaders trust X-Forwarded-For from any source. If the app is ever reachable outside the proxy network, a client can forge its own IP — poisoning audit/session IPs and evading the IP-partitioned rate limiter. That's the same class of hole this PR is meant to close, just moved down a layer. Please configure the trusted ingress CIDR(s) in KnownIPNetworks instead of clearing them, and bind it from configuration so prod can lock it down without a code change.
🔴 HIGH — ForwardLimit left at the default (1). With the 2-hop ingress described in the PR (cloudflared → Caddy → app), reading only the rightmost hop yields Caddy's IP (bug unfixed) or an attacker-injected value. Set ForwardLimit to the real hop count.
src/BuildingBlocks (Golden Rule #4). Needs explicit maintainer sign-off; please call it out in the description.
nit — add a negative test proving an untrusted source's X-Forwarded-For is ignored once known-proxies are set; right now only the happy path is covered, so the security boundary is untested.
Address review on #1334. Instead of clearing the known-proxy allow-list (which trusts X-Forwarded-* from any source and reopens the IP-spoofing hole this PR is meant to close), trust only the ingress proxies/networks bound from the new TrustedProxyOptions, and honor a configurable ForwardLimit for the real multi-hop ingress. With nothing configured the framework default (loopback only) stands, so a client reaching the app directly can't forge its IP/scheme. Add a negative test proving an untrusted source's X-Forwarded-For is ignored, alongside the trusted-proxy happy path. TestServer has no socket, so the connection IP is stamped via a test-only startup filter.
|
Thanks for the careful review — all four addressed in 75475d3. 🔴 known-proxy allow-list no longer cleared. Added 🔴
nit — negative test added. |
iammukeshm
left a comment
There was a problem hiding this comment.
Approving. All four points from my last review are properly addressed, and the negative test is the one that makes this trustworthy.
Specifically:
KnownProxies.Clear()/KnownIPNetworks.Clear()now run only inside the branch where configured values replace them, and the early-return leaves the framework loopback default intact. Secure-by-default holds: no config means no trust, not universal trust.ForwardLimitis config-bound with the multi-hop rationale documented on the property.ForwardedHeadersIpTestsproves the boundary in both directions.TestRemoteIpStartupFilteris a legitimate solution to a real constraint (TestServer has no socket, soConnection.RemoteIpAddressis null and the trust check is otherwise untestable) — running it as anIStartupFilterso it lands ahead of the app pipeline is the correct placement, and it's inert without the header. Using RFC 5737 documentation ranges for the fixtures is a nice touch.- BuildingBlocks is called out in the description.
Middleware placement is right: after UseExceptionHandler, ahead of response compression / CORS / HTTPS redirect / auth / rate limiting, so everything downstream sees the real client.
nit (non-blocking)
IPAddress.Parse and IPNetwork.Parse inside the Configure lambda mean a typo'd CIDR surfaces as a bare FormatException at first options resolution, with nothing in the message naming TrustedProxyOptions. Given this config is edited exactly once per deployment, under time pressure, by someone wiring up an ingress — a TryParse with a message naming the offending entry and setting would pay for itself. Not blocking.
note for the follow-up, not this PR
X-Forwarded-Host is deliberately not in the flags list. That's the right default — honouring it without an allow-list is a host-header-injection primitive — but it does mean anything deriving a public URL from the request host still sees the internal host behind the proxy. That interacts directly with #1323's origin work; worth a line in the TrustedProxyOptions doc comment saying host is intentionally excluded and why, so the next person doesn't "fix" it by adding the flag.
BuildingBlocks sign-off (Golden Rule #4)
Granted. The change is confined to forwarded-headers registration plus the new options type, and alters no existing building-block behaviour.
|
Holding the merge on one dependency, not on the code. Golden Rule #10 — the docs change has to travel with the code. fullstackhero/docs#237 is still open and currently conflicting, so merging this now would ship a new required-ish config surface ( Rebase docs#237 and I'll merge both together. Nothing else outstanding here; the approval stands and this branch is CLEAN against |
|
Docs dependency cleared — fullstackhero/docs#237 is rebased and now The conflict wasn't substantive: Verification note for that repo: it has no CI workflows, so there's no check rollup to read. No new commit on this branchThis PR is untouched — still Heads-up:
|
…formed A typo'd entry in TrustedProxyOptions surfaced as a bare FormatException from IPAddress.Parse / IPNetwork.Parse, with nothing in the message pointing at the setting that caused it. For config an operator edits once per deployment, under time pressure, while wiring up an ingress, that is the wrong failure mode: the silent version of it leaves the app trusting nobody while looking configured. Both parses now use TryParse and throw an InvalidOperationException naming the config path and the offending value. Also closes two gaps the change exposed: - TrustedProxyOptionsBindingTests pins the TrustedProxyOptions -> ForwardedHeadersOptions binding through AddHeroPlatform: the loopback-only default when the section is absent, KnownProxies + ForwardLimit binding, and both malformed-entry messages. Before this, renaming the config section broke nothing that any test could see. The host builder runs with DisableDefaults so an ambient TrustedProxyOptions__* on the machine cannot change what "nothing configured" resolves to. - The untrusted-source integration test asserted only that the connection IP was persisted, which stays true when forwarded-header processing is absent entirely, so it passed with app.UseForwardedHeaders() removed. It now sends the identical header from the trusted proxy as well and asserts that arm is honored, so the trust boundary is what the test actually pins.
|
Pushed What changed since
|
`NU1903` / `GHSA-q939-rpr3-3284` on `SSH.NET` 2025.1.0, pulled transitively by Testcontainers, fails `restore` for the whole solution under `TreatWarningsAsErrors` — on `main` too. It is not introduced here and the fix belongs to #1333, which is still open. Carried byte-identical to #1333's version of the file, comment included, so both stay mergeable in either order and this copy can simply be dropped once #1333 lands.
|
Pushed That is the only change since your approval — no code, no tests, no config touched. For the record, Verified locally on the pushed tree, with the audit on rather than disabled:
One caveat stated rather than glossed: the first suite run had |
|
Reopened as #1379. This PR was closed automatically on 2026-09-14, when the head fork was deleted. |
…ine (#1379) * fix(web): honor X-Forwarded-* so the real client IP reaches the pipeline UseHeroPlatform never called UseForwardedHeaders, so behind the reverse proxy (Caddy / cloudflared) Connection.RemoteIpAddress was always the proxy container IP. That collapsed the rate-limit partitions into a single install-wide bucket (one anonymous spike throttles every tenant's login) and recorded a useless proxy IP on audit trails and user sessions. Register ForwardedHeadersOptions (X-Forwarded-For + X-Forwarded-Proto, known networks/proxies cleared to trust the immediate upstream) and call UseForwardedHeaders first in the pipeline, before HTTPS redirect / rate limiting / auth / audit read the client. Lock the trusted set down via ForwardedHeadersOptions when the ingress topology is fixed. * fix(web): bind forwarded-headers trust to configured proxies Address review on #1334. Instead of clearing the known-proxy allow-list (which trusts X-Forwarded-* from any source and reopens the IP-spoofing hole this PR is meant to close), trust only the ingress proxies/networks bound from the new TrustedProxyOptions, and honor a configurable ForwardLimit for the real multi-hop ingress. With nothing configured the framework default (loopback only) stands, so a client reaching the app directly can't forge its IP/scheme. Add a negative test proving an untrusted source's X-Forwarded-For is ignored, alongside the trusted-proxy happy path. TestServer has no socket, so the connection IP is stamped via a test-only startup filter. * fix(web): name the offending setting when trusted-proxy config is malformed A typo'd entry in TrustedProxyOptions surfaced as a bare FormatException from IPAddress.Parse / IPNetwork.Parse, with nothing in the message pointing at the setting that caused it. For config an operator edits once per deployment, under time pressure, while wiring up an ingress, that is the wrong failure mode: the silent version of it leaves the app trusting nobody while looking configured. Both parses now use TryParse and throw an InvalidOperationException naming the config path and the offending value. Also closes two gaps the change exposed: - TrustedProxyOptionsBindingTests pins the TrustedProxyOptions -> ForwardedHeadersOptions binding through AddHeroPlatform: the loopback-only default when the section is absent, KnownProxies + ForwardLimit binding, and both malformed-entry messages. Before this, renaming the config section broke nothing that any test could see. The host builder runs with DisableDefaults so an ambient TrustedProxyOptions__* on the machine cannot change what "nothing configured" resolves to. - The untrusted-source integration test asserted only that the connection IP was persisted, which stays true when forwarded-header processing is absent entirely, so it passed with app.UseForwardedHeaders() removed. It now sends the identical header from the trusted proxy as well and asserts that arm is honored, so the trust boundary is what the test actually pins. * build: pin SSH.NET to 2026.0.0 so restore passes while #1333 is open `NU1903` / `GHSA-q939-rpr3-3284` on `SSH.NET` 2025.1.0, pulled transitively by Testcontainers, fails `restore` for the whole solution under `TreatWarningsAsErrors` — on `main` too. It is not introduced here and the fix belongs to #1333, which is still open. Carried byte-identical to #1333's version of the file, comment included, so both stay mergeable in either order and this copy can simply be dropped once #1333 lands. * build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. * fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. * fix(web): rebuild the forwarded-headers trust list instead of appending to it AddHeroPlatform only added to KnownProxies/KnownIPNetworks, which assumes whatever is already there is the framework's loopback default. Under ASPNETCORE_FORWARDEDHEADERS_ENABLED=true, ConfigureWebDefaults registers 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 the app rewrote RemoteIpAddress from an X-Forwarded-For sent by any caller, forging the rate-limit partition and the audit IP. Clear both lists unconditionally, then either restate the loopback default or apply the configured proxies/networks. The new test builds through WebApplication.CreateBuilder with the flag set, asserts ForwardedHeadersOptionsSetup is actually registered so it cannot pass vacuously, and checks the resolved lists equal a fresh ForwardedHeadersOptions. * fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. * build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). * test(security): read the session this request created, not the newest one `GetNewestSessionIpAsync` ordered `UserSessions` by `CreatedAt` and took the first row from the whole table. It is safe only because the collection runs serially; any other test in it issuing a token leaves the assertion reading a row this request did not create. Ordering is not what makes it correct either: `CreatedAt` comes from a single `TimeProvider.System` read and two issues can land on the same tick, and `Id` is a random `Guid`, so a tiebreak on it picks deterministically but not necessarily correctly. Snapshot the session ids before the request and take the one that was not there. `ShouldHaveSingleItem` asserts the correlation instead of assuming it. Also states what the factory's `PostConfigure<ForwardedHeadersOptions>` leaves these tests covering. It overwrites the flags, the forward limit and both trust lists wholesale, so the `TrustedProxyOptions` binding is not what runs here - the middleware and the placement of `UseForwardedHeaders` are. The binding has its own gate in `Framework.Tests/Web/TrustedProxyOptionsBindingTests`, and the comment now says so rather than reading as if this pinned production. Verified: `dotnet test --filter FullyQualifiedName~ForwardedHeadersIpTests` passes 2/2; inverting the new filter to `before.Contains(s.Id)` fails 2/2. --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com>
* fix(web): honor X-Forwarded-* so the real client IP reaches the pipeline UseHeroPlatform never called UseForwardedHeaders, so behind the reverse proxy (Caddy / cloudflared) Connection.RemoteIpAddress was always the proxy container IP. That collapsed the rate-limit partitions into a single install-wide bucket (one anonymous spike throttles every tenant's login) and recorded a useless proxy IP on audit trails and user sessions. Register ForwardedHeadersOptions (X-Forwarded-For + X-Forwarded-Proto, known networks/proxies cleared to trust the immediate upstream) and call UseForwardedHeaders first in the pipeline, before HTTPS redirect / rate limiting / auth / audit read the client. Lock the trusted set down via ForwardedHeadersOptions when the ingress topology is fixed. * fix(web): bind forwarded-headers trust to configured proxies Address review on #1334. Instead of clearing the known-proxy allow-list (which trusts X-Forwarded-* from any source and reopens the IP-spoofing hole this PR is meant to close), trust only the ingress proxies/networks bound from the new TrustedProxyOptions, and honor a configurable ForwardLimit for the real multi-hop ingress. With nothing configured the framework default (loopback only) stands, so a client reaching the app directly can't forge its IP/scheme. Add a negative test proving an untrusted source's X-Forwarded-For is ignored, alongside the trusted-proxy happy path. TestServer has no socket, so the connection IP is stamped via a test-only startup filter. * fix(web): name the offending setting when trusted-proxy config is malformed A typo'd entry in TrustedProxyOptions surfaced as a bare FormatException from IPAddress.Parse / IPNetwork.Parse, with nothing in the message pointing at the setting that caused it. For config an operator edits once per deployment, under time pressure, while wiring up an ingress, that is the wrong failure mode: the silent version of it leaves the app trusting nobody while looking configured. Both parses now use TryParse and throw an InvalidOperationException naming the config path and the offending value. Also closes two gaps the change exposed: - TrustedProxyOptionsBindingTests pins the TrustedProxyOptions -> ForwardedHeadersOptions binding through AddHeroPlatform: the loopback-only default when the section is absent, KnownProxies + ForwardLimit binding, and both malformed-entry messages. Before this, renaming the config section broke nothing that any test could see. The host builder runs with DisableDefaults so an ambient TrustedProxyOptions__* on the machine cannot change what "nothing configured" resolves to. - The untrusted-source integration test asserted only that the connection IP was persisted, which stays true when forwarded-header processing is absent entirely, so it passed with app.UseForwardedHeaders() removed. It now sends the identical header from the trusted proxy as well and asserts that arm is honored, so the trust boundary is what the test actually pins. * build: pin SSH.NET to 2026.0.0 so restore passes while #1333 is open `NU1903` / `GHSA-q939-rpr3-3284` on `SSH.NET` 2025.1.0, pulled transitively by Testcontainers, fails `restore` for the whole solution under `TreatWarningsAsErrors` — on `main` too. It is not introduced here and the fix belongs to #1333, which is still open. Carried byte-identical to #1333's version of the file, comment included, so both stay mergeable in either order and this copy can simply be dropped once #1333 lands. * fix(web): reject a trusted-proxy ForwardLimit below 1 at startup TrustedProxyOptions.ForwardLimit was passed straight to ForwardedHeadersOptions with no validation, and neither bad value announces itself. Zero truncates the unwind loop in ApplyForwarders to zero iterations, so X-Forwarded-* stop being processed with no error and no log while the config still reads as configured. A negative value makes the middleware allocate a negative-length buffer, which throws OverflowException on every request - including requests carrying no forwarded headers at all - and UseForwardedHeaders sits after UseExceptionHandler, so that surfaces as a plain 500 rather than a boot failure a smoke test catches. Reject anything below 1 where the malformed KnownProxies/KnownNetworks entries are already rejected, naming the setting and the offending value. The throw lands during startup, so a bad hop count fails the deploy instead of the traffic. Closes #1358 * docs(web): record why X-Forwarded-Host stays out of the flag list Review note from #1334, left for the follow-up: the flag list carries only X-Forwarded-For and X-Forwarded-Proto, and the omission is deliberate. Rewriting Request.Host from a header is a host-header injection primitive, and the three Identity endpoints that build a public URL from the request would then mail confirmation links pointing wherever the header said. The consequence an operator has to know is that Request.Host keeps the internal host behind a proxy, and those links carry it. * build(deps): bump Testcontainers to 4.14.0 and SourceLink past their advisories `dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on `main` and on every open PR alike. Advisory-database drift, not a regression from any change: a commit green on 2026-08-10 is red today with no edits. - `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903, GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already depends on the patched 2026.0.0, so the advisory clears with no transitive pin to remember to remove later. Same fix as #1369, so the two do not conflict. - `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902, GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the 8.x line has no patched release, so a transitive pin cannot fix it; the package itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401, past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is excluded from the template, so the scaffold never sees it. Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and `dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings and 0 errors. * fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers `object not found` for the repository, and a pull fails with: pull access denied for minio/minio, repository does not exist or may require 'docker login' That takes down every Testcontainers-backed integration test (the harness boots a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at container start), the Aspire AppHost, and the Docker Compose deployment. The image is still published at `quay.io/minio/minio`: - `Integration.Tests` and `Integration.Middleware.Tests` harnesses - `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag` - `deploy/docker/docker-compose.yml` and the image table in its README The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay has not moved `:latest` since 2025-09-07, so the two resolve to the same digest today; pinning only removes the surprise of a silent move later, and keeps the test harness off a floating tag. Whether to track a newer release, or a different S3-compatible image, is a separate call. While in the README's image table: `postgres` and `redis` rows had drifted from what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`). Verified: `docker pull minio/minio:latest` fails with the error above; `docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds (`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release` passes against the pinned image, and the Aspire manifest renders the container as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`. * fix(web): rebuild the forwarded-headers trust list instead of appending to it AddHeroPlatform only added to KnownProxies/KnownIPNetworks, which assumes whatever is already there is the framework's loopback default. Under ASPNETCORE_FORWARDEDHEADERS_ENABLED=true, ConfigureWebDefaults registers 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 the app rewrote RemoteIpAddress from an X-Forwarded-For sent by any caller, forging the rate-limit partition and the audit IP. Clear both lists unconditionally, then either restate the loopback default or apply the configured proxies/networks. The new test builds through WebApplication.CreateBuilder with the flag set, asserts ForwardedHeadersOptionsSetup is actually registered so it cannot pass vacuously, and checks the resolved lists equal a fresh ForwardedHeadersOptions. * fix(infra): pull minio/mc from quay.io too, not just minio/minio The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers 404), and it is what `minio-init` runs: without it `dotnet run --project src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull, and the `fsh` bucket is never created, so the first upload fails with NoSuchBucket. Same pinned tag as #1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands. * build(deps): drop the dead SSH.NET pin The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping Testcontainers does not help", but the branch also bumps Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two statements cannot both be true, and the bump is the one that is: with the pin removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903 and exits 0. It was carrying a transitive pin that no longer pins anything. The MessagePack pin above it stays: that one is still load-bearing (removing it brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe). * docs(web): say what a ForwardLimit above the real hop count costs The option documented the failure of setting it too low and the two invalid values, but not the one an operator is most likely to reach for: rounding it up "to be safe". The middleware trusts one entry per hop counting from the right and only ever checks the peer, so a limit of 2 behind a single proxy hands the caller its own RemoteIpAddress, and every IP-based rate limit and audit entry follows it. * test(security): read the session this request created, not the newest one `GetNewestSessionIpAsync` ordered `UserSessions` by `CreatedAt` and took the first row from the whole table. It is safe only because the collection runs serially; any other test in it issuing a token leaves the assertion reading a row this request did not create. Ordering is not what makes it correct either: `CreatedAt` comes from a single `TimeProvider.System` read and two issues can land on the same tick, and `Id` is a random `Guid`, so a tiebreak on it picks deterministically but not necessarily correctly. Snapshot the session ids before the request and take the one that was not there. `ShouldHaveSingleItem` asserts the correlation instead of assuming it. Also states what the factory's `PostConfigure<ForwardedHeadersOptions>` leaves these tests covering. It overwrites the flags, the forward limit and both trust lists wholesale, so the `TrustedProxyOptions` binding is not what runs here - the middleware and the placement of `UseForwardedHeaders` are. The binding has its own gate in `Framework.Tests/Web/TrustedProxyOptionsBindingTests`, and the comment now says so rather than reading as if this pinned production. Verified: `dotnet test --filter FullyQualifiedName~ForwardedHeadersIpTests` passes 2/2; inverting the new filter to `before.Contains(s.Id)` fails 2/2. --------- Co-authored-by: iammukeshm <iammukeshm@gmail.com>
Fixes audit finding API-02.
Problem
UseHeroPlatformnever calledUseForwardedHeaders, so behind the reverse proxy (Caddy / cloudflared)Connection.RemoteIpAddresswas always the proxy's container IP. Consequences:authpolicy and the global IP limiter (RateLimiting/Extensions.cs) partition byip:{RemoteIpAddress}, so every request shares one bucket. One anonymous spike throttles every tenant's login; per-origin brute-force protection is gone.RequestContextService.IpAddress(persisted onUserSession, audit trails) records the proxy IP for every request.Fix
TrustedProxyOptions(config sectionTrustedProxyOptions):KnownProxies(IPs),KnownNetworks(CIDRs) andForwardLimit, bound from configuration.ForwardedHeadersOptions—X-Forwarded-For+X-Forwarded-Proto. Trust is bound to the configured ingress proxies/networks; forwarded headers from any other source are ignored, so a client reaching the app directly cannot forge its IP/scheme. When nothing is configured, the framework default (loopback only) stands.ForwardLimitfollows config so a multi-hop ingress (cloudflared → Caddy → app) unwinds the right number of hops.app.UseForwardedHeaders()first inUseHeroPlatform, before HTTPS redirect / rate limiting / auth / audit read the client IP or scheme.appsettings.json/appsettings.Production.jsoncarry an emptyTrustedProxyOptionssection; prod sets the ingress CIDR(s) + hop count to activate real-client extraction (secure-by-default: no config ⇒ no trust).KnownProxies/KnownNetworksentry now fails with anInvalidOperationExceptionnaming the config path and the offending value, instead of a bareFormatException.Tests
ForwardedHeadersIpTests(integration):X-Forwarded-Forpersists the real client IP on theUserSession.TestServer has no socket, so the connection IP is stamped via a test-only startup filter (
X-Test-Remote-Ip).TrustedProxyOptionsBindingTests(unit) pins theTrustedProxyOptions→ForwardedHeadersOptionsbinding throughAddHeroPlatform: the loopback-only default when the section is absent,KnownProxies+ForwardLimitbinding, and both malformed-entry messages. The host builder runs withDisableDefaultsso an ambientTrustedProxyOptions__*on the machine or CI runner can't change what "nothing configured" resolves to.Full suite green locally: 1773 passed / 1 skipped / 0 failed (Integration.Tests 735/1, Architecture.Tests 51).
src/BuildingBlocks(Golden Rule #4)This modifies
src/BuildingBlocks/Web/Extensions.csand addssrc/BuildingBlocks/Web/TrustedProxy/TrustedProxyOptions.cs— the shared framework wiring, so it needs maintainer sign-off. The change is confined to forwarded-headers registration + the new options type; no existing behavior of other building blocks is altered. Sign-off granted in review on 2026-08-08.Changed after the last review
75475d30was what was approved.85aa03b3adds, and has not been reviewed by anyone:TryParse+ named-message change (the non-blocking nit from the approving review);TrustedProxyOptionsBindingTests;No production behavior changes beyond the error message for malformed config.
The
SSH.NETpin is carried from #1333NU1903/ GHSA-q939-rpr3-3284 onSSH.NET2025.1.0, which arrives transitively via Testcontainers (4.11.0 and 4.13.0 both pin 2025.1.0), failsrestorefor the whole solution underTreatWarningsAsErrors. It is not caused by this PR: building unmodifiedorigin/mainfails identically, re-verified today at3f2959e6(exit 1,NU1903fromIntegration.TestsandIntegration.Middleware.Tests). The advisory was published after these branches were last built, which is why previously-green PRs went red with no code change.The fix properly belongs to #1333, which is still open. Rather than leave an approved PR red on someone else's advisory, the pin is carried here byte-identical to #1333's version of the file, comment included — the blob hashes match. That keeps both mergeable in either order, and once #1333 lands this copy can simply be dropped. If the pin changes during that PR's review, this copy should be matched rather than allowed to drift: the same pin under a reworded comment conflicts.
Notes
Docs in fullstackhero/docs#237 (rebased,
MERGEABLE): changelog entry + a new "Reverse proxy & forwarded headers" section (CORS & headers page) documentingTrustedProxyOptions, and a production-checklist note that honoringX-Forwarded-Protorequires configuring the trusted ingress.Two things deliberately left out of this PR:
ForwardLimitvalidation — it's a plainintwhere the framework's own option isint?. A negative value throwsOverflowExceptioninsideForwardedHeadersMiddlewareon every request (500s across the board, sinceUseForwardedHeadersruns afterUseExceptionHandler), and0silently disables processing. Measured against the real middleware; filed separately as TrustedProxyOptions.ForwardLimit is an unvalidated int: a negative value 500s every request, zero silently disables forwarded headers #1358 rather than widening this PR.X-Forwarded-Host— intentionally excluded from the flags list; the doc-comment note about why is the follow-up you called out in review.