Tiered access, security fixes, and v1 cleanup#7
Merged
Conversation
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 deployfrom a dirty tree).dfc88c5makes it reproducible; everything after builds on it.Security (all verified exploitable against a local server, all were live)
base_urlforwardedDEEPGRAM_API_KEYto any host a caller named. It was interpolated raw intof"https://{base_url}/v1/listen"next to anAuthorizationheader, so a caller chose the destination and could inject path/query.base_url="postman-echo.com/post?x="deliveredToken <key>to that third party. Now an operator-controlledALLOWED_STT_HOSTSallowlist requiring a bare hostname./uploadwrote anywhere the process could write.TEMP_DIR / file.filenamelets an absolute filename replace the base entirely, and../traverse out. Both confirmed. Now_safe_temp_path().callbackwas forwarded to Deepgram, which POSTs the transcript to a caller-chosen URL on this account. Now denied, including through theextramerge 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.
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
stt.options.serialize_params/query_string.stt/client.pyimportedrequests, dropped in the v2 migration, soscripts/test_redaction.pydied onModuleNotFoundErrorandSTTClientwas unusable. Ported tohttpx.transcribe_batchpassed raw bools to the HTTP layer, which sends"True"; Deepgram rejects that./uploadbuffered the whole body before checking size, so an oversized upload OOM'd a 512mb machine before the cap applied.authRequiredinapp.jswas set twice and read nowhere.Cleanup
Retired
requirements*.txt, the starter-skindeepgram.toml, theflask-live-transcriptionproject name, andstatic/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 litteringupdate-deepgram-sdk-v*branches. Addedtest.yml, becausefly-deploy.ymlhad 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_HOSTSmust include the AI Works host to keep testing flows throughbase_url.deepgram-sdkis still pinned 6.0.1 against 7.6.0 current, deliberately left as its own change.🤖 Generated with Claude Code