README, docs/{ARCHITECTURE,CONFIGURATION,INSTALL,OPERATIONS,SECURITY,TESTING}
and CHANGELOG were stale: they described the original SQLite + CardDAV +
zero-JS vm_web portal. Brought them in line with the actual code:
- MySQL (PyMySQL) as the default store; SQLite fallback only
- vm_contacts resolves from the MySQL contacts table (file/google/carddav removed)
- React SPA frontend + vm_api.py JSON API (:8098); vm_web.py now serves /audio/
- Apache vhost proxies /api/ -> :8098, /audio/ -> :8099, serves the SPA
- CSP relaxed to script-src 'self' 'unsafe-inline' (UI is now JavaScript)
- Added CHANGELOG 1.2.0 entry for the React/vm_api frontend work
5.6 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 (MySQL password, Telegram token, contacts export) leaks.
- XSS / injection poisons the HTML/JSON 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
MySQL-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_api 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
(and their own contacts).
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 per service); 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 CSP:
default-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; font-src 'self';
media-src 'self'; connect-src 'self' http://127.0.0.1:8098;
script-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'
The portal now ships JavaScript (a React SPA), so the CSP is relaxed from
the original script-src 'none' to script-src 'self' 'unsafe-inline'. Inline
scripts in the built bundle are still same-origin; base-uri 'none' and
frame-ancestors 'none' remain to blunt injection / framing. If you can build
the frontend without inline scripts, tighten this back to 'self' only.
Network exposure is minimal.
Both uvicorn backends bind loopback only
(IPAddressAllow=127.0.0.1 / localhost in the units). The only ingress is
Apache. The vm-api unit also sets ProtectSystem=full, ProtectHome=read-only,
NoNewPrivileges, PrivateTmp; vm-portal adds ProtectKernelTunables,
ProtectControlGroups, RestrictSUIDSGID, IPAddressDeny=any, and writable
paths limited to /var/lib/vm-transcribe and /var/log/asterisk.
Secrets are separated and git-ignored.
telegram.conf, contacts.conf, db_secret, api.env, *.db, audio/ are
in .gitignore. The repo ships *.example templates only. db_secret and
telegram.conf are mode 640 root:asterisk; api.env is mode 640
root:root.
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 MySQL. If the DB is stolen, sessions are replayable
until they expire. The asterisk DB is not web-served and the asterisk user is
least-privilege; keep db_secret at 640 root:root. For higher assurance,
shorten VM_SESSION_HOURS or store sessions in a server-side cache with short
TTLs.
(R4) Contact data. Contacts now live in the MySQL contacts table (no live
Google/CardDAV token at all — the old OAuth/app-password backends were removed).
The only secret around contacts is the DB password. Prefer the MySQL backend
(which we are already on) — there is no external address-book token to leak.
(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 used to locate the blob. 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.
(R8) who-called.co.uk lookups. The Lookup number action opens a new browser tab to who-called.co.uk using the caller's sanitised digits. This is a user-initiated click from the UI — no data is sent server-side. It does mean the user's browser (and the caller's number) reach a third-party site; acceptable for a manual reverse-lookup, but worth knowing.
Past Google-contacts note (no longer applicable)
The old file / google / carddav contact backends were removed in favour of
the MySQL contacts table. Historically, Google disabled basic auth for
CardDAV/CalDAV/IMAP on 2024-09-30, so an app-password read of Google
Contacts never worked — that is moot now that contacts are a local MySQL table.