Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was guarded as meant. Fixed, each with a test: - login: a directory 503 counts as a failed sign-in; a locked directory account is a refused password, not "unavailable" - open redirect after OpenID sign-in (a tab in next) - directory email matching only for non-admins without their own password; others connect on My settings (POST /api/me/identities/ldap); only admins disconnect a directory account - OpenID session check reads the refreshed ID token's claims too - connect is POST /api/auth/oidc/connect (CSRF header) - stored bind password, client secret and SMTP password tied to their server - 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400 - @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a password asked again shares the login limiter Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
21 KiB
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 FILTERed 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) |
| 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/<mac>.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 |
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.shcopies them from the bundled Caddy's storage (data/caddy/caddy/certificates/*/<PUBLIC_HOSTNAME>/) or from two given paths (certbot), owner app UID, key 0600, and runsmodule 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-tlsout when the files are missing, soPATCH /api/settingsrefuses to turnsip.tlsEnabledon unless both load and the key matches (node:cryptoX509Certificate.checkPrivateKey), and the dashboard warns (tls_cert) when TLS is on and the cert is missing or expires within 14 days. transport-tlshasverify_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 checks a qualify OPTIONS against the IP instead of the host name, which fails on voip.ms ("server identity does not match") and marks the contact Unavailable, so TLS trunks getqualify_frequency = 0and Test skips the qualify; REGISTER and INVITE verify the name correctly. Re-check on Asterisk upgrades.- Phones over TLS are not provisioned (templates stay UDP); that's for remote phones later.
- Certificate:
2.2 Phone ACL (setting sip.restrictPhonesToLocalNets)
When true, the generator adds to every desk extension endpoint (not -web, not trunks):
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.confwritessecurity.log(01 §3.3).- Admin guide: fail2ban
asteriskjail 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"andip, 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→ FastifytrustProxy: '127.0.0.1'only.- Rate limits on every route (04 §2.2):
RATE_LIMIT_PER_MINUTEper 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 (401totp_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_removewith anany()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
onSendhook on every response (UI, API,/prov);connect-srcalso allowsWEBRTC_WS_URL's origin when it's set. The dark-mode bootstrap moved from an inline<script>inindex.htmlto/theme.js, soscript-srcstays'self'.
2.5 Files and permissions
/etc/asterisk/selfphoneng/*mode 0640, owner app UID..envmode 0600 (install.sh sets it).- Recordings and voicemail are served only through authenticated api routes, never as static files.
2.6 Network diagnostics (owner, 2026-10-21)
System → Diagnostics (services/diagnostics.ts, admin only). Every failed or doubtful line says
how to fix it.
- This server (
GET /api/system/diagnostics): public IP set and equal to what api.ipify.org sees;PUBLIC_HOSTNAME's A records include it (Cloudflare proxy named in the fix);network.lanIpinsidenetwork.localNets; the phone ACL (§2.2) andprovisioning.lanOnlyflagged as limits on phones elsewhere;pjsip show transportshas the enabled transports; the RTP range's even ports bound once each, a warning past a quarter taken; whether a probe is set. - From the internet (
POST /api/system/diagnostics/network {mode}) needs the probe,apps/probe(no dependencies, its own imageselfphoneng-probe,deploy/probe/compose.yml), run outside the network;network.probeUrlpoints at it. A server can't test its own ports: hairpin NAT is missing or skips the port forwards, and browsers refuse port 5060.- Without Docker (owner, 2026-10-21): each release also attaches
selfphoneng-probe-linux-x64and-arm64(Node's single executable applications: esbuild bundles the probe into one CJS file, postject injects it into that Node's official binary, checked against nodejs.org's SHASUMS256.txt), the bundle itself (selfphoneng-probe.cjs, Node 22+) andSHA256SUMS.apps/probe/build-binary.mjs, run inbinary.Dockerfilewith BuildKit's local output (so it works on Gitea's runner, where the job's checkout can't be bind-mounted).deploy/probe/selfphoneng-probe.serviceruns it under systemd withDynamicUserand a read-only system. About 45 MB of memory. POST /v1/check {host, tests}: the probe tests only the address the request came from (the last X-Forwarded-For entry withTRUST_PROXY=true), so it can't be aimed at anyone else; 20 tests at most, 10 checks per address per 10 minutes, 4 s per test.- Quick: HTTP 80, HTTPS 443
/healthz(status and certificate), SIP OPTIONS over UDP (and TCP when on) tosip.udpPort. Complete adds SIP over TLS (when on), a WebSocket upgrade on/ws(protocolsip), and up to four even ports of the RTP range: the api binds them and the probe sends a token to each. - The answer's
ipis checked too: a private one means the probe is inside the network; another public one thannetwork.publicIpmeans a VPN or a changed address.
- Without Docker (owner, 2026-10-21): each release also attaches
- The RTP range comes from
RTP_PORT_START/RTP_PORT_END(api env, default 10000–20000; 01 §3.6).
3. Operations
3.1 Install / update
scripts/install.sh(01 §1.3).- Docker only (owner, 2026-10-21): SelfPhoneNG never supports a bare-metal install.
- Database changes ship in the image: the api runs pending migrations as it starts. Before it does,
it backs up (
backupBeforeMigrate: database, voicemail, custom sounds, MOH; no recordings; prunes nothing) and stops, leaving the database alone, if that fails;SKIP_BACKUP_BEFORE_MIGRATE=trueoverrides. Any other one-off upgrade step goes in the image the same way, so pulling the new image is the whole update for every install. What can't (a PostgreSQL major version, a new stack setting) goes in the changelog under the version that needs it. scripts/update.sh, for compose installs (the folderinstall.shset up) only: checks (clean checkout, 3 GB free, no calls, the changelog, new.env.examplekeys), a backup (node dist/backup-cli.jsin the running api), Asterisk logs gzipped (kept 90 days), the release's images built while the old ones run,up -d, then a check (healthz,/app/VERSION, Asterisk fully booted, trunks registered before still are). On a failed check: the previous commit rebuilt and, if the migration count changed,restore.shwith the api's pre-migration backup. Logs indata/update-logs/. Image-based installs (Portainer) change the tag and redeploy (admin guide, maintenance.md).- Version:
package.jsonversion at the root, exposed at/api/system/status. Semver.CHANGELOG.mdkept by hand. - Releases (owner, 2026-10-20; first one v0.0.1): container images are built only when a release
is published, by
.github/workflows/release.yml, which runs on Gitea and on the GitHub mirror, each pushing to its own registry:ghcr.io/<owner>/selfphoneng-{api,asterisk}(the job'sGITHUB_TOKEN) and<gitea host>/<owner>/selfphoneng-{api,asterisk}(repository secretsREGISTRY_USER+REGISTRY_TOKEN, a personal access token withwrite:package: Gitea's job token can't push packages). Tags: the version, plus (pre-release:dev; otherwise)<major>.<minor>andlatest. The job fails unless the tag isv+ the root package.json version, and checks the images (every api module loads,/app/VERSION,asterisk -V) before pushing. To release: set the version in all four package.json files, add aCHANGELOG.mdentry, commit, push, then create releasevX.Y.Zon Gitea (it mirrors to GitHub; create the release there too for ghcr.io). The asterisk image is built withASTERISK_UID/GID1000.docker-compose.ymlstill builds locally; pulling the published images is a later step. - Asterisk security releases: bump
ASTERISK_VERSIONin the Dockerfile and rebuild. The admin guide explains how.
3.2 Monitoring
GET /healthzfor an external monitor (Uptime Kuma etc.): 200 / 503 + JSON reasons.- Emails to admins: trunk down > 5 min, disk < 10%, public IP changed, backup failed.
- Logs:
docker compose logs api,./data/asterisk-logs/messages.log.
3.3 Troubleshooting page in the admin guide (M11)
Must cover: one-way audio (NAT/publicIp/SIP ALG/RTP ports), trunk won't register (credentials, server,
IP whitelist when IP auth, firewall), inbound calls don't arrive (DID routing + POP), browser softphone won't
register (WSS proxy path /ws, certificate, microphone permission), voicemail emails not sent (SMTP test), and
how to use /admin/system CLI views and pjsip set logger on (via docker compose exec asterisk asterisk -rx).
4. Licensing compliance
- Our code: AGPL-3.0-or-later. Add
LICENSE, SPDX headers, and an About page (/admin/system→ About) listing the license and a link to the source repository (AGPL requires offering source to network users). As built (owner, 2026-10-20):/aboutneeds no sign-in (linked from the login page, My phone and System). The api image carries the exact source it was built from:apps/api/Dockerfiletars the build context (minus.dockerignore: no.env,data/,node_modules, build output or.git) into/app/selfphone.tar.gzbefore installing anything, withVERSIONfrom the root package.json. "Code v" downloads it (GET /api/about/source,selfphone.tar.gz, top folderselfphoneng-<version>/). Outside Docker there is no archive and the page says so. The repository link is general context. The ringtone is synthesized (WebAudio), so there's no sound file to credit. - Asterisk: GPL-2.0, runs as a separate program. We don't link to it, so there's no license conflict.
- Asterisk core/extra sounds and opsound MOH: CC-BY-SA. Credit them on the About page.
- Ringtone: CC0, documented in
apps/web/public/sounds/SOUNDS-LICENSE.md. - No closed-source codecs (AGENTS rule 4).
5. Legal features (not legal advice; the admin guide says to check local law)
- Call recording consent: US federal law and most states are one-party consent, but several states
(e.g. California, Florida, Illinois, Maryland, Massachusetts, Montana, New Hampshire, Pennsylvania, Washington)
require all parties to consent. When any recording is enabled, the Recording settings tab shows this notice and
strongly suggests setting
recordings.announcementSoundId. The setup wizard doesn't turn recording on. - E911 / Kari's Law / RAY BAUM'S Act: see 06 §6.
- Data retention: retention settings for recordings and voicemail (04 §8). Deleting a user offers "delete their voicemail and recordings too".
6. Security review (M11-T8, 2026-10-20)
Two passes: this file's §1 table row by row against the code, and a separate review agent that hadn't written the code (read-only, told to prove each finding end to end). Each finding below was reproduced before the fix where a test could show it, and that test now passes.
| # | Severity | Finding | Fix | Proof |
|---|---|---|---|---|
| 1 | Critical | SIP allows "," in a dialed number. Goto(sp-route-…,${EXTEN},1) with 011…,4 became Goto(sp-route-…,011…,4,1), starting past the class check: any extension, even class internal, could call abroad on the default 011. route |
${FILTER(0-9*#+,…)} in every sp-or-* Goto (03 §6.12) |
trunk-test.ts dials 01144207946095,4 with no class: failed, now hears ss-noservice |
| 2 | High | -web endpoints (desk phone's password, any transport) were not covered by the phone ACL |
ACL on -web: 127.0.0.1 only (§2.2) |
call-test.ts |
| 3 | Medium | "&" in a dialed number added a second Dial target, outside the trunk channel limit | the same FILTER (#1) | golden files |
| 4 | Medium | IVR direct dial included sp-local-numbers: outside callers could page the office (and listen, duplex) or take a parked call |
sp-ivr-direct (03 §6.9) |
ivr-direct.test.ts |
| 5 | Medium | The public source archive was the build context minus a denylist: a backup or key dropped in the repo folder would be served to anyone | .dockerignore is an allowlist |
built the image with a planted selfphoneng-*.tar.gz: not in the archive |
| 6 | Low | Trunk endpoints (no inbound auth) matched by From username: From: trunk-<id> from anywhere rang phones |
identify_by = ip on trunks (03 §5.3) |
trunk-test.ts (a third Asterisk plays the attacker): failed, now refused |
| 7 | Low | CSRF check on raw req.url: /%61pi/… is routed to /api/… without the header |
check the matched route | auth.test.ts |
| 8 | Low | Linphone setup tokens (credentials) in the request log | req serializer masks them | log-url.test.ts |
| 9 | Low | /api/live kept a socket's rights after logout, deactivation or demotion; no Origin check |
foreign Origin → 4403; session re-checked every 30 s → 4401 | live.test.ts |
| 10 | Low | *45<queue> and **<ext> put raw dialed text into app arguments |
FILTER to digits (03 §6.13) | golden files |
| 11 | Low | pg_dump got the DB password on its command line (visible to ps on the host) |
PGPASSWORD in its environment |
pg_dump run with the new URL form |
| 12 | Low | Webhook private-address block missed 192.0.0.0/24, 198.18.0.0/15, NAT64, 6to4 | added | integrations.test.ts |
| 13 | Medium | No limit on simultaneous outside calls (trunk max_channels empty by default): a stolen password could run many international calls at once |
Owner's call (2026-10-20): calls.maxPerExtension (3) and calls.maxTotal (10), GROUP_COUNT in each sp-route-* |
trunk-test.ts (limits 1 and 2: a 2nd call from 101's phone and a 3rd in total refused; with both 0 the same test fails), call-limits.test.ts |
Checked and sound (both passes): no sql.raw or string-built SQL; every admin route has
requireAdmin, and recordings, voicemail, /api/me/* check ownership; generated config can't be
injected (newline, ;, [section], ${…}); file serving is rooted, uploads go through ffmpeg
without a shell; CSV export escapes formulas; no dangerouslySetInnerHTML; sessions and API tokens
stored as sha256; AES-256-GCM with a random IV per value; AMI, Asterisk HTTP and the api listen on
127.0.0.1 only; webhooks check addresses at connect time (no DNS rebinding), follow no redirects.
Fixed after the review (owner, 2026-10-21):
asterisk/conf/modules.confdoesn't loadapp_system,func_shell,app_disa,res_agiorapp_externalivr. Nothing uses them, but they are what a dialplan injection would reach for (SHELL()runs host commands).pnpm test:asteriskpasses without them.- Turning two-step sign-in on asks for the password again, like turning it off (
totp.test.ts). *97/*98: one voicemail login at a time per phone and 10 an hour (sp-sub-vmlimit, AstDBSP/VMLOGIN/<ext>=<UTC hour>:<count>), each with 3 PIN tries (voicemail-test.ts)./prov: an unknown MAC gets the same 401 as a known one, so the answers don't tell which MACs exist; with credentials it counts as a failure (devices.test.ts).- The login limiter forgets keys past every window, swept at most once a minute
(
login-limiter.test.ts).