Skip to content

Tiered access, security fixes, and v1 cleanup#7

Merged
Jacob-Lasky merged 10 commits into
mainfrom
feature/tts-test-mode
Jul 25, 2026
Merged

Tiered access, security fixes, and v1 cleanup#7
Jacob-Lasky merged 10 commits into
mainfrom
feature/tts-test-mode

Conversation

@Jacob-Lasky

Copy link
Copy Markdown
Owner

What

Brings the deployed code into git, closes three verified vulnerabilities, makes access tiered so the demo actually works for a visitor, and retires the v1 Flask remnants.

The deployed app had been running code that existed in no commit (a manual fly deploy from a dirty tree). dfc88c5 makes it reproducible; everything after builds on it.

Security (all verified exploitable against a local server, all were live)

  • base_url forwarded DEEPGRAM_API_KEY to any host a caller named. It was interpolated raw into f"https://{base_url}/v1/listen" next to an Authorization header, so a caller chose the destination and could inject path/query. base_url="postman-echo.com/post?x=" delivered Token <key> to that third party. Now an operator-controlled ALLOWED_STT_HOSTS allowlist requiring a bare hostname.
  • /upload wrote anywhere the process could write. TEMP_DIR / file.filename lets an absolute filename replace the base entirely, and ../ traverse out. Both confirmed. Now _safe_temp_path().
  • callback was forwarded to Deepgram, which POSTs the transcript to a caller-chosen URL on this account. Now denied, including through the extra merge that runs after the filter loop.

Access is a budget, not a wall

A token gate left untokened visitors with a page that loaded and did nothing, which is wrong for a capability demo. Anonymous visitors now get a working app, mic streaming included, under a small allowance; the token lifts the limits.

Anonymous With token
Requests 15/IP/5min, 300/hr across all anon unlimited
Concurrent streams 3 unlimited
Stream length 120s unlimited
TTS / upload 400 chars / 10MB 2000 chars / 100MB

The global hourly ceiling is the load-bearing control, not the per-IP limit: IPs are cheap. Two spend paths a rate limit structurally cannot bound are capped separately: a mic stream (one connect, hours of audio) and an audio URL of unknown length.

Review findings fixed

  • Five near-identical parameter-encoding loops collapsed onto stt.options.serialize_params / query_string.
  • stt/client.py imported requests, dropped in the v2 migration, so scripts/test_redaction.py died on ModuleNotFoundError and STTClient was unusable. Ported to httpx.
  • transcribe_batch passed raw bools to the HTTP layer, which sends "True"; Deepgram rejects that.
  • /upload buffered the whole body before checking size, so an oversized upload OOM'd a 512mb machine before the cap applied.
  • The rate limiter leaked a deque per IP ever seen; caught by its own test.
  • authRequired in app.js was set twice and read nowhere.

Cleanup

Retired requirements*.txt, the starter-skin deepgram.toml, the flask-live-transcription project name, and static/script.js. Rewrote the weekly SDK-bump workflow, which had been pip-installing Flask 3.0.0 on Python 3.10 for a project requiring 3.12 and littering update-deepgram-sdk-v* branches. Added test.yml, because fly-deploy.yml had no test gate.

Verification

86 tests, 1 skipped, up from 30. Live-verified with the gate on: 15 anon requests then 429, privileged unaffected, 401 chars rejected for anon and accepted for privileged, socket refuses bad tokens.

Visual artifact, three states in /coding/dump/stt-tier-probe/: anonymous (budget banner, socket CONNECTED, recording enabled), privileged (no banner, token stripped from URL), limit-hit (red banner, app still usable).

Note

ALLOWED_STT_HOSTS must include the AI Works host to keep testing flows through base_url. deepgram-sdk is still pinned 6.0.1 against 7.6.0 current, deliberately left as its own change.

🤖 Generated with Claude Code

