Files
selfphoneng/docs/spec/08-security-ops.md
T

35 KiB
Raw Blame History

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)
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/<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; 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/*/<PUBLIC_HOSTNAME>/) 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):

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 <script> in index.html to /theme.js, so script-src stays 'self'.

2.5 Files and permissions

  • /etc/asterisk/selfphoneng/* mode 0640, owner app UID.
  • .env mode 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.lanIp inside network.localNets; the phone ACL (§2.2) and provisioning.lanOnly flagged as limits on phones elsewhere; pjsip show transports has 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 image selfphoneng-probe, deploy/probe/compose.yml), run outside the network; network.probeUrl points 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-x64 and -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+) and SHA256SUMS. apps/probe/build-binary.mjs, run in binary.Dockerfile with 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.service runs it under systemd with DynamicUser and a read-only system. About 45 MB of memory.
    • With PROBE_KEY set the probe only answers Authorization: Bearer <key> (401 otherwise, compared in constant time); without it, it answers anyone and logs a warning at start. The key is made in Settings → Network and sent by the api only, never the browser (08 §7 #9).
    • POST /v1/check {host, tests}: the probe tests only the address the request came from (the last X-Forwarded-For entry with TRUST_PROXY=true, believed only from a proxy on 127.0.0.1 or ::1, where the probe then listens by default; 08 §7 #4), 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) to sip.udpPort. Complete adds SIP over TLS (when on), a WebSocket upgrade on /ws (protocol sip), 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 ip is checked too: a private one means the probe is inside the network; another public one than network.publicIp means a VPN or a changed address.
  • 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=true overrides. 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 folder install.sh set up) only: checks (clean checkout, 3 GB free, no calls, the changelog, new .env.example keys), a backup (node dist/backup-cli.js in 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.sh with the api's pre-migration backup. Logs in data/update-logs/. Image-based installs (Portainer) change the tag and redeploy (admin guide, maintenance.md).
  • Version: package.json version at the root, exposed at /api/system/status. Semver. CHANGELOG.md kept 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's GITHUB_TOKEN) and <gitea host>/<owner>/selfphoneng-{api,asterisk} (repository secrets REGISTRY_USER + REGISTRY_TOKEN, a personal access token with write:package: Gitea's job token can't push packages). Tags: the version, plus (pre-release: dev; otherwise) <major>.<minor> and latest. The job fails unless the tag is v + 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 a CHANGELOG.md entry, commit, push, then create release vX.Y.Z on Gitea (it mirrors to GitHub; create the release there too for ghcr.io). The asterisk image is built with ASTERISK_UID/GID 1000. docker-compose.yml still builds locally; pulling the published images is a later step.
  • Asterisk security releases: bump ASTERISK_VERSION in the Dockerfile and rebuild. The admin guide explains how.

3.2 Monitoring

  • GET /healthz for 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): /about needs 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/Dockerfile tars the build context (minus .dockerignore: no .env, data/, node_modules, build output or .git) into /app/selfphone.tar.gz before installing anything, with VERSION from the root package.json. "Code v" downloads it (GET /api/about/source, selfphone.tar.gz, top folder selfphoneng-<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).
  • 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.conf doesn't load app_system, func_shell, app_disa, res_agi or app_externalivr. Nothing uses them, but they are what a dialplan injection would reach for (SHELL() runs host commands). pnpm test:asterisk passes 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, AstDB SP/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).

7. Security review 2 (2026-10-28)

Everything since §6 (8d5475c..): contacts and Contrack, single sign-on, call reports, local AI, spam check, the mobile app's pairing, my reports and spam reports. Two passes again: one against the code by the author, and an independent review agent (read-only, told to prove each finding end to end). Each fix came with a test that failed before it and passes now. Nothing Critical or High.

# Severity Finding Fix Proof
1 Medium A directory sign-in matched by email a user who signs in with OpenID only. Where people can edit their own mail, Mallory set hers to the victim's and her own directory password opened the victim's account the email match also needs no connected account of any kind (12 §4.3 step 2) directory-signin.test.ts
2 Medium An OpenID sign-in matched by verified email an admin, or a user who signs in through the directory (no password, no OpenID account yet): a provider account with that email got their session, admin included not an admin, and no connected account of any kind (12 §3.3 step 2) oidc.test.ts, both key types
3 Medium, privacy Everyone whose phone rang counted as on the call: a ring group or queue member who didn't answer could play a coworker's recording, read its transcript, and got its summary in their call report email and on their Contrack timeline. The audio part predates §6 ON_THE_CALL (services/cdr.ts): only the caller and answered roles, in mayHear, /api/me/calls (no recordingId), the call report and the timeline recordings.test.ts, call-reports.test.ts, contrack.test.ts
4 Low The probe with TRUST_PROXY=true believed any X-Forwarded-For and listened on every address: reached directly, anyone could have it test any address and ports X-Forwarded-For only from 127.0.0.1 or ::1; HOST defaults to 127.0.0.1 with TRUST_PROXY=true (§2.6) probe.test.ts
5 Low A user could swap their directory account for another one they know the password of (a shared mailbox): the leaver check then followed the wrong account POST /api/me/identities/ldap → 409 connected_other when a different one is connected; an admin disconnects first (12 §4.3) directory-signin.test.ts
6 Low, privacy The voicemail.new webhook carried the transcript, though webhooks leave the server and 10 §1 says transcripts don't the webhook has no transcript (04 §9) voicemail.test.ts
7 Low Contrack's saved company token went to any address: Test with another URL, or saving a new URL, sent it there (SMTP, LDAP and OpenID already refused) the saved token only for the saved address; a new address needs it typed again and clears everyone's own token (11 §4.3) contrack.test.ts
8 Low The OpenID connect callback wasn't tied to the browser that asked: a planted sp_oidc (a sibling subdomain can set it) connected the victim's provider account to the attacker's user the callback's own session must be the one in sp_oidc, else expired (12 §3.3) oidc.test.ts, both key types
9 Low Anyone who found a probe could use it (owner's idea after #4): to test their own address, and spend its per-address limits an optional key: Settings → Network → Generate key, set as the probe's PROBE_KEY; the api sends it as a Bearer token, encrypted at rest, dropped when the probe address changes. Without a key the probe stays open (owner, 2026-10-28) and Diagnostics warns, strongly recommending one (§2.6) probe.test.ts, diagnostics.test.ts

The independent agent found 1, 2, 4, 5 and 8, the author 3; both found 6 and 7; 9 was the owner's idea. The agent had checked the recording rule and called it sound: it's the same rule as the audio, which §6 also passed.

Checked and sound (both passes): every new route's guard and ownership check (contacts and sharing, spam reports, my reports, caller check, mobile devices); a paired phone is never an admin and can't make pairing codes, and codes are single-use; OpenID PKCE, state, nonce, safeNext and encrypted provider tokens; LDAP filter escaping and no empty binds; the caller-name lookup only for Asterisk on this machine with its token; phonebook XML and timeline HTML escaped, report emails plain text; AI and Contrack addresses literal local IPs with redirects refused; the spam provider key kept out of errors; every new raw SQL parameterized; no secret in any GET.

8. After the FreePBX campaign (owner, 2026-10-29)

INJ3CTOR3 got into internet-facing FreePBX through its admin panel: CVE-2025-57819 (Endpoint module, an auth bypass + SQL injection, no sign-in needed) and CVE-2025-64328 (filestore, command injection after sign-in). It left PHP webshells, cron jobs and backdoor accounts, then copied out the trunk, AMI, database and mail passwords. With the trunk password it registers to the provider from its own machine, so the calls skip the PBX entirely. Our stack has no PHP and no shell in reach of the web (§6, §7). Two changes follow.

8.1 AMI: only the classes we use (as built)

Class Actions the api sends Events the api reads
system DBPut, DBDel, DBDelTree, PJSIPShowContacts, PJSIPShowEndpoint, PJSIPShowRegistrationsOutbound, PJSIPQualify, CoreShowChannels ContactStatus, Registry
call Redirect, Hangup, ParkedCalls, ExtensionStateList Newchannel, Newstate, NewCallerid, NewConnectedLine, BridgeEnter/Leave, Hold/Unhold, Hangup, ParkedCall, ParkedCallTimeOut, ParkedCallGiveUp, UnParkedCall, ExtensionStatus, MessageWaiting
command Command (reloads, core show uptime, core restart, pjsip show transports)
originate Originate
agent QueuePause QueueCaller*, QueueMember*, AgentConnect
user VoicemailMove, VoicemailRemove UserEvent, VoicemailPasswordChange
cdr Cdr
dialplan VarSet, Newexten

Ping, Login, Logoff and QueueStatus need no class. Action classes come from manager show command on 24.0.0-rc3; event classes from Asterisk's AMI event docs.

Measured on 24.0.0-rc3: an action is allowed when the user has any one of its classes. So system, which DB* and PJSIP* need, still allows GetConfig (any config file, trunk passwords included) and ModuleLoad, and ModuleLoad loaded func_shell.so past noload in modules.conf (Command: module load does the same). Class limits are no barrier (Asterisk's docs say not to rely on them). What dropping the rest takes away is config: UpdateConfig and CreateConfig, a way to write a trunk into a file the admin panel never shows. The barrier is the image itself: asterisk/Dockerfile deletes app_system, func_shell, app_disa, res_agi and app_externalivr, so nothing is left for ModuleLoad to load. The AMI secret is still the whole key (Originate is enough for toll fraud), so AMI stays on 127.0.0.1.

The Asterisk tests mount the real template with only bindaddr and permit opened (test/asterisk/manager-conf.ts), so a class the api needs and the template lacks fails them.

8.2 Admin access (to build: M11-T9)

Both FreePBX bugs were reached through an admin panel open to the internet, and Sangoma's first advice was to block it. SelfPhoneNG serves the admin pages, the user portal and the browser phone's WebSocket on one host and port, so a firewall can't keep one out and let the others in. Two settings on Settings → Security, both off by default (today's behaviour):

auth.adminAllowedNets: CIDR list, IPv4 and IPv6 (z.union([z.cidrv4(), z.cidrv6()])). Empty means admin from anywhere.

  • requireAuth (auth/guards.ts), once req.user is set: an admin whose req.ip is outside the list, and isn't 127.0.0.1 or ::1, gets req.user = { ...user, role: 'user' } and req.adminElsewhere = true, the same way a paired phone is never an admin (13 §2). Every role check after that sees a user: requireAdmin, recordings (role === 'admin'), the OpenID next default. Sessions and API tokens alike (a token acts as its admin, 04 §2.3).
  • Matching: node:net BlockList, as /prov does (isLocal in routes/provisioning.ts, which becomes the shared inNets(ip, nets)), with ::ffff:-mapped IPv4 unwrapped. The list is read with getSetting only when the user is an admin (one row by key).
  • /api/live (live/hub.ts) reads the session itself: admin at connect uses the same rule with the socket's req.ip. The 30 s re-check compares the same way and closes with 4401 when the list changes under it.
  • Two-step routes (/api/auth/totp/*) check the stored role, not the downgraded one: an admin away from the office can still look after their own sign-in.
  • Signing in works from anywhere. An admin outside the list gets their own portal (My phone, voicemail, calls). GET /api/auth/me adds adminElsewhere: true, and the web app shows one line where the admin menu would be: "Admin pages open only from an allowed network (Settings → Security)."
  • 127.0.0.1 and ::1 always count as allowed. That's the way back in when the list is wrong: ssh -L 8080:127.0.0.1:8080 <server>, then http://localhost:8080 (browsers keep Secure cookies on localhost). The admin guide shows it.
  • PATCH /api/settings refuses, like sip.tlsEnabled (§2.1):
    • 409 would_lock_out when the new list isn't empty and the request's own address isn't in it (loopback counts as in);
    • 409 trust_proxy_off when req.ip is loopback and the request has X-Forwarded-For: behind a proxy without TRUST_PROXY=true every request looks local and the list would do nothing.
  • The form offers "Add the local networks" (network.localNets) and the address you're using now. Its help text: integrations calling admin routes with an API token need their address listed too; a VPN into the office network is the usual answer for working away.
  • Diagnostics (§2.6): a warning when the list is empty ("Admin pages open from anywhere"); otherwise the list.

auth.requireAdminTotp: boolean.

  • When on, an admin without two-step sign-in (totpSecretEnc null) whose session came from a password or the directory gets a session that works like must_change_password (04 §2.2): every route outside /api/auth/* → 403 totp_setup_required, and the web app sends them to My account → Two-step sign-in. The check reads the user row on each request, so the session turns normal once two-step is on. mustChange in auth/sessions.ts grows this case, so /api/live follows it too.
  • Exempt: OpenID sessions (sessions.method = 'oidc', the provider does the second factor, 12) and API tokens (made by an admin who signed in).
  • Turning it on needs the saving admin to have two-step on (409 totp_first).
  • "Turn off for a lost phone" (POST /api/users/:id/reset-two-step) still works: that admin is sent to set it up again at their next sign-in.

Tests (each fails without its rule): an admin outside the list gets 403 on an admin route and 200 on /api/me, from 127.0.0.1 and from inside it is an admin (IPv4, IPv6, ::ffff:); an API token the same; /api/live gives admin: false outside; a recording of someone else refused outside; would_lock_out and trust_proxy_off; totp_setup_required until two-step is on, then 200; an OpenID session exempt; totp_first.

Setup (owner, 2026-10-29): the first-run wizard offers both, switched on, with the list prefilled from the local networks (05 §2.1 step 3a). The settings' own defaults stay off, so an update changes nothing on an existing install.

8.3 Fraud alerts (owner, 2026-10-29; M11-T10)

Until now an admin learned of toll fraud from voip.ms's balance alert or the bill. Fraud with a stolen trunk password leaves no call in our records at all: the calls never touch the PBX. Three checks, each an email to every admin plus a warning system event; all three are on by default and on Settings → Security, "Fraud alerts". In services/fraud.ts, run from jobs.ts.

  • riskyCallWatch (every minute; alerts.riskyCalls): outside calls the PBX placed, new since the last run (calls.created_at), to a number riskyDestination calls risky: 011… (international), area code 900 or exchange 976 (premium), and the NANP area codes outside the US and Canada, which are dialed like a domestic call but billed at international rates and are the usual one-ring-scam targets: 242 246 264 268 284 345 441 473 649 658 664 721 758 767 784 809 829 849 868 869 876 (verify the list). One email per run listing the calls. The cursor starts when the api starts: calls made while it was down have no row anyway (their CDR events are lost).
  • providerCallCheck (every 15 minutes; alerts.providerCalls; voip.ms trunks with an API login, 06 §5.1): voip.ms's call records for the trunk's sub-account (getCDR, account = trunks.username, timezone = 0 so times are UTC) from 2 hours to 10 minutes ago. Records to one of our DIDs are incoming and skipped. Every other record must match an outbound calls row: same last 10 digits of the number, started within 5 minutes. One that doesn't is a call voip.ms billed that this PBX didn't make: the email lists them and says to change the sub-account password, turn on its IP restriction (06 §1), and that a call made while the api was restarting, or by another device on the same sub-account, also shows here. Each voip.ms record is reported once (by uniqueid, time and number, kept in memory for 3 hours).
  • balanceWatch (every 15 minutes, same API logins; alerts.balanceDropPerHour, 0 = off): the balance (getBalance) more than that amount below its highest in the last hour → one email per hour. It catches what the call check can't: another sub-account, or one made with a stolen API password. A deposit only raises the balance, so it never alerts.

An API error is logged (warn), not emailed: Reports already shows why the balance can't be read.