fix(web): honor X-Forwarded-* so the real client IP reaches the pipeline - #1379
marcelo-maciel wants to merge 12 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.
Address review on fullstackhero#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.
…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.
…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 fullstackhero#1333, which is still open. Carried byte-identical to fullstackhero#1333's version of the file, comment included, so both stay mergeable in either order and this copy can simply be dropped once fullstackhero#1333 lands.
…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 fullstackhero#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.
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`.
…ng 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.
|
Note for whoever merges this: the same forwarded-headers trust-list fix is carried, content-identical, on #1386. The two PRs touch the same block in Verified by content rather than by SHA: the added and removed lines of both commits are identical, differing only in blob index and hunk offset. |
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 fullstackhero#1388, which owns the fix, so the copy stays byte-identical to it and can be dropped once that lands.
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).
|
Pairing note, for whoever picks these up. #1386 is cut from this branch and is the only PR that depends on it. Measured rather than described: Merge this one first and that PR collapses to exactly those 35 lines. Merge that one first and this one has nothing left to add. Either order works mechanically: both carry the same two infra carve-outs as identical hunks, so whichever lands second drops them as an empty cherry-pick. |
… 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.
|
Pairing note, re-measured. The numbers in my earlier comment went stale: both branches have At the current heads, The recommendation is unchanged. Merge this one first and that PR collapses to exactly those 43 |
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 loopback-only default is restated, not inherited: the trust list is cleared first and rebuilt every time, because a host started withASPNETCORE_FORWARDEDHEADERS_ENABLED=trueregistersForwardedHeadersOptionsSetup, which empties both lists before this runs. An empty list is not "trust nobody" inForwardedHeadersMiddleware— it only validates the peer when at least one entry exists, so empty means the app would rewriteRemoteIpAddressfrom anX-Forwarded-Forsent by any caller at all.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.ForwardedHeadersHostDefaultsTests(unit) covers the one host shape the binding tests cannot reach: it builds throughWebApplication.CreateBuilderwithFORWARDEDHEADERS_ENABLEDset, assertsForwardedHeadersOptionsSetupis actually registered so the test cannot pass vacuously, and then asserts the resolved lists equal a freshForwardedHeadersOptions. Red with theClear()and the restatement reverted, green with them in place.Full suite green locally: 1773 passed / 1 skipped / 0 failed (Integration.Tests 735/1, Architecture.Tests 51). Re-run after the trust-list fix: solution build under
TreatWarningsAsErrorsexit0,Framework.Tests138/138,Integration.Tests(Security filter) 9/9,Integration.Middleware.Tests5/5.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 dependency bumps that
restoreneedsdotnet restore src/FSH.Starter.slnxfails onmainunderTreatWarningsAsErrors— not because ofthis PR — so the branch carries the Testcontainers 4.14.0 and SourceLink bumps that clear it, in the
same shape as the PR that owns them.
It used to carry an explicit
SSH.NETpin too, on the stated grounds that bumping Testcontainerswould not help. That was wrong: 4.14.0 declares
SSH.NET >= 2026.0.0, and with the pin removeddotnet restore --forcereports zero NU1902/NU1903 and exits 0. The pin is gone.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.Infra carve-outs, corrected after review. Two things in the out-of-topic hunks were wrong and
are fixed on the branch:
minio/minioto quay.io.minio/mcis gone from Docker Hub too(
hub.docker.com/v2/repositories/minio/mc/answers 404) and it is whatminio-initruns, so bothdotnet run --project src/Host/FSH.Starter.AppHostanddocker compose updied on the pull and thefshbucket was never created. Now pinned to the same quay tag #1388 uses.SSH.NETpin is gone: it pinned nothing. Its own comment claimed bumping Testcontainersdoes not help, but 4.14.0 — which this branch also carries — declares
SSH.NET >= 2026.0.0.Measured rather than argued: with the pin removed,
dotnet restore src/FSH.Starter.slnx --forcereports zero NU1902/NU1903 and exits 0. (The MessagePack pin next to it stays; removing that one
does bring its advisory straight back.)
With both applied,
deploy/docker/docker-compose.ymlandsrc/Directory.Packages.propsare nowgenuinely byte-identical to #1388 (
git diff --exit-code, checked today), which the earlier claimwas not.
Two corrections in the tests themselves.
ForwardedHeadersIpTestsread back "the newest session" - it orderedUserSessionsbyCreatedAtand took the first row of the whole table. That is safe only because the collectionruns 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:
CreatedAtcomes from a singleTimeProvider.Systemread and two issues can land on the same tick, andIdis a randomGuid,so a tiebreak on it picks deterministically but not necessarily correctly. It now snapshots the
session ids before the request and takes the one that was not there, with
ShouldHaveSingleItemasserting the correlation instead of assuming it. 2/2 green; inverting the new filter to
before.Contains(s.Id)fails 2/2.The factory comment overstated what these tests cover. Its
PostConfigure<ForwardedHeadersOptions>overwrites the flags, the forward limit and both trustlists wholesale, so what runs against
TestConstants.TrustedProxyIpis the real middleware andthe real placement of
UseForwardedHeaders, not theTrustedProxyOptionsbinding that feeds themin production. That binding has its own gate in
Framework.Tests/Web/TrustedProxyOptionsBindingTests, and the comment says so now rather thanreading as if this pinned production.