Skip to content

fix(rate-limit): read proxy headers from both websockets shapes; stop CI/runtime drift - #32

Merged
TheArtofSatoshi merged 1 commit into
mainfrom
fix/ws-forward-compat
Jul 19, 2026
Merged

fix(rate-limit): read proxy headers from both websockets shapes; stop CI/runtime drift#32
TheArtofSatoshi merged 1 commit into
mainfrom
fix/ws-forward-compat

Conversation

@TheArtofSatoshi

Copy link
Copy Markdown
Contributor

Found while assessing dependabot #28 (websockets >=16.1,<17.0). Two problems, one root cause. This is a prerequisite for #28 — merge this first.

1. A websockets upgrade would silently disable X-Forwarded-For

websockets 14 swapped websockets.serve from the legacy asyncio server to websockets.asyncio.server, and the two expose the handshake differently. Verified live against both:

serve resolves to request_headers request
13.1 (production) websockets.legacy.server present absent
16.1 (PR #28) websockets.asyncio.server absent present

IPRateLimiter._header only looked at websocket.request_headers, via getattr(..., None). On the new server that yields None — so it would not raise, it would just never find the header again.

With TRUST_PROXY on (as in production, behind a proxy on 172.18.0.0/16) every client would resolve to the proxy's own address and share a single rate-limit bucket: one abusive client throttles everyone, and is_exempt_peer sees a loopback peer for all of them. A silent availability regression no test would catch.

_header now also reads websocket.request.headers. Harmless on 13.x, where request is absent and the getattr chain skips it.

2. CI resolved websockets 15.0.1 while production runs 13.1

requirements-ci.txt allowed <16.0 while requirements.txt pinned <14.0 — the only constraint differing between the two files. CI has been exercising the new asyncio implementation while the server runs the legacy one, on the exact axis where they diverge.

It stayed invisible because the rate-limiter tests fake the session object rather than standing up a real websockets server, so neither implementation was ever actually driven.

Aligned requirements-ci.txt to <14.0 so CI tests what the server runs. Left requirements.txt alone — raising the runtime ceiling changes what the next image build installs and deserves its own deliberate testing (that is what #28 is for), not a drift fix.

Testing

5 new tests: both websocket shapes, X-Real-IP on the new shape, multi-hop selection on the new shape, and a connection exposing neither attribute (must degrade to the socket peer, not raise).

Verified the three new-shape tests fail without the _header change while the legacy ones still pass.

Suite: 1001 passed, 0 failed.

🤖 Generated with Claude Code

… CI/runtime drift

Found while assessing dependabot PR #28 (websockets >=16.1,<17.0). Two
problems, one root cause.

1. A websockets upgrade would silently disable X-Forwarded-For.

websockets 14 swapped `websockets.serve` from the legacy asyncio server to
websockets.asyncio.server, and the two expose the handshake differently.
Verified live against both:

    13.1  serve -> websockets.legacy.server   request_headers present, request absent
    16.1  serve -> websockets.asyncio.server  request_headers ABSENT, request present

IPRateLimiter._header only looked at `websocket.request_headers`, via
getattr(..., None). On the new server that yields None — so it would not
raise, it would just never find the header again. With TRUST_PROXY on (as in
production, behind a proxy on 172.18.0.0/16) every client would resolve to the
proxy's own address and share a single rate-limit bucket: one abusive client
throttles everyone, and is_exempt_peer sees a loopback peer for all of them. A
silent availability regression that no test would catch.

_header now also reads `websocket.request.headers`. Harmless on 13.x, where
`request` is absent and the getattr chain skips it.

2. CI resolved websockets 15.0.1 while production runs 13.1.

requirements-ci.txt allowed <16.0 while requirements.txt pinned <14.0 — the
only constraint that differed between the two files. So CI exercised the new
asyncio implementation while the server runs the legacy one, on the exact axis
where they diverge. The drift was invisible because the rate-limiter tests
fake the session object rather than standing up a real websockets server, so
neither implementation was ever actually driven.

Aligned requirements-ci.txt to <14.0 so CI tests what the server runs. Left
requirements.txt alone: raising the runtime ceiling changes what the next
image build installs and deserves its own deliberate testing (that is what
PR #28 is for), not a drift fix.

5 new tests cover both websocket shapes plus a connection exposing neither
(must degrade to the socket peer, not raise). Verified the three new-shape
tests fail without the _header change while the legacy ones still pass.

Suite: 1001 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@TheArtofSatoshi
TheArtofSatoshi merged commit 100d99f into main Jul 19, 2026
7 checks passed
@TheArtofSatoshi
TheArtofSatoshi deleted the fix/ws-forward-compat branch July 19, 2026 16:30
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