# Security App-specific notes. The shared controls — firewall, container isolation, the OAuth gate, host hardening — are documented once, for the whole droplet, in [the Orchestrator's security doc](../../Orchestrator/docs/security.md). Read that first; this covers what's particular to ArchiveAI-TTS. ## Two deployments | | DO droplet | HuggingFace Space | |---|---|---| | UI access | Google OAuth, 2-address allowlist | HF Space visibility settings | | API access | `X-API-Token`, OAuth-exempt path | Same app check, no Caddy in front | | Project storage | Local files under `TTS_DATA_DIR` on the volume | HF dataset `tts-turn-projects` | | Glossary storage | HF dataset `tts-glossary` — on both | Same | The fail-closed `API_TOKEN` behavior applies to both. ## This is the one app that writes to HuggingFace during normal droplet operation Exception to how the rest of the droplet works. `TTS_DATA_DIR=/data` is set, so projects and audio go to the attached volume. The pronunciation glossary is stored on a HuggingFace dataset regardless (`GarchenArchive/tts-glossary`, overridable with `GLOSSARY_DATASET_REPO`), because it's shared with the HF Space deployment. Consequences for credentials: - This container's `HF_TOKEN` needs HuggingFace access scoped to **write on the dataset `GarchenArchive/tts-glossary`**, and nothing else. Glossary reads fall back to a local cache, so a read-only or absent token degrades gracefully; saves and removals need write. - Today it's the account-owner token, which also grants org-admin on `GarchenArchive` and `khyentsevision` plus write on `BDRC` and `openpecha`, far beyond what this app needs. Full scoping table: [Orchestrator security → the HuggingFace token](../../Orchestrator/docs/security.md#open-gap-the-huggingface-token). - The same value doubles as the `POST /api/drain` operator secret. If the token is scoped down, drain's bearer value becomes this app's glossary-write token, meaning anyone who can drain also holds glossary write. Acceptable as-is; split into a separate secret if that risk is unacceptable. ## Third-party API keys This app holds the most valuable non-HF credentials on the droplet, because both cost money per call: | Key | Used for | Notes | |---|---|---| | `ELEVENLABS_API_KEY` | Speech synthesis | Metered per character. A leak is a direct billing risk. | | `GEMINI_API_KEY` | Turn-plan generation | Metered per call. | Handling: - Both are passed via `--env-file`, never `-e` on the command line, so they don't appear in the host's `ps` output. - Callers may override either per job (`elevenlabs_api_key`, `gemini_api_key`). Caller-supplied values are sanitised at the job-store layer on write, so they never reach a response body — relevant because `GET /api/jobs` lists every job, not just the caller's. Because it's done on write, any new response builder inherits the protection. - Nothing logs either key. Preserve this if adding logging around the ElevenLabs or Gemini calls. ## The `/api/*` OAuth carve-out This app's Caddy site block uses `authed_app_with_api`, exempting `/api/*` from `forward_auth`. The rule that keeps that safe: > Every route under `/api/*` must authenticate, and must fail closed. A route that doesn't is published to the internet. `/api/ping` is the one intentional exception. ## Input validation - **`project_id` is validated** against `^[A-Za-z0-9_-]+$` on both `POST /api/jobs` and `GET /api/projects/{project_id}`. This matters most in this app: its local-file backend builds real filesystem paths from the value (`projects/{project_id}/project.json`), and at the time the gap was found the container was still running as root. The UI only ever generates IDs via slugify+uuid, so nothing was exploitable through the browser, only through the API. - **Webhook URLs are re-resolved and IP-pinned immediately before delivery.** Synthesis jobs are long-running, so the window between submission-time validation and actual delivery is wide — the DNS-rebinding gap this closes. - **Uploaded caption and turns files are parsed, not executed.** `turns_file` is JSON, parsed with `json.loads`; there is no pickle or YAML path anywhere in the upload handling. ## Known gaps - **The UI's synthesis handlers bypass the job semaphore.** Three separate call sites plus a sweeper thread were out of scope when the API was added. Security-relevant consequence: `/api/drain` does not stop a UI batch already running, and `active_jobs` undercounts real work, so "drained" is not a guarantee that nothing is writing. See [api.md](api.md#concurrency-the-ui-is-not-on-the-same-queue). - **`HF_TOKEN` is over-privileged** (needs only `tts-glossary` write). Fix requires a manual change in the HF web UI. - **Job state is in-memory.** A restart loses queued and running jobs; projects and their audio are durable. - **Single-tenant trust model.** Any holder of `API_TOKEN` can read every project and job. Fine for one CMS plus the Orchestrator; would need per-caller scoping before wider exposure.