fix(web): honor X-Forwarded-* so the real client IP reaches the pipeline - #1334
fix(web): honor X-Forwarded-* so the real client IP reaches the pipeline#1334marcelo-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 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.
|
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
|
…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.
|
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 |
Review note from fullstackhero#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 the host stays internal, so OriginOptions:OriginUrl is what makes those links public.
Review note from fullstackhero#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.
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.