Repository navigation
security: resolve client IP through a trusted-proxy allowlist (rate-limit bypass) - #11
Open
IbraTech04 wants to merge 1 commit into
Open
IbraTech04 wants to merge 1 commit into
IbraTech04 wants to merge 1 commit into
Conversation
RateLimitingFilter.getClientIp took X-Forwarded-For.split(",")[0] --
the first entry, which is fully attacker-controlled -- while nginx used
$proxy_add_x_forwarded_for, which APPENDS the real peer to whatever the
client already sent. So the value being trusted was the one the caller
supplied. Rotating a random X-Forwarded-For per request minted a fresh
Caffeine bucket every time and made the 100 req/min limiter a no-op,
including in front of the unauthenticated /api/agent/enroll endpoint
where it is the only abuse guard. AgentEnrollmentController.resolveClientIp
duplicated the same helper, so enrollment lastSeenIp and audit records
could be poisoned with arbitrary attacker-chosen addresses.
New ClientIpResolver honours the header only when the peer is inside a
configured trusted-proxy CIDR set, then reads hop (size - trustedProxyCount)
-- the entry our own proxy contributed. With no trusted proxies
configured it ignores the header entirely and uses the peer address,
so the default is safe and an unconfigured deployment fails closed.
Both call sites now share it.
nginx overwrites rather than appends (proxy_set_header X-Forwarded-For
$remote_addr) so a client's own header can never survive to the app,
and compose sets the bridge network as the trusted range.
The existing clientIpIsTakenFromXForwardedForFirstHop test asserted the
vulnerable behaviour and is replaced by three tests covering a trusted
proxy, a spoofing attempt from an untrusted peer, and the no-header
fallback. MethodSecurityTestConfig gains the resolver bean, which
@WebMvcTest slices need since they pick up the filter but not its
dependency.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Collaborator
Author
|
Not sure how necessary this is considering we use Cloudflare to protect our backend. Will research more and work based off of that. |
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 problem
docker/nginx/nginx.conf:96usedproxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, which appends the real peer to whatever the client already sent. Takingsplit(",")[0]therefore read the entry the caller supplied.Consequence: rotate a random
X-Forwarded-Foron each request and every request gets a fresh Caffeine bucket. The 100 req/min limiter becomes a no-op — including in front ofPOST /api/agent/enroll, the unauthenticated device-enrollment endpoint where the rate limiter is the only abuse guard on token redemption.AgentEnrollmentController.resolveClientIpduplicated the same helper, so enrollmentlastSeenIpvalues and audit records could be stamped with arbitrary attacker-chosen addresses.The fix
A single shared
ClientIpResolver:X-Forwarded-Foronly when the peer is inside a configured trusted-proxy CIDR set.size - trustedProxyCount— the entry our own proxy contributed, not the first one. This is correct under both the old appending config and the new overwriting one.At the edge, nginx overwrites instead of appending (
proxy_set_header X-Forwarded-For $remote_addr), so a client's own header can never reach the app at all.docker-compose.ymlsets the bridge network as the trusted range, since only nginx can reach the app container (it is not port-published).A note on the test suite
clientIpIsTakenFromXForwardedForFirstHopasserted the vulnerable behaviour — that two requests from different peers sharing a forged first hop share a bucket. It is replaced by three tests:forwardedHeaderFromATrustedProxyIdentifiesTheClient— the header is honoured behind our proxy.spoofedForwardedHeaderFromAnUntrustedPeerCannotEvadeTheBucket— the actual fix: a direct caller rotating a forged header still gets 429.trustedProxyWithoutAForwardedHeaderFallsBackToThePeerAddress.MethodSecurityTestConfiggains the resolver bean:@WebMvcTestslices pick upRateLimitingFilter(it is a servlet filter) but not the resolver it depends on, so without it five test classes fail to start their context. No trusted proxies are configured there, which is both the safe default and the right model for a slice with no proxy in front of it.Verification
Merge note
The sibling account-lockout PR keys lockout on
(email, clientIp)with its own temporary IP resolution and a TODO to adopt this resolver. Once both land, that TODO is a one-line swap to inject this bean.🤖 Generated with Claude Code
https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm