- 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.
118 lines
5.0 KiB
Markdown
118 lines
5.0 KiB
Markdown
# 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/<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.
|