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).
- 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). Seegateway/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.
- 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 in174c47d. - 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.
- No
dangerouslySetInnerHTMLanywhere. Frontend uses a typed AST renderer — backend parses markdown into AST, frontend renders via React components. Unknown node types returnnull. Rawhtml_inlinenodes dropped intransformer.py. - Link href sanitization in
inlineContent.tsx: rejectsjavascript:,vbscript:,data:URI schemes (defense-in-depth behind markdown-it's own filtering). All rendered links haverel="noopener noreferrer"and images havereferrerPolicy="no-referrer". - CSP not deployed (deferred since beta). Blocker: inline
<style>inAccountSettingsPage.tsxneeds refactoring to CSS file first. - Security headers in
frontend/nginx.conf: HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy.
- 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-editfirewall-1in Hetzner Console. Seescripts/sync-cf-firewall.sh. - Together these make
CF-Connecting-IPtrustworthy. 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 viaSlowAPIMiddleware; 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. Seeyapit/gateway/rate_limit.pyfor the endpoint table. - Client IP resolution:
CF-Connecting-IP→ nginxmaprewritesX-Forwarded-For(falls back to$remote_addrwithout Cloudflare) → uvicorn--proxy-headerssetsrequest.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 —
SlowAPIMiddlewareskips 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 ownRequestfrom 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: trueset globally in/etc/docker/daemon.json(Swarm ignores per-servicesecurity_opt). Postgres/metrics-db get selectivecap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID, KILL]for their entrypoint privilege-drop dance. Each Dockerfile specifies the user; compose files addcap_drop: [ALL]and selectivecap_addwhere needed. - Chromium sandbox disabled (accepted risk, see [[security-audits]]) — Playwright launches Chromium with
--no-sandboxunlesschromiumSandbox: trueis passed, anddocker/defuddle/app.jsdoesn'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 onbrowser-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) andchroot, since Docker's default allows those only withCAP_SYS_ADMIN/CAP_SYS_CHROOTanddocker stack deploydrops per-servicesecurity_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 checks must run for every server-side synthesis job, unconditionally.
- Free tier:
server_kokoro_characters=0blocks server synthesis. Browser TTS (Kokoro.js) is unlimited and runs entirely client-side.
- nginx
add_headerinheritance — If alocationblock has ANYadd_header, it stops inheriting ALL server-leveladd_headerdirectives. Security headers must be repeated in every location block that has its ownadd_header(e.g., cache-control blocks). Auth proxy locations (/auth/api/,/auth/) must NOT getX-Frame-Options DENY— Stack Auth uses iframes for token refresh. - nginx
proxy_passpath 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/needsproxy_pass http://gateway/v1/ws/;— usinghttp://gateway/;would strip/api/v1/ws/and send just/ttsto the backend. See [[vps-setup]] nginx section. - SQL injection false positives —
cache.pyandmetrics.pyuse 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.