- 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.
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:
- An attacker reads someone else's voicemail (broken authz or IDOR).
- An attacker brute-forces a 4-digit PIN at scale.
- A stored credential (Telegram token, contacts app password) leaks.
- XSS / injection poisons the HTML shown to a user.
- 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)
- Google Cloud console → OAuth consent screen (External) → add your account as a test user.
- Credentials → OAuth client ID → Desktop app.
- Scope
https://www.googleapis.com/auth/contacts.readonly. - Authorize once (the token is written to
google-token.json, mode640). 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.