Update all documentation to current MySQL + React SPA + vm_api.py architecture
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
This commit is contained in:
@ -13,8 +13,8 @@ their messages — often personal or commercial. The portal also proves who
|
||||
|
||||
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.
|
||||
3. A stored credential (MySQL password, Telegram token, contacts export) leaks.
|
||||
4. XSS / injection poisons the HTML/JSON shown to a user.
|
||||
5. The recording audio leaks off-box.
|
||||
|
||||
---
|
||||
@ -23,43 +23,50 @@ their messages — often personal or commercial. The portal also proves who
|
||||
|
||||
**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.
|
||||
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_web` filter every read and delete by the session's mailbox.
|
||||
`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](TESTING.md)). A mailbox owner can only ever see their own messages.
|
||||
([TESTING.md](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); a restart clears
|
||||
it.
|
||||
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 tight CSP:
|
||||
`Strict-Transport-Security`, `X-Frame-Options: DENY`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy`, and a CSP:
|
||||
|
||||
```
|
||||
```text
|
||||
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'
|
||||
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'
|
||||
```
|
||||
|
||||
**No JavaScript in the portal at all**, which is what lets `script-src 'none'`
|
||||
be genuinely enforceable — there is nothing to inject.
|
||||
**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.**
|
||||
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`.
|
||||
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`, `audio/` are in `.gitignore`. The
|
||||
repo ships `*.example` templates only. Both secret files are mode `640 root:asterisk`.
|
||||
`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
|
||||
@ -78,22 +85,22 @@ access log. Recommended.
|
||||
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.
|
||||
**(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 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.
|
||||
**(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 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
|
||||
**(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.
|
||||
|
||||
@ -101,17 +108,17 @@ per-message token instead of the content hash if you want defence in depth.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Obtaining a Google token (if you use the `google` backend)
|
||||
## Past Google-contacts note (no longer applicable)
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user