fix(security): read the forwarded-for entry a trusted proxy wrote, not the caller's - #93
Merged
Merged
Conversation
…t the caller's Rate limiting partitioned on the leftmost X-Forwarded-For entry. A proxy appends to that header rather than replacing it, so a caller sending "X-Forwarded-For: 9.9.9.9" arrives as "9.9.9.9, <real client>" and position zero is a value they chose. Varying it per request minted a fresh partition every time, which is throttling defeated -- with TrustForwardedFor correctly set to true, on the deployed app. TrustForwardedFor was the setting that got all the attention, and it turns out to answer only half the question. Whether to read the header is one decision; which entry of it a proxy actually wrote is the other, and only the second is a trust boundary. A unit test asserted the wrong semantics by name -- The_leftmost_entry_in_a_ forwarded_chain_is_the_client -- so the defect was pinned in place by a passing test that described the code accurately and the protocol incorrectly. Read from the trusted end instead. With TrustedProxyHops proxies in front the client sits at count - hops; everything left of it is caller-supplied and ignored. A chain shorter than the hop count falls back to the connection address rather than trusting an entry nearer the caller. Entries are normalised through IPAddress/IPEndPoint, which drops the ":port" App Service appends. That mattered: the source port is ephemeral, so keeping it would have partitioned per connection instead of per caller and reopened the same hole from the other side. ProxyConfigurationCheck gained a third warning, for a hop count higher than the chain arriving -- that misconfiguration reproduces the global-cap outage while the setting that explains it reads as correct. Verified: the pre-fix code returns "9.9.9.9" for the forged chain and the new code returns the real client, both exercised directly. 530 tests pass, format clean, no warnings under -warnaserror. Not verified: the live spoof test against App Service. This sandbox's network policy blocks azurewebsites.net (403 on CONNECT), so whether the platform normalises the header before the app sees it is still unconfirmed from here. The fix is correct either way -- it stops depending on that question having a favourable answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Rate limiting partitioned on the leftmost
X-Forwarded-Forentry. A proxy appends to that header rather than replacing it, so a caller sendingX-Forwarded-For: 9.9.9.9arrives as9.9.9.9, <real client>— position zero is a value the caller chose. Varying it per request minted a fresh partition every time.That is throttling defeated with
TrustForwardedForcorrectly set totrue, on the deployed app. It's the same forgeryRateLimitOptionsalready warned about, reached through the other door — and setting that flag right did not close it.TrustForwardedForwas the setting that got all the attention, and it answers only half the question. Whether to read the header is one decision; which entry of it a proxy actually wrote is the other, and only the second is a trust boundary.The test that held it in place
It passed. It described the code accurately and the protocol incorrectly, and it read as a specification while documenting a bug.
The fix
Count from the trusted end. With
TrustedProxyHopsproxies in front the client sits atcount - hops; everything to its left is caller-supplied and ignored. A chain shorter than the hop count falls back to the connection address rather than trusting an entry nearer the caller.The port mattered too. App Service appends the client as
ip:port, and the source port is ephemeral. Taking the rightmost entry naively would have partitioned per connection instead of per caller — reopening the identical hole from the other side. Entries are normalised throughIPAddress/IPEndPoint, which drops it.IPAddressis tried first so a bare IPv6 address isn't mistaken for ahost:portpair on account of its colons.Values that aren't addresses (
unknown,_hidden, junk) are discarded rather than used as keys — they aren't identities, and every caller sending the same placeholder would otherwise share one budget under a name that reads specific.Changes
ClientAddressRateLimitOptionsTrustedProxyHops(default1;2behind a CDN/WAF)ProxyConfigurationCheckappsettings.jsonShippedConfigurationTestsClientAddressTestsA hop count set too high runs off the front of every chain, so each request falls back to the proxy's own address — recreating the global-cap outage while the setting that explains it reads as correct. That's what the new warning is for.
Verification
9.9.9.9for the forged chain; new code returns the real client. Run directly against both implementations, not inferred.dotnet format --verify-no-changesclean; 0 warnings under-warnaserror.Not verified: the live spoof test against App Service. This sandbox's network policy blocks
azurewebsites.net(403 on CONNECT), so whether the platform normalises the header before the app sees it is unconfirmed from here. The fix is correct either way — it stops depending on that question having a favourable answer.Provision.ps1gained a comment only (nopwshavailable to syntax-check it);TrustedProxyHopsis deliberately not set as an Azure app setting, since the shipped default of1is already right for App Service on its own.Generated by Claude Code