A defensive security review of VoxTranslate (client + server + infra) was run on 2026-06-12 across XSS/DOM injection, SQL injection, authn/authz (IDOR), the admin and WebSocket surfaces, Stripe, CORS, rate-limiting, security headers, DoS limits, secret handling, file upload, and CI/supply-chain. This file records the findings, what was fixed (spec 0028), what's tracked, and how to keep the app secure.
Found a vulnerability? Email the owner (see the repo profile). Please don't open a public issue for anything exploitable.
- No SQL injection β every query is parameterized (sqlx
bind()/macros); the fewformat!-built queries interpolate only fixed column constants. - JWT signature + expiry are verified with the algorithm pinned to HS256 (no
alg=none/confusion). Google ID tokens verifyaud == client_id. - Stripe webhook verifies the HMAC-SHA256 signature before trusting the event,
with constant-time compare; crediting is idempotent on the Stripe
event_id. - Admin endpoints use a constant-time shared-secret extractor that runs before
body parsing, and every action writes an
admin_auditrow. - IDOR-safe: transcript/report/sentiment/email/bookmark/billing/usage endpoints are participant- or owner-scoped, not merely "logged-in".
- WS
fromcan't be spoofed (server stamps the connection id); relay is room-scoped. - Secrets are never logged and never serialized into responses (
skip_serializing, server-only keys; only the derived TURN credential is emitted). - File upload: private bucket + signed URLs + forced-safe content-types, and the storage key is sanitized (path-traversal tested) β no stored-HTML XSS from our origin.
| Sev | Issue | Fix |
|---|---|---|
| π΄ High | Stored/relayed XSS β a peer's display name went to innerHTML (app.ts), so a name like <img onerror=β¦> ran JS in every participant's tab and could exfiltrate the localStorage JWT. |
Render the name with textContent. |
| π΄ High | CORS permissive() on all routes; the ALLOWED_ORIGINS config was dead code, so any site could call the API. |
Build the CORS layer from allowed_origins (allowlist in prod; permissive only when unset for dev). |
| π Med-High | Expensive endpoints unthrottled β AI report/sentiment/email-draft (Groq), email-send (Resend), and /api/ice (TURN cred minting) had no rate limit (cost/abuse). |
Per-user limits on the AI/email handlers; tight per-user cap on email-send; per-IP limit on /api/ice. |
| π Medium | No security headers (clickjacking, MIME-sniff, no HSTS). | vercel.json: HSTS, X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, a scoped Permissions-Policy (keeps camera/mic/display-capture for calls), and a frame-ancestors/object-src/base-uri CSP. Server: a nosniff response-header layer. |
| π Medium | Unbounded WS chat text β Groq translation cost/DoS. | Cap chat at 8 KB before the moderation/translation fan-out. |
| π Medium | No supply-chain scanning. | .github/dependabot.yml for cargo + npm + github-actions (weekly update PRs). |
| π‘ Low | javascript: scheme allowed in legal-page markdown links. |
Block dangerous schemes (javascript:/data:/vbscript:/file:) in the link renderer (relative + http(s)/mailto stay intact). |
| π‘ Low | TURN abuse via the anonymous /api/ice. |
coturn total-quota/user-quota/max-bps + RFC-1918 peer denylist (bounds the damage of a leaked credential) + the per-IP rate limit above. |
| Sev | Issue | Where | Note |
|---|---|---|---|
| π Med | Glossary endpoints lack room-membership authz β any logged-in user can read/overwrite/delete a room's glossary by code. | api.rs glossary_{get,save,delete,import} |
Needs user_id on the room Peer (threaded from the WS join) to map an HTTP user to room membership; data isn't sensitive + rooms ephemeral, so deferred. |
| π‘ Low | Upload is membership-gated, not JWT-gated (by design β same trust model as guest chat). | files.rs |
β Now throttled per-uploader + a 15 s PDF-extraction timeout (spec 0029); the membership-gating is intentional. |
| π‘ Low | CSP is minimal (frame-ancestors/object-src/base-uri only). |
vercel.json |
β
Now a full enforcing CSP (default-src 'self' + per-directive allowlists for GSI/Supabase/Railway-WS/MediaPipe) after a source audit (spec 0075); staged via Report-Only since #117. Remaining tightening: drop script-src 'unsafe-inline' once Astro emits CSP nonces/hashes. |
| π‘ Low | Stateless JWT: 7-day lifetime, no revocation; a ban is enforced at WS join but not on the REST API. | auth.rs, middleware.rs |
Add a jti denylist or shorten the lifetime if account-takeover risk rises. |
| π‘ Low | Rate limiter trusts the spoofable X-Forwarded-For for the IP key. |
auth.rs |
β Eviction added (spec 0029 β prunes elapsed keys past 10 k); using the trusted-proxy hop is host-specific, deferred. |
| π‘ Low | Signed-URL TTL is 7 days for chat-file links broadcast in plaintext. | config.rs |
Shorten the default (it's env-tunable) β weigh against transcript links dying. |
| π‘ Low | A blocking dep-audit gate (vs the informational one). | .github/workflows/ci.yml |
β
Dependabot (0028) + a non-blocking cargo audit/npm audit job (0029); a blocking gate would fail on the 3 dev-only npm highs (esbuild/vite/astro) until the Astro 6 major upgrade. |
| π‘ Low | Stripe webhook timestamp age not checked (replay neutralized by idempotency, so impact nil). | stripe_handler.rs |
Add a freshness window for defense-in-depth. |
- Set
ALLOWED_ORIGINSon the server to your real domains (e.g.https://voxtranslate.app). Without it, CORS stays open (dev mode). - Rotate secrets if a value ever leaks:
JWT_SECRET,ADMIN_API_SECRET,STRIPE_*,TURN_SECRET,DEEPGRAM_API_KEY,GROQ_API_KEY,RESEND_API_KEY. They live only in the host's env β never commit them. - Watch Dependabot PRs weekly; merge the security ones promptly. For a deep
check run
cargo audit(server) andnpm audit(client) locally. - The golden rule for user content: render any user/peer string with
textContent, neverinnerHTML. The one XSS we found was exactly this slip. - Don't widen TURN/
/api/ice: keep coturn's quotas + peer-IP denylist so a leaked credential can't become an open relay. - Headers: the security headers ship via
client/vercel.json; if you add a feature that loads a new third-party script/connects to a new host, update the CSP/Permissions-Policyrather than removing them. - Admin: treat
ADMIN_API_SECRETlike a root password; it's the only gate on/api/admin/*(ban, credits, GDPR delete).
WebRTC media is peer-to-peer (the server never sees it). TURN relay credentials are minted server-side and expire. This review covered the server, client, and infra in the repo; it is not a substitute for a professional third-party pentest.