Skip to content

Gate the chat's websocket, and make a captcha pass expire (review, area 1b) - #305

Merged
adamjohnwright merged 1 commit into
mainfrom
review-1b-gate
Oct 3, 2026
Merged

adamjohnwright merged 1 commit into
mainfrom
review-1b-gate

Conversation

@adamjohnwright

Copy link
Copy Markdown
Contributor

These fixes come from the max-level review of the rest of the public surface. Each problem was reproduced before it was fixed.

Finding Fix
The captcha never saw the chat's websocket. @app.middleware("http") doesn't see websocket connections, so a client that opened the socket directly got the full chat with no captcha. WebsocketGate, an ASGI middleware that refuses the handshake (close 1008, which the client sees as a 403) unless there is a current pass.
Guests could reset their only message limit. It was per session, and the client picks the session id. A per-captcha-solve limiter for guests: CHAT_MESSAGES_PER_SOLVE (default 100) per CHAT_SOLVE_WINDOW_SECONDS (default 3h).
A captcha pass never expired. The cookie was value|HMAC(value) with no issue time. util/captcha_cookie.py: v2.<issued>.<nonce>|HMAC. It expires on the server after 12h and is renewed after 30 min of use. Old-format cookies are refused.
The Cloudflare check blocked every session. requests.post was blocking, and a non-JSON reply was a 500. Uploads to the check form were unbounded and spooled to disk. Async httpx, with any failure treated as "not verified". request.form(max_files=0, max_fields=4, max_part_size=8192).
The Referer check turned away the wrong people. It returned 400 to readers coming from an http page. Removed. TLS is Apache's job, and the cookie is Secure.
The API exemption was too wide. It also exempted /chat/guest/api-anything. The prefix is now matched with its trailing slash.

Tests

  • tests/api/test_captcha_gate.py drives the real app over HTTP and websocket, in its own process.
    • Covers: no cookie, the old format, an expired cookie, a current pass, renewal, the plain-http referrer, the look-alike path, and a file on the form.
    • Removing the websocket gate makes its three websocket tests fail.
  • tests/util/test_captcha_cookie.py covers expiry, renewal, nonces, tampering, the old format, and future timestamps.
  • The per-solve limiter sits in the Chainlit handler and isn't unit-tested.

Verified in a real browser

I ran this through Turnstile's always-pass test keys over HTTPS (~/chat-uitest/gate_browser.py):

  • The captcha passes and the v2. cookie is set.
  • The browser's websocket is accepted, and an answer arrives.
  • The handoff-through-captcha test (handoff_gate.py) passes: first visit, reload, returning visitor.

Worth knowing

  • Everyone will see the captcha once more. Old-format cookies are refused after this deploys.
  • Production would still accept beta's new cookies. Production runs the old check, and beta signs with production's Turnstile secret. That was already true of beta's old cookies; it needs separate keys for beta, or this deployed to production.

🤖 Generated with Claude Code

From the review of the public surface (area 1b). Reproduced first, then
fixed, then checked in a real browser through Turnstile's test keys.

- The captcha gate was HTTP middleware, which never sees a websocket. A
  client opening the chat's socket.io websocket directly got the whole
  chat with no captcha. An ASGI middleware now refuses ungated websocket
  handshakes (close 1008, a 403 to the client).
- Guests' only per-person limit was per session, and the client picks the
  session id. Guests are now also limited per captcha solve (100 messages
  per 3h, configurable), which a client cannot reset without solving again.
- The cookie was value|HMAC(value) with no issue time: one solve was a
  pass for ever, for anyone. It now carries an issue time and a nonce,
  expires on the server after 12h, and renews while in use so an open tab
  keeps working. Old-format cookies are refused: readers solve once more.
- verify_captcha called the blocking requests.post on the shared event
  loop for every anonymous POST, and a non-JSON reply was a 500. Now
  async httpx, failure treated as not verified, and the form is bounded
  (no files) -- it spooled unbounded uploads to the shared disk.
- The Referer check returned 400 to readers arriving from any http page
  and stopped no one. Removed.
- The API exemption is the prefix with its slash; /chat/guest/api-x was
  exempt too.

tests/api/test_captcha_gate.py drives the real app over HTTP and
websocket in its own process; with the websocket gate removed, its three
websocket tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@adamjohnwright
adamjohnwright merged commit b3344b6 into main Oct 3, 2026
10 checks passed
@adamjohnwright
adamjohnwright deleted the review-1b-gate branch October 3, 2026 14:37
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