Jake Lasky and others added 10 commits March 18, 2026 15:45
- Add ElevenLabs TTS backend (API-only, hidden from UI)
  - /api/tts-voices endpoint to list ElevenLabs voices
  - /api/tts-transcribe accepts tts_provider param
- Replace free-text voice input with language-filtered dropdown
  - 7 language tabs: English, Spanish, German, French, Italian, Dutch, Japanese
  - 90+ Deepgram Aura-2 voices with name, accent, and gender labels
- Defaults to English / Asteria on load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a base_url override so the Explorer can drive any host serving a
Deepgram-shaped /v1/listen, which is how an AI Works flow's compat
endpoint gets tested through the same params panel.

The deepgram-sdk only connects to api.deepgram.com, so _stt_streaming
hands off to a new _stt_streaming_raw when base_url points elsewhere.
That path uses the raw websockets package, builds the query string
itself, and returns raw_responses so an unfamiliar upstream schema can
be inspected. It parses four transcript shapes: an aiworks deepgram_stt
wrapper (object or single-element list), a plain Results event, a
{"type":"text"} event, and a bare top-level transcript.

Frontend picks up the ElevenLabs voice cache and language filtering,
plus a grouped redact multi-select with Select All.

Note: TTS generation still always goes to api.deepgram.com. Only the
STT leg is redirected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
av was added but nothing imports it. websocket-client is only reachable
from stt/client.py's sync STTClient, which only scripts/test_redaction.py
uses, so it is dev tooling rather than an app runtime dependency.

Promoting it to [project] dependencies broke the v2 migration guard,
which asserted the string was absent from the whole pyproject. That
assertion was too broad: the real constraint is that the served async
app never ships the sync gevent-era WebSocket path. Retarget the test
at [project] dependencies via tomllib and rename it accordingly.

Also replace the hardcoded /coding/... absolute paths in this file with
a REPO_ROOT derived from __file__, which pinned the suite to one machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three issues on a public unauthenticated app, all verified exploitable
against a local server on 2026-07-25 and all live in production.

1. base_url forwarded DEEPGRAM_API_KEY to any host a caller named.
   base_url was interpolated raw into f"https://{base_url}/v1/listen"
   alongside an Authorization header, so it controlled the destination
   host AND could inject path and query. A request with
   base_url="postman-echo.com/post?x=" delivered "Token <key>" to that
   third party. Replaced with _resolve_stt_host(), which requires a bare
   hostname with optional port and exact membership in an
   operator-controlled ALLOWED_STT_HOSTS env allowlist. The aiworks
   workflow is preserved by adding that host to the env var; what is
   gone is caller-chosen destinations.

2. /upload wrote anywhere the process could write. path = TEMP_DIR /
   file.filename let an absolute filename replace the base entirely
   (Path("/tmp/x") / "/etc/passwd" is "/etc/passwd") and let ../
   traverse out. In the container that is arbitrary overwrite of app
   code. Now routed through _safe_temp_path(), which takes the basename
   only and asserts the resolved parent is TEMP_DIR. /files reads go
   through the same guard.

3. callback was forwarded to Deepgram, which POSTs the finished
   transcript to a URL of the caller's choosing on this account: data
   exfil plus arbitrary async jobs. Added DENIED_PARAMS, applied both in
   the filter loop and again to the extra dict, because that merge runs
   afterward and would otherwise smuggle it straight through.

Also adds spend and disk caps (MAX_TTS_CHARS, MAX_UPLOAD_BYTES) since
nothing authenticates callers, and routes /transcribe's error paths
through _clean_error so they scrub credentials like the TTS route does.

Does NOT close the remaining hole: every endpoint is still
unauthenticated. Caps and allowlists blunt the abuse, they do not stop
someone spending the key's budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo still carried its Deepgram-starter-template skin and a duplicate
dependency pin from the pre-v2 Flask/gevent stack. Both were actively
wrong rather than merely untidy.

