# 08 — Security, operations, legal ## 1. Threat model (what attacks a PBX on the internet) | Threat | Example | Controls | |---|---|---| | SIP scanners / password guessing | "friendly-scanner", sipvicious hitting 5060 | Generated 24-char SIP passwords; no anonymous endpoint; trunks matched by source address only (`identify_by = ip`, M11-T8); fail2ban/CrowdSec on `security.log`; optional phone ACL (§2.2), browser endpoints included; firewall allow-list | | **Toll fraud** (the expensive one) | Stolen SIP creds used to call premium international numbers | International route disabled by default; per-user outbound class; outside calls at once per extension (3) and in total (10), Settings → Security; trunk `max_channels`; voip.ms-side international block + balance limits (06 §1); forward targets checked against class; dialed numbers `FILTER`ed before the route's Goto (M11-T8: a "," skipped the class check) | | Web credential stuffing | Login brute force | argon2id, rate limits, audit log, TOTP for admins (M11) | | Admin panel bugs | FreePBX 2025–26: an auth bypass + SQL injection, then webshells and stolen trunk passwords (§8) | Admin pages only from listed networks, two-step sign-in required for admins (§8.2, M11-T9) | | Voicemail PIN guessing | Calling *98 and trying PINs | 6-digit default PIN, `maxlogins=3`, reject trivial PINs (`000000`, `123456`, the extension number repeated). As built (M11-T1): also for PINs changed from the phone's voicemail menu: `externpasscheck = /usr/local/bin/sp-vm-pincheck` (asterisk image, same rules, a unit test keeps it in step with `isTrivialPin`), `minpassword = 4`; the api stores accepted ones from AMI `VoicemailPasswordChange` (`services/vmpin.ts`), otherwise the next config write would put the old PIN back | | Provisioning leaks | Fetching `/prov/.cfg` to steal SIP passwords | Per-device Basic auth, 404 on unknown MAC, rate limit + temporary IP block, optional LAN-only | | Dialplan injection | Name `x); System(...)`; dialed `011…,4` or `…&PJSIP/…` | Sanitizers (03 §2), no `System()` anywhere; at call time, `FILTER` on every `${EXTEN}` from a phone that reaches an app's arguments (M11-T8) | | SSRF via webhooks | Webhook to `http://127.0.0.1:5038` | Private IP block by default (04 §9), NAT64/6to4 included | | Stolen backup | Backup file copied | Backups exclude generated plaintext configs; DB secrets are encrypted with `APP_SECRET` (kept in `.env`, not in the backup); mode 0600 | | AMI exposure | AMI reachable from network | Bound to 127.0.0.1, `permit` 127.0.0.1 only, random secret; only the classes the api uses, and no module in the image that runs host commands (§8.1) | ## 2. Required controls ### 2.1 Network - Expose only 443/tcp, 5060/udp+tcp (5061/tcp if TLS), 10000–20000/udp. - Admin guide includes an nftables example that limits 5060/5061 to voip.ms server IPs (from the voip.ms wiki list, **verify**) plus `sip.extraAllowedNets`, for setups with no remote phones. - As built (M11-T2), SIP over TLS: - Certificate: `data/tls/fullchain.pem` + `privkey.pem`, mounted read-only into asterisk and the api (`TLS_DIR`). `scripts/tls-cert.sh` copies them from the bundled Caddy's storage (`data/caddy/caddy/certificates/*//`) or from two given paths (certbot), owner app UID, key 0600, and runs `module reload res_pjsip.so`. Asterisk 24 re-reads the cert and key on that reload (same file names, and only when a file's mtime changed; verified, calls stay up), so no restart. It does nothing when the files are unchanged: run it daily from cron. - Asterisk quietly leaves `transport-tls` out when the files are missing, so `PATCH /api/settings` refuses to turn `sip.tlsEnabled` on unless both load and the key matches (node:crypto `X509Certificate.checkPrivateKey`), and the dashboard warns (`tls_cert`) when TLS is on and the cert is missing or expires within 14 days. - `transport-tls` has `verify_server = yes` + `ca_list_file = /etc/ssl/certs/ca-certificates.crt`: the provider's certificate is checked (voip.ms: Let's Encrypt, every POP in its SAN list, checked 2026-10-19). Asterisk 24.0.0-rc2 checked a **qualify** OPTIONS against the IP instead of the host name, which failed on voip.ms ("server identity does not match") and marked the contact Unavailable, so TLS trunks had `qualify_frequency = 0` and Test skipped the qualify. 24.0.0-rc3 fixed it ("Preserve target hostname for qualified OPTIONS destination"): TLS trunks are qualified again, and the upgrade check (`tlsQualifyByHostname`, 01 §2) catches it if it comes back. - Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later. ### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`) When true, the generator adds to every **desk** extension endpoint (not `-web`, not trunks): ```ini deny = 0.0.0.0/0.0.0.0 permit = 192.168.0.0/16 ; each network.localNets + sip.extraAllowedNets (deduplicated) ``` Golden case `phone-acl`; `scripts/e2e/m11-t1.mjs` shows Asterisk refusing a REGISTER ("Not match Endpoint ACL") and accepting it once the network is listed. Changed in M11-T8: with the setting on, `-web` endpoints get `deny = 0.0.0.0/0.0.0.0` + `permit = 127.0.0.1/255.255.255.255`. They share the desk phone's password and pjsip accepts an endpoint on any transport, so `101-web` over UDP from the internet was a way around the ACL. Browsers reach Asterisk only through the reverse proxy (its HTTP server listens on 127.0.0.1). `call-test.ts` checks both: `102-web` over UDP from the phones' network is refused, over WebSocket from 127.0.0.1 it registers. A dev setup whose proxy isn't on Asterisk's host (Docker without host networking) needs the setting off for the browser phone. Changed from the first draft (M11-T1): `contact_acl = sp-phone-acl` names a section of **acl.conf**, not pjsip.conf, and a `type = acl` section in pjsip.conf is res_pjsip_acl's *global* ACL, which would filter trunks too. Contact-header rules alone are also weak: a phone behind NAT at home advertises a private address. Source-address `deny`/`permit` on the endpoint is what keeps remote phones out. Browser phones arrive through the reverse proxy (their source is the proxy), so `-web` endpoints get the proxy's address only (below). The setting is on Settings → Security, with the provisioning switches (07 §2). ### 2.3 fail2ban / CrowdSec - `logger.conf` writes `security.log` (01 §3.3). - Admin guide: fail2ban `asterisk` jail pointed at `./data/asterisk-logs/security.log`, or the CrowdSec Asterisk collection with the same log path **(verify collection name)**. - The api's own auth failures go to stdout JSON with `event: "auth.failed"` and `ip`, so they can be parsed too. ### 2.4 Web - Security headers on every api response: `Content-Security-Policy: default-src 'self'; connect-src 'self' wss://{host}; media-src 'self' blob:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy: microphone=(self), camera=()`. - HSTS is set by the reverse proxy (Caddy does it by default; nginx example adds `add_header Strict-Transport-Security "max-age=31536000" always;`). - `TRUST_PROXY=true` → Fastify `trustProxy: '127.0.0.1'` only. - Rate limits on every route (04 §2.2): `RATE_LIMIT_PER_MINUTE` per client IP, stricter on Test buttons; login and a password asked again share the login limiter. - Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed). - As built (M11-T1), TOTP (`auth/totp.ts`, node:crypto only, RFC 6238 test vectors in its test): opt-in for admins on **My account** (header → Account): scan the QR or type the key, confirm one code, get 10 recovery codes once (`k7m3q-x9tpw`; case and dashes don't matter). Login asks for the code only after the password is right (401 `totp_required`, no session); a wrong code is a failed login (rate limit, audit). Each 30 s step signs in once (`users.totp_last_step`, updated atomically) and each recovery code once (`array_remove` with an `any()` guard). Turning it on or off, or making new codes, needs the password. Another admin can turn it off for a lost phone (`POST /api/users/:id/reset-two-step`, signs that user out). `scripts/e2e/m11-t1-totp.mjs`. - As built (M11-T1): the headers come from an `onSend` hook on every response (UI, API, `/prov`); `connect-src` also allows `WEBRTC_WS_URL`'s origin when it's set. The dark-mode bootstrap moved from an inline `