# Security Threat model and the hardening already in place. Read before exposing the portal on a hostile network. --- ## What we are protecting Voicemail is sensitive: it contains callers' names, numbers, and the content of their messages — often personal or commercial. The portal also proves who *isn't* a mailbox owner (knowing a PIN is the only gate). Breaches of concern: 1. An attacker reads someone else's voicemail (broken authz or IDOR). 2. An attacker brute-forces a 4-digit PIN at scale. 3. A stored credential (Telegram token, contacts app password) leaks. 4. XSS / injection poisons the HTML shown to a user. 5. The recording audio leaks off-box. --- ## What is already done **Authentication — mailbox PIN from voicemail.conf.** No new password store to breach; the portal trusts Asterisk's PINs. Sessions are DB-backed (random token, `Secure`+`HttpOnly`+`SameSite=lax`, 12 h). Logout and expiry are enforced server-side, not merely by dropping the cookie. **Authorization — every query is mailbox-scoped.** `vm_store` and `vm_web` filter every read and delete by the session's mailbox. Cross-mailbox reads/deletes return **404**. There is a test that asserts this ([TESTING.md](TESTING.md)). A mailbox owner can only ever see their own messages. **Brute-force lockout.** 5 failed attempts per `(mailbox, IP)` → 15-minute lockout. The correct PIN is also refused while locked. State is in-memory (single worker); a restart clears it. **Transport — TLS terminated at Apache, with headers.** `Strict-Transport-Security`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, and a tight CSP: ``` default-src 'self'; style-src 'self' 'unsafe-inline'; media-src 'self'; img-src 'self' data:; script-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' ``` **No JavaScript in the portal at all**, which is what lets `script-src 'none'` be genuinely enforceable — there is nothing to inject. **Network exposure is minimal.** The uvicorn backend binds **loopback only** (`IPAddressAllow=localhost` in the unit). The only ingress is Apache. The systemd unit also sets `NoNewPrivileges`, `ProtectSystem=full`, `ProtectHome`, `PrivateTmp`, `ProtectKernelTunables`, `ProtectControlGroups`, `RestrictSUIDSGID`, and writable paths are limited to `/var/lib/vm-transcribe` and `/var/log/asterisk`. **Secrets are separated and git-ignored.** `telegram.conf`, `contacts.conf`, `*.db`, `audio/` are in `.gitignore`. The repo ships `*.example` templates only. Both secret files are mode `640 root:asterisk`. **Fail-safe preserves mail.** A pipeline exception relays the original Asterisk message unchanged — we never lose a notification to a bug we introduced. --- ## Residual risks and how to close them **(R1) 4-digit PINs.** Lockout helps but a distributed attack from many IPs defeats per-IP throttling. If the portal is on a hostile network, put it behind your VPN (there is already a `t01.vpn.conf` on this host) or fail2ban the Apache access log. Recommended. **(R2) No TLS on the loopback hop.** Apache→uvicorn is plain HTTP on localhost. Acceptable (same host, no network path), but if you ever run uvicorn on another host, use a unix socket or mTLS. **(R3) Session token in SQLite.** If the DB file is stolen, sessions are replayable until they expire. DB is `640 asterisk:asterisk` and not web-served. For higher assurance, store sessions in a server-side cache with shorter TTLs. **(R4) Contact resolver tokens.** A Google OAuth token or a CardDAV app password grants read access to your address book. Scope the Google token to `contacts.readonly`, use a dedicated app password (never your login password), and keep these files `640`. Prefer the `file` backend (a periodic vCard export) — no live token at all. **(R5) Telegram bot token.** Whoever holds it can post as your bot. Keep `telegram.conf` `640 root:asterisk`; rotate via @BotFather if leaked. **(R6) Content-addressed audio filenames.** The sha256 of the audio is in the URL (`/audio/`). An attacker who guesses a sha could fetch that recording without a session. The portal checks the session's mailbox *owns* that message before serving, so this is not directly exploitable — but consider a random per-message token instead of the content hash if you want defence in depth. **(R7) CDN / edge.** If `vm.txt3.net` is orange-clouded at Cloudflare, recording audio streams through the edge. Grey-cloud it (DNS only) to keep voice data off the CDN. Your call. --- ## Obtaining a Google token (if you use the `google` backend) 1. Google Cloud console → OAuth consent screen (External) → add your account as a test user. 2. Credentials → OAuth client ID → **Desktop app**. 3. Scope `https://www.googleapis.com/auth/contacts.readonly`. 4. Authorize once (the token is written to `google-token.json`, mode `640`). The People API is used, not CardDAV, because basic auth / app passwords were disabled by Google on **2024-09-30**. There is no support for an app-password read of Google Contacts — it does not work. Use the `file` or `google` backend.