- .github/workflows/update-deepgram-sdk.yaml rewritten. It ran weekly,
  rewrote the pin in requirements.txt, then pip-installed that file
  (Flask 3.0.0, deepgram-sdk v3.10.1) on Python 3.10 for a project
  requiring >=3.12, then ran pytest. It could only no-op or open a
  broken PR, and it left roughly a dozen abandoned
  update-deepgram-sdk-v* branches on the remote. Now uses uv, reads the
  interpreter from requires-python, bumps pyproject + uv.lock, gates the
  PR on the suite passing, and uses create-pull-request so it stops
  littering branches.
- requirements.txt and requirements-dev.txt deleted. pyproject.toml plus
  uv.lock is the single source of dependency truth, which is what the
  Dockerfile and README already used. Two homes for one pin is what the
  workflow above kept tripping over.
- deepgram.toml rewritten. It still advertised the app as a Flask starter
  and told users to pip install requirements.txt.
- pyproject renamed from flask-live-transcription to
  deepgram-stt-explorer, and [tool.uv] dev-dependencies migrated to PEP
  735 [dependency-groups] since uv deprecated the old field.
- Dropped the python-socketio[asyncio] extra. It does not exist, so uv
  warned on every command; .planning/STATE.md already records that async
  support is built in and not gated by an extra.
- static/script.js deleted. Superseded by static/app.js and referenced
  nowhere.
- The scripts/ evidence harness is now tracked, with its hardcoded
  /coding/... output paths replaced by a SCRIPT_DIR derived from
  __file__. Generated CSVs are gitignored so `git add -A` stops sweeping
  them in.
