Files
asterisk-voicemail/docs/SECURITY.md
jp 857284abbf Initial build: Asterisk voicemail transcription + portal
- mailcmd replacement (vm_mailcmd.py): faster-whisper transcription (CPU int8),
  extractive summary + intent tags + spoken-digit number extraction,
  multipart/alternative HTML email, fail-safe relay of original message
- Telegram DM delivery (vm_telegram.py) with per-mailbox routing
- Caller-ID -> name (vm_contacts.py): file / google / carddav backends
- SQLite store (vm_store.py) with content-addressed audio
- FastAPI portal (vm_web.py): PIN login, list/play/delete, per-user settings,
  zero JS, loopback-only behind Apache TLS
- Backfill importer (vm_import.py) for existing spool recordings
- systemd unit, Apache vhost + certbot TLS, install.sh
- Docs: INSTALL, CONFIGURATION, ARCHITECTURE, OPERATIONS, SECURITY, TESTING

Verified end-to-end on mail.txt3.net: 157 historical messages backfilled,
live voicemail -> transcribed -> stored -> visible at https://vm.txt3.net.
2026-08-13 09:31:41 +01:00

5.0 KiB

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). 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/<sha>). 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.