Skip to content

Security: yapit-tts/yapit

Security

docs/security.md

Security

Audits

See [[security-audits]] for the consolidated findings, fix verification, and accepted risks from all three red-team audits (v1 Feb 21, v2 Feb 25, v3 Mar 22).

Auth & Trust Boundaries

  • Anonymous sessions are server-issued UUIDs with HMAC-SHA256 tokens. The server validates the token on every request (both HTTP and WebSocket) — not just on claim. This prevents forged anonymous IDs and closes the rate-limit bucket multiplication vector (each anonymous ID gets its own rate-limit bucket). Session endpoint: POST /v1/users/anonymous-session. Frontend auto-renews on 401 (handles secret rotation). See gateway/auth.py.
  • Token-based auth (Bearer tokens via Stack Auth), not cookie-based → CSRF not applicable. The gateway checks a token's ES256 signature and audience locally, so a token stays valid until it expires (Stack Auth's default is 10 minutes), after sign-out and after account deletion alike. Tokens of Stack Auth's anonymous and restricted users are signed with keys outside the project's key set and are rejected.
  • WebSocket auth uses query params (?token=...) — a known limitation since browsers don't support headers on WS upgrade. Tokens appear in proxy logs. Keep Stack Auth token expiry short.
  • Content-addressed resources (audio variants, images) have no per-user ownership check by design — they're shared cache keys (SHA256 of content). This is a deliberate tradeoff for cache efficiency, not a bug.