- README documents the abuse limits and the deploy-drift hazard, and no
  longer claims 30 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The UI shell stays public on purpose: GET / and /static/* need no token, so a
shared link is browsable and the params panel is explorable with no wall.
Everything that SPENDS THE DEEPGRAM KEY now requires one: /upload, /files/*,
/transcribe, /api/tts-transcribe, /api/tts-voices, and the SocketIO stream.

The socket is gated deliberately, even though the page that opens it is
public. Mic and file streaming spend the key over that socket, so leaving it
open would be a free key-spend path straight through the open UI. There is no
way to authenticate "our own page" as distinct from a user: the browser is
attacker-controlled, so anyone who loads the page can read its network calls
and replay them.

- APP_ACCESS_TOKEN unset means no gate, which is what local dev and the test
  suite want. REQUIRE_AUTH=true (set in fly.toml) makes the app refuse to boot
  without a token, so a forgotten fly secret fails the deploy loudly instead
  of quietly serving an open API.
- Token accepted as X-App-Token, Authorization: Bearer, or ?token=. The query
  form is not laziness: an <audio src="/files/..."> element cannot send
  headers, so file playback has no other way to authenticate.
- Compared with secrets.compare_digest, not ==, so a wrong token cannot be
  recovered by timing.
- Frontend reads ?token= once, keeps it in sessionStorage, strips it back out
  of the visible URL so it cannot leak into a screenshot or a copied link, and
  attaches it to every call including the socket handshake. A 401 or a refused
  connect surfaces as a toast telling the user to open the link with a token.
- scripts/test_rrhaphy_*.py read APP_ACCESS_TOKEN from the environment.
  test_time_pronunciation.py is untouched: it calls Deepgram directly with its
  own key and never touches this app's API.

Live-verified with the gate on: UI 200 without a token; all five API routes
401 without one and pass with header, bearer, or query forms; a wrong token
401s; SocketIO refuses no-auth, wrong, and empty tokens and accepts the
correct one.

Still open: cors_allowed_origins is "*" and there is no rate limit, so a token
holder can spend without bound. Use a scoped usage:write Deepgram key here,
not an org-wide one, and set a spend ceiling with alerting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w findings

The previous commit gated the API behind a token, which left an untokened
visitor with a page that loaded and did nothing. This app is a capability
demo as well as a diagnostic tool, so a dead UI is a broken product. Access
is now tiered: an anonymous visitor gets a working app, mic streaming
included, under a small allowance, and the token lifts the limits for the
operator and the sweep harnesses.

Anonymous tier: 15 requests per IP per 5 minutes, 300/hour across ALL
anonymous traffic, 3 concurrent streams, 120s per stream, 400 TTS chars,
10MB uploads. Privileged: unlimited requests, 2000 chars, 100MB.

The GLOBAL hourly ceiling is the load-bearing control, not the per-IP limit.
IPs are cheap, so per-IP only stops one person hammering; the global cap is
what makes a distributed attempt pointless. In-process counters are correct
because fly.toml already pins this to one machine with one worker for the
socketio session state.

Two spend paths the rate limit could not bound, now closed:
- A mic stream. One anonymous connect could stream for hours, so anonymous
  streams stop on a wall-clock cap.
- An audio URL. Uploads are capped as they stream, but a URL hands Deepgram
  an object of unknown length, so /transcribe HEADs it for anonymous callers
  and refuses a missing or oversized Content-Length. Refusing an unknown
  length is deliberate: failing open would make the cap decorative.

Review findings fixed in the same pass:

- DRY: five near-identical parameter-encoding loops (three in app.py, one in
  _stt_streaming_raw, one in STTClient.build_url) collapsed onto
  stt.options.serialize_params / query_string. A change to how one value type
  is encoded had to be made in five places or silently diverge.
- stt/client.py imported `requests`, which the v2 migration removed from the
  dependencies, so `scripts/test_redaction.py` died on ModuleNotFoundError
  and STTClient was entirely unusable. Ported to httpx (already a runtime
  dep) as .planning/research/STACK.md always intended.
- transcribe_batch passed raw clean_params output to the HTTP layer, which
  encodes a Python bool as "True"/"False". Deepgram rejects that. It now uses
  serialize_params like every other call site.
- /upload buffered the entire body via `await file.read()` and checked the
  size afterwards, so an oversized upload OOM'd a 512mb machine before the
  cap could reject it. Now streams to disk against a running total.
- The rate limiter leaked one deque per IP that ever visited: pruning is lazy
  and per-IP, so deleting only already-empty deques never collected the
  expired-but-unpruned ones. Amortized sweep prunes then deletes. Caught by
  its own test.
- `authRequired` in app.js was set in two places and read nowhere. Replaced
  with a tier model the UI actually renders.
- /transcribe's error paths now scrub via _clean_error, and the stale comment
  block describing the old binary gate is rewritten.

Visual artifact (three states, /coding/dump/stt-tier-probe/):
- tier-anon.png: banner explains the budget, socket CONNECTED, recording
  enabled. Alpine tier state {privileged:false, maxStreamSeconds:120,
  maxTtsChars:400}.
- tier-privileged.png: no banner, {privileged:true, maxStreamSeconds:null,
  maxTtsChars:2000}, and the token stripped from the visible URL.
- tier-limit.png: red banner naming the limit, app still usable.

Live-verified: 15 anonymous requests pass then 429; a privileged caller is
unaffected; 401 chars rejected for anonymous and accepted for privileged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fly-deploy.yml deploys every push to main with no test gate, so a red suite
could reach production. test.yml runs the suite on every PR and every push to
main. It needs to be marked required in branch protection to actually block.

Uses uv and reads the interpreter from requires-python rather than pinning a
version that can drift from the project, same as the SDK-bump workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Queried each project's latest release rather than pinning from memory:
setup-uv v5 to v9 (v5 targets the deprecated Node 20 and CI warned about it),
checkout v4/v5 to v7, create-pull-request v7 to v8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`@v9` does not resolve. setup-uv's newest release is v9.0.0 but its floating
major tags stop at v7, so the previous commit's `@v9` failed the run with
"unable to find version v9". Pin the exact tag instead of falling back to v7.

v9.0.0's only breaking change is `prune-cache` defaulting to false; the
`enable-cache` input this workflow uses is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jacob-Lasky
Jacob-Lasky merged commit e39e45a into main Jul 25, 2026
1 check passed
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