Skill: Security Audit
Use this skill for security reviews of VoxBento code. Covers OWASP Top 10 and VoxBento-specific threat model.
Threat Model
| Actor | Capability | Risk |
|---|---|---|
| Anonymous user | Reaches public routes (/, /register, /login, /healthz, /ws/captions/*) | Low |
| Authenticated user | Joins events they are assigned to | Low |
| Malicious invite token holder | Uses token for different booth | Mitigated by WS scope check |
| Rogue interpreter | Tries to go live without being active | Mitigated by _resolve_whip_url |
| Admin with access | Full event/user management | Inherently trusted |
| Network attacker | MITM on HTTP connections | Use HTTPS/TLS in production |
Security Checklist
A01 — Broken Access Control
- All admin routes use
Depends(require_admin). -
require_adminchecksuser_token(is_admin or event_admin) thenadmin_token. - WHIP URL only returned to active interpreter (
check_publish_permissioninBoothRegistry). - WS
booth:set-active: only coordinator or current active can reassign. - WS token scope:
session_token.event_slug + language_codemust matchbooth_id. - WS role:
session.granted_roleused — neverdata['role']. - Booth page:
granted_role = None→ 403 (not just redirect).
A02 — Cryptographic Failures
-
JWT_SECRET/SECRET_KEYare not default values in production. -
API_KEY_ENCRYPTION_KEYis not"change-this-encryption-key-in-production"(raisesRuntimeErrorif default). - API keys stored Fernet-encrypted in DB; never plaintext.
- Passwords hashed with bcrypt (cost factor determined by bcrypt defaults ~12).
-
admin_password/jwt_secretnot logged. - HTTPS enforced by reverse proxy (Caddyfile provided).
A03 — Injection
- SQLAlchemy ORM with parameterized queries used throughout — no raw string SQL.
- Event slug and language code validated by regex before DB write (
validate_event_slug,validate_language_code). - Invite token is a 64-char hex string — no user-controlled content in PK.
- Form input passed to template is escaped by Jinja2 autoescaping.
A05 — Security Misconfiguration
-
debug: bool = Truein default settings — must beFalsein production. -
BOOTH_ACCESS_TOKENunset = no token guard on API; set it in production if API is public. -
ADMIN_PASSWORDmust be set; empty string disables admin login (seeportal/routers/auth.py). -
database_urldefault is SQLite — use PostgreSQL in production. -
SECRET_KEY: str = 'change-me'— must be overridden.
A07 — Identification and Authentication Failures
- No rate limiting on
/loginor/register(see TD-08 in TECHNICAL_DEBT_REPORT.md). -
user.is_activechecked on login — deactivated users cannot log in. - JWT expiry is enforced (
jwt_expiry_secondsdefault 86400 = 24h). - Cookie flags:
httponly=True,samesite='lax'on all auth cookies. - No
secure=Trueon cookies in code — must be added for HTTPS deployments or handled by reverse proxy.
A10 — Server-Side Request Forgery (SSRF)
-
_check_mediamtx()and_ensure_mediamtx_path()usesettings.mediamtx_api_base— hardcoded from config, not user input. - Jitsi URL construction:
_make_jitsi_url(base_url, room)—base_urlis from settings;roomis from DB (admin-entered, not end-user). - No user-controlled URLs are fetched by the server.
Open Redirect Audit
All redirects in portal/routers/ must use safe_redirect(url):
def safe_redirect(url: str, status_code: int) -> RedirectResponse:
url = url.replace('', '').strip()
parsed = urlparse(url)
if url and not parsed.netloc and not parsed.scheme and url.startswith('/'):
return RedirectResponse(url=url, status_code=status_code)
return RedirectResponse(url='/', status_code=status_code)
Check next_url / next parameter usage:
grep -rn "next_url\|next=" portal/routers/
Ensure all uses pass through safe_redirect.
Cookie Security Flags
| Cookie | httponly | samesite | secure |
|---|---|---|---|
session_token |
✓ | lax | ✗ (set by reverse proxy TLS) |
user_token |
✓ | lax | ✗ |
admin_token |
✓ | lax | ✗ |
Production hardening: ensure TLS termination at Caddy/nginx level; add Strict-Transport-Security header.
Prompt Injection Detection
VoxBento does not directly pass user input to LLM APIs. Transcription providers receive audio (PCM bytes), not text, from the server. There is no LLM chain in the current implementation.
Security Quick Checks
# Check for raw redirects (should be none)
grep -rn "RedirectResponse(url=" portal/routers/ | grep -v safe_redirect
# Check for debug=True in production settings
grep -n "debug" portal/config.py
# Check JWT secret default
grep -n "change-me\|secret_key" portal/config.py
# Check no inline scripts in templates
grep -rn "<script>" templates/