SSRF

  • All HTTP fetching routes through Smokescreen proxy (http://smokescreen:4750) — network-layer SSRF protection, no TOCTOU/DNS rebinding vulnerability.
  • Playwright must also use the proxy — browser.new_context(proxy={"server": "http://smokescreen:4750"}). Without this, page.goto() bypasses Smokescreen and can reach internal Docker services. Fixed in 174c47d.
  • Application-level IP validation is insufficient for SSRF (DNS rebinding). Smokescreen was chosen over code-level IP validation because DNS rebinding defeats any application-level check.

Frontend Security

  • No dangerouslySetInnerHTML anywhere. Frontend uses a typed AST renderer — backend parses markdown into AST, frontend renders via React components. Unknown node types return null. Raw html_inline nodes dropped in transformer.py.
  • Link href sanitization in inlineContent.tsx: rejects javascript:, vbscript:, data: URI schemes (defense-in-depth behind markdown-it's own filtering). All rendered links have rel="noopener noreferrer" and images have referrerPolicy="no-referrer".
  • CSP not deployed (deferred since beta). Blocker: inline <style> in AccountSettingsPage.tsx needs refactoring to CSS file first.
  • Security headers in frontend/nginx.conf: HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy.

Infrastructure

  • Origin protection (two layers):
    • Application layer: Authenticated Origin Pulls (mTLS) — Traefik requires a client certificate signed by our custom CA. Only our Cloudflare zone has this cert, so direct-to-origin HTTPS is rejected at TLS handshake. Custom cert (not Cloudflare's shared one) because the origin IP is public. Cert expires 2036, CA key in sops.
    • Network layer: Hetzner Cloud Firewall restricts ports 80/443 to Cloudflare IP ranges. Drops TCP packets from non-CF sources before they reach the VPS — prevents slowloris, SYN floods, TLS handshake exhaustion. Managed automatically by scripts/sync-cf-firewall.sh (hourly cron). Do not hand-edit firewall-1 in Hetzner Console. See scripts/sync-cf-firewall.sh.
    • Together these make CF-Connecting-IP trustworthy. See [[vps-setup]] for Traefik config and firewall details.
  • Rate limiting — Two layers. Cloudflare handles volumetric DDoS at the edge. App-level slowapi (yapit/gateway/rate_limit.py) handles per-IP abuse of expensive operations. Global default applies to all routes via SlowAPIMiddleware; expensive endpoints get tighter per-route limits via @limiter.limit() decorators; hot-path/external endpoints (audio, webhook) are exempt via @limiter.exempt. Monthly caps (1000/month per IP) on all document creation endpoints. See yapit/gateway/rate_limit.py for the endpoint table.
  • Client IP resolution: CF-Connecting-IP → nginx map rewrites X-Forwarded-For (falls back to $remote_addr without Cloudflare) → uvicorn --proxy-headers sets request.client.host. AOP guarantees the header is trustworthy in prod. Without AOP (selfhost), the header is spoofable — documented limitation.
  • slowapi gotcha: Decorated routes are handled by the decorator, not the middleware — SlowAPIMiddleware skips them. override_defaults=True (the default) means per-route limits replace the global default, they don't stack. Undecorated routes get the global default via middleware, which uses its own Request from ASGI scope — endpoint function signatures don't matter for the default.
  • Stack Auth dashboard (auth.yapit.md) is behind Cloudflare Access (email auth wall at the edge). SDK auth calls (/api/*) bypass the access policy. See [[vps-setup]] for details.
  • Redis has no auth but is firewall-protected (Hetzner firewall blocks 6379 from internet, Tailscale workers connect via VPN). Not a meaningful risk — if you can reach Redis, you're already on the machine.
  • All containers run as non-root. Custom images use USER appuser (UID 1000); third-party images use their native users (node, nginx, redis). cap_drop: [ALL] on every service strips all Linux capabilities. no-new-privileges: true set globally in /etc/docker/daemon.json (Swarm ignores per-service security_opt). Postgres/metrics-db get selective cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID, KILL] for their entrypoint privilege-drop dance. Each Dockerfile specifies the user; compose files add cap_drop: [ALL] and selective cap_add where needed.
  • Chromium sandbox disabled (accepted risk, see [[security-audits]]) — Playwright launches Chromium with --no-sandbox unless chromiumSandbox: true is passed, and docker/defuddle/app.js doesn't pass it. The renderer executing page JavaScript is unsandboxed; the defuddle container is the boundary: no credentials, non-root, cap_drop: [ALL], no-new-privileges, and only on browser-net, which reaches Smokescreen and the gateway. Website extraction is unauthenticated, so the attacker controls which page Chromium navigates to, which makes keeping Playwright's Chromium current the mitigation that matters. Turning the sandbox on would need a daemon-wide seccomp profile that allows user namespaces (clone/unshare/setns) and chroot, since Docker's default allows those only with CAP_SYS_ADMIN/CAP_SYS_CHROOT and docker stack deploy drops per-service security_opt.
  • Flat Docker network — all services on yapit-network. Workers only need Redis but can reach Postgres, Stack Auth. Segmentation would limit blast radius of a compromised worker.

Billing

  • Billing checks must run for every server-side synthesis job, unconditionally.
  • Free tier: server_kokoro_characters=0 blocks server synthesis. Browser TTS (Kokoro.js) is unlimited and runs entirely client-side.

Gotchas

  • nginx add_header inheritance — If a location block has ANY add_header, it stops inheriting ALL server-level add_header directives. Security headers must be repeated in every location block that has its own add_header (e.g., cache-control blocks). Auth proxy locations (/auth/api/, /auth/) must NOT get X-Frame-Options DENY — Stack Auth uses iframes for token refresh.
  • nginx proxy_pass path stripping — proxy_pass http://upstream/; (with trailing slash) replaces the matched location prefix with the URI. When splitting location blocks, the proxy_pass URI must preserve the expected backend path. E.g., location /api/v1/ws/ needs proxy_pass http://gateway/v1/ws/; — using http://gateway/; would strip /api/v1/ws/ and send just /tts to the backend. See [[vps-setup]] nginx section.
  • SQL injection false positives — cache.py and metrics.py use f-strings in SQL but only for structural elements (IN (?, ?, ?) placeholder construction, column name lists from hardcoded arrays). Actual values are always parameterized. Confirmed safe in both the beta audit and the red-team audit.

There aren't any published security advisories