Skip to content

fix(web): reject a trusted-proxy ForwardLimit below 1 at startup - #1365

Open
marcelo-maciel wants to merge 8 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/trusted-proxy-forward-limit
Open

fix(web): reject a trusted-proxy ForwardLimit below 1 at startup#1365
marcelo-maciel wants to merge 8 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/trusted-proxy-forward-limit

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Closes #1358.

TrustedProxyOptions.ForwardLimit was passed straight through to ForwardedHeadersOptions with no validation, so two values that nobody means are accepted in silence.

What the two bad values actually do

Measured on .NET 10 by starting a real host with UseExceptionHandler + UseForwardedHeaders in the same order UseHeroPlatform mounts them, and issuing one request:

config request result
ForwardLimit = -1 no forwarded headers at all HTTP 500
ForwardLimit = 0 X-Forwarded-For from a trusted proxy HTTP 200, but RemoteIpAddress and scheme unchanged

Negative is the sharp edge. In ApplyForwarders, entryCount starts at 0, and there is no early return when no forwarded headers are present. 0 > -1 is true, so entryCount is clamped to -1 and new SetOfForwarders[entryCount] throws OverflowException. Because UseForwardedHeaders() sits after UseExceptionHandler(), that is a 500 on 100% of traffic rather than a startup failure a deploy smoke test would catch. -1 is a plausible thing for an operator to write when reaching for "unlimited hops", precisely because the underlying framework option expresses exactly that via null.

Zero is the quiet one. entryCount > 0 truncates to 0, the consume loop runs zero iterations, and forwarded headers stop being processed with no error and no log, reinstating the original symptom (proxy IP in rate-limit partitions and audit trails) while the config still reads as configured.

The fix

Reject anything below 1 where the malformed KnownProxies / KnownNetworks entries are already rejected, with a message naming the setting and the offending value. Verified by execution that a throw from inside that Configure<ForwardedHeadersOptions> delegate surfaces during startup (StartAsync fails, the host never serves), so a bad hop count fails the deploy instead of the traffic.

Scope

  • Extensions.cs: the guard, at the top of the delegate so it also covers the case where no proxies or networks are configured.
  • TrustedProxyOptions.cs: the XML doc records the minimum.
  • TrustedProxyOptionsBindingTests.cs: a theory over -1 and 0, mirroring the two existing "name the setting" tests. Both go red with the guard reverted (checked), and Framework.Tests is 139/139 green with it in place.
  • Docs: docs(security): forwarded-headers trusted-proxy config (TrustedProxyOptions) docs#237 carries the matching wording (reverse-proxy section, production checklist, changelog).

A second commit answers the other note you left for the follow-up on this same type: the class doc now records that only X-Forwarded-For and X-Forwarded-Proto are honoured, that leaving X-Forwarded-Host out 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 mail links pointing wherever the header said), and that the trade-off is Request.Host keeping the internal host behind a proxy. Doc comment only, no behaviour change; docs#237 carries the same note.

Nothing in deploy/, the AppHost or either appsettings sets a value below 1 (both ship 1), so this changes nothing for a correct deployment. It does turn an existing 0 into a hard startup failure, which is the intent.

Two notes

This is stacked on #1334, which introduces TrustedProxyOptions: the branch is cut from fix/web-forwarded-headers, so the diff against main shows that PR's commits until it merges. Raised separately so a follow-up doesn't widen an approved security PR.

This keeps ForwardLimit an int, so "unlimited" stays inexpressible. The larger shape from #1358, making it int? to mirror the framework's null-means-unlimited while still rejecting <= 0, changes the options contract and is still on the table if you prefer it. Say the word and I will send that instead.

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.
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 fullstackhero#1358
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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.
@marcelo-maciel
marcelo-maciel force-pushed the fix/trusted-proxy-forward-limit branch from 972c5f3 to cb62b10 Compare August 17, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TrustedProxyOptions.ForwardLimit is an unvalidated int: a negative value 500s every request, zero silently disables forwarded headers

1 participant