fix(http): stop trusting spoofable IP headers - #40
Open
johnhooks wants to merge 2 commits into
Open
Conversation
johnhooks
marked this pull request as ready for review
August 4, 2026 20:57
johnhooks
commented
Aug 4, 2026
johnhooks
commented
Aug 4, 2026
johnhooks
commented
Aug 4, 2026
| # limiting it to main skips every check on a stacked PR, which is when | ||
| # review needs them most. | ||
| pull_request: | ||
| branches: [ main ] |
Collaborator
Author
There was a problem hiding this comment.
cuz I like a good stack
johnhooks
force-pushed
the
feature/client-ip
branch
from
August 4, 2026 22:13
e986a01 to
71884d5
Compare
The activity log recorded whichever address a request claimed to come from. It read the forwarded-for headers without checking that the request had passed through a proxy, and took the left-most entry, which is the part of that list a client writes. Anyone could attribute their own actions to an address of their choosing, so the log could not be relied on when investigating one. Two other places answered the same question differently. The audit log read the connecting address through a helper of its own, and rate limiting had a third implementation. A limit, a log line, and an audit record could each name a different caller for the same request. They now share one answer, defaulting to the address the webserver saw, which a caller cannot forge. Sites behind a CDN still need the proxy's header to see past the edge, so an operator names it with WORKOS_CLIENT_IP_HEADER. That is a deliberate assertion that the origin accepts nothing but proxied traffic, because an origin reachable directly makes the header attacker-controlled again. The effect is that per-IP limits count real callers instead of a value the caller chooses, and the activity log becomes usable as evidence.
The pull_request trigger filtered on branches, which matches the base of a PR rather than the head. A branch opened against another branch instead of main therefore matched nothing and ran no checks at all: no PHPCS, no PHPStan, no tests, no JS build. Stacked work was the least verified when it needed review the most. Pushes still only build main directly; branches with a PR open are covered by the pull_request event.
johnhooks
force-pushed
the
feature/client-ip
branch
from
August 4, 2026 22:18
71884d5 to
d7e10a3
Compare
bordoni
approved these changes
Aug 5, 2026
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.
Branch:
feature/client-ip→refactor/rate-limiter-2. Stacked on #39; review that one first.The activity log recorded whichever address a request claimed to come from. It read the forwarded-for headers without checking that the request had passed through a proxy, and took the left-most entry, which is the part of that list a client writes. Anyone could attribute their own actions to an address of their choosing, so the log could not be relied on when investigating one. Two other places answered the same question differently, so a rate limit, a log line, and an audit record could each name a different caller for the same request.
Changes
WORKOS_CLIENT_IP_HEADER(constant or environment variable). Behind a CDN that is the only way to see past the edge, and requiring it to be named is a deliberate assertion that the origin accepts nothing but proxied traffic. An origin reachable directly makes the header attacker-controlled again, which is why it is never auto-detected.client_ip()andnormalize_email(). Neither is about counting, and the second had no callers left.Why it matters
Per-IP limits now count real callers rather than a value the caller picks: previously anyone could mint a fresh bucket per request by changing a header, which made every per-IP limit in the plugin advisory. The activity log becomes usable as evidence rather than a record of what a caller asserted.
Behavior changes to note
WORKOS_CLIENT_IP_HEADERis set. Until it is set, those sites keep counting the edge, which is the safe direction.Details in the "Who the caller is" section of
docs/rate-limiting.md.Not included:
LoginBypassstill reads the connecting address directly. It matches the new default so there is no defect, but behind a CDN an IP allowlist would be comparing against the edge, which is worth a follow-up.