Skip to content

feat(access): password unlock box as a typeable alternative to the token - #16

Merged
Jacob-Lasky merged 1 commit into
mainfrom
feat/password-unlock
Oct 5, 2026
Merged

Jacob-Lasky merged 1 commit into
mainfrom
feat/password-unlock

Conversation

@Jacob-Lasky

Copy link
Copy Markdown
Owner

What

  • Optional APP_PASSWORD (≥12 chars), accepted anywhere APP_ACCESS_TOKEN is.
  • The anonymous banner gets an Unlock box, so the 48-hex token never has to be pasted into a ?token= URL.
  • Wrong passwords are rate-limited: 5 per IP per 15 min, and 50 per hour across all callers. A caller who is locked out doesn't get their guesses checked at all.
  • The token is never rate-limited, so it still works during a lockout.

Why the limits are required

The socket connect resolved the access tier without charging any request and reported the result in access_tier. That gave anyone an unlimited way to test guesses. It didn't matter for a 48-hex token, but it does for a password a person can remember.

Fixes found along the way

  • The banner shipped unstyled. static/style.css was never linked from index.html. Its rules move into the inline <style>, and the dead file is deleted.
  • Live HTTP 500 on any non-ASCII token (?token=jalape%C3%B1o → 500, measured on prod). compare_digest raises TypeError on non-ASCII str; the code now compares bytes.
  • _enforce_access ran in FastAPI's thread pool, so a parallel burst could get past the anonymous and password counters. It's now a coroutine.
  • uvicorn's access log recorded /files/…?token=<credential>. The value is now redacted.
  • engineio hardcodes REMOTE_ADDR=127.0.0.1, so the socket fallback now reads the client address from the ASGI scope.
  • X-Auth-Error response header, so the page drops a credential the server stopped accepting instead of showing "unlimited" while it's actually limited.

Verification

  • uv run pytest -q: 187 passed, 1 skipped. 19 new tests, none removed.
  • Mutation checks: removing failure counting, removing the window sweep, restoring the str comparison, or making the dependency sync again each turns tests red.
  • Playwright, against a local server with a test password: wrong password shows the error and the credential is forgotten from both sessionStorage and socket.auth; Unlock is disabled during a stream; the correct password removes the banner and survives a reload; an HTTP rejection relocks the page with the real anonymous limits.
  • Real uvicorn access log line: GET /files/x.wav?token=[redacted].
  • Prod: a forged Fly-Client-IP did not get past the per-IP limit (429 on the 16th of 17 forged requests), so trusting that header first is safe.
  • Outside review: Codex gpt-6-sol. 5 of 6 findings confirmed and fixed; the IP-spoofing concern was disproved live (above).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CFynXGnSmofasTt5UDQaMh

APP_PASSWORD (optional, >=12 chars) is accepted anywhere APP_ACCESS_TOKEN is,
and the anonymous banner now carries an Unlock box, so the 48-hex token never
has to be pasted into a ?token= URL.

A memorable password is guessable where the token is not, so wrong passwords
are rate-limited per IP (5/15min) and globally (50/h), and a locked-out
caller's guesses are not compared. The token is never limited and stays the
recovery path. The socket connect previously answered every guess with no
charge at all (access_tier); it now goes through the same counters.

Fixes found on the way:
- static/style.css was never linked by index.html, so the access banner had
  shipped unstyled. Its rules move into the inline <style>; the dead file goes.
- secrets.compare_digest on non-ASCII str raises TypeError: a token with an
  accented character was a live HTTP 500. Compare bytes.
- _enforce_access ran in FastAPI's thread pool, so the anonymous limiter's
  check-then-append could overshoot under a parallel burst. Now a coroutine.
- uvicorn's access log recorded /files/...?token=<credential>; redacted.
- engineio hardcodes REMOTE_ADDR=127.0.0.1; socket IP falls back to the scope.
- Responses carry X-Auth-Error so the page drops a credential the server
  stopped honouring instead of showing "unlimited" while metered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFynXGnSmofasTt5UDQaMh
@Jacob-Lasky
Jacob-Lasky merged commit 2208a3b into main Oct 5, 2026
1 check passed
@Jacob-Lasky
Jacob-Lasky deleted the feat/password-unlock branch October 5, 2026 13:06
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