Skip to content

Stop believing an address a client made up (GRYT-179) - #45

Merged
sivert-io merged 1 commit into
mainfrom
claude/GRYT-179-trusted-proxy-hops
Aug 11, 2026
Merged

Stop believing an address a client made up (GRYT-179)#45
sivert-io merged 1 commit into
mainfrom
claude/GRYT-179-trusted-proxy-hops

Conversation

@sivert-io

Copy link
Copy Markdown
Member

getClientIp read x-forwarded-for and took the first value, falling back to the socket address only when the header was absent. That header is one any client can set, and the leftmost entry is precisely the part a client controls.

So every per-IP protection in the server could be walked around by varying one string per connection.

Measured, not theorised: 30 joins against a cap of 19, while testing server#44. The same trick defeats the invite brute-force cooldown — the thing standing between an attacker and guessing invite codes — and the per-IP half of every chat and moderation limit.

The fix

Count from the right, which is the only end that means anything. x-forwarded-for grows left to right as a request crosses proxies, so the rightmost entries were appended by infrastructure and the leftmost is whatever was claimed. Asking for more hops than the chain holds means the request didn't come through them, so the socket address wins rather than the shortfall.

Read this bit before merging

Nothing is trusted by default, and that is a trade rather than a free win.

Direct exposure: correct and now unspoofable.

Behind a reverse proxy or tunnel — which is how dev.lan runs — you must set GRYT_TRUSTED_PROXY_HOPS=1. Otherwise every client arrives wearing the proxy's address and shares a single rate-limit bucket, so one noisy person throttles everybody. That failure is quiet in one direction and loud in the other, which is why it's in example.env in capitals.

Worth checking what the tunnel in front of dev.lan actually appends before picking the number.

Verified against a live server

Result
Spoofed x-forwarded-for, default config 19 through, then rate_limited — same as an honest client, spoofing buys nothing
Before this change, same test 30 through — the cap simply didn't apply
GRYT_TRUSTED_PROXY_HOPS=1, attacker varies the left value, real address on the right 19 through, then rate_limited — the claim is ignored, the appended address is used

That last row is the one that shows it reads the correct end.

Also

Documented GRYT_INVITE_MAX_JOINS_PER_HOUR from server#44 in example.env while I was in there — it was added without an entry.

🤖 Generated with Claude Code

getClientIp read x-forwarded-for and took the first value, falling back
to the socket address only when the header was absent. That header is one
any client can set, and the leftmost entry is precisely the part a client
controls — so every per-IP protection in the server could be walked
around by varying one string per connection.

Not theory. Measured 30 joins against a cap of 19 while testing something
else, and the same trick defeats the invite brute-force cooldown, which
is the thing standing between an attacker and guessing codes.

Now it counts from the right, which is the only end that means anything:
x-forwarded-for grows left to right as a request crosses proxies, so the
rightmost entries were appended by infrastructure and the leftmost is
whatever was claimed. Asking for more hops than the chain holds means the
request did not come through them, so the socket address wins rather than
the shortfall.

Trusts nothing by default. That is the safe end of the trade but it is a
trade: a server behind a reverse proxy or tunnel must set
GRYT_TRUSTED_PROXY_HOPS=1, or every client arrives wearing the proxy's
address and shares one rate-limit bucket with everyone else. Said plainly
in example.env, in capitals, because getting it wrong is quiet in one
direction and loud in the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sivert-io
sivert-io merged commit 90b0543 into main Aug 11, 2026
1 check passed
@sivert-io
sivert-io deleted the claude/GRYT-179-trusted-proxy-hops branch August 11, 2026 20:35
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.

1 participant