From 65e777ab41b73de400ef1f9183924893ae9224e4 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Mon, 19 Oct 2026 14:47:04 -0500 Subject: [PATCH] security: headers, phone ACL, PIN rules for PINs set from the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headers (08 §2.4) on every response: CSP (connect-src also allows WEBRTC_WS_URL's origin when set), nosniff, Referrer-Policy, Permissions-Policy. The dark-mode bootstrap moved from an inline +
diff --git a/apps/web/public/theme.js b/apps/web/public/theme.js new file mode 100644 index 0000000..9986ca5 --- /dev/null +++ b/apps/web/public/theme.js @@ -0,0 +1,10 @@ +// Apply the theme before first paint so dark mode doesn't flash. A file, not inline: the +// Content-Security-Policy allows only scripts from this origin (08 §2.4). +/* global matchMedia, document */ +try { + const t = localStorage.getItem('theme'); + if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme: dark)').matches)) + document.documentElement.classList.add('dark'); +} catch { + // no storage (private window): the light default +} diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index cf3d222..90f0a2b 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// Settings (05 §2). M3: Email and Emergency; M7: Recording. The other tabs arrive with their features. +// Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Security (phone ACL, provisioning). import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { NavLink, useParams } from 'react-router'; @@ -12,6 +12,7 @@ const TABS = [ { id: 'email', label: 'Email' }, { id: 'emergency', label: 'Emergency' }, { id: 'recording', label: 'Recording' }, + { id: 'security', label: 'Security' }, ] as const; export function SettingsPage() { @@ -40,6 +41,7 @@ export function SettingsPage() { {settings.data && tab === 'email' && } {settings.data && tab === 'emergency' && } {settings.data && tab === 'recording' && } + {settings.data && tab === 'security' && } ); } @@ -373,3 +375,62 @@ function RecordingTab({ s }: { s: SettingsDto }) { ); } + +/** Phone ACL (08 §2.2) and provisioning access (07 §2). */ +function SecurityTab({ s }: { s: SettingsDto }) { + const save = useSave(); + const [restrict, setRestrict] = useState(s['sip.restrictPhonesToLocalNets']); + const [extra, setExtra] = useState(s['sip.extraAllowedNets'].join(', ')); + const [lanOnly, setLanOnly] = useState(s['provisioning.lanOnly']); + const [provOn, setProvOn] = useState(s['provisioning.enabled']); + const box = (checked: boolean, set: (v: boolean) => void, label: string) => ( + + ); + return ( +
{ + e.preventDefault(); + save.mutate({ + 'sip.restrictPhonesToLocalNets': restrict, + 'sip.extraAllowedNets': extra.split(/[,\s]+/).filter(Boolean), + 'provisioning.lanOnly': lanOnly, + 'provisioning.enabled': provOn, + }); + }} + > +

Desk phones

+ {box(restrict, setRestrict, 'Only accept desk phones from the networks below')} +

+ Local networks: {s['network.localNets'].join(', ')}. Phones anywhere else (at home, on a + mobile network) can't sign in while this is on. Browser phones aren't affected. +

+ + {(p) => ( + setExtra(e.target.value)} + /> + )} + +

Phone setup files

+ {box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')} + {box(lanOnly, setLanOnly, 'Only to phones on the local networks')} +
+ + {save.isSuccess && Saved.} +
+ {save.error && {save.error.message}} +
+ ); +} diff --git a/asterisk/Dockerfile b/asterisk/Dockerfile index e7dda48..e54a7cc 100644 --- a/asterisk/Dockerfile +++ b/asterisk/Dockerfile @@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=build /out/ / COPY conf/ /etc/asterisk/ COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/run/asterisk \ +COPY sp-vm-pincheck /usr/local/bin/sp-vm-pincheck +RUN chmod +x /entrypoint.sh /usr/local/bin/sp-vm-pincheck && mkdir -p /etc/asterisk/selfphoneng /var/lib/asterisk/db /var/lib/asterisk/moh /var/run/asterisk \ && chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/spool/asterisk /var/log/asterisk /var/run/asterisk ENTRYPOINT ["/entrypoint.sh"] diff --git a/asterisk/sp-vm-pincheck b/asterisk/sp-vm-pincheck new file mode 100644 index 0000000..ecd9e05 --- /dev/null +++ b/asterisk/sp-vm-pincheck @@ -0,0 +1,20 @@ +#!/bin/sh +# Voicemail PIN check for PINs changed from the phone (app_voicemail externpasscheck). +# Args: mailbox context oldpin newpin. Prints VALID or INVALID. Same rules as the api +# (crypto/secrets.ts isTrivialPin): 4-10 digits, not one digit repeated, not a run like +# 1234 or 4321, not the extension repeated. +box=$1 +pin=$4 +case $pin in + '' | *[!0-9]*) echo INVALID; exit 0 ;; +esac +len=${#pin} +if [ "$len" -lt 4 ] || [ "$len" -gt 10 ]; then echo INVALID; exit 0; fi +first=$(printf %.1s "$pin") +if [ -z "$(printf %s "$pin" | tr -d "$first")" ]; then echo INVALID; exit 0; fi +case 01234567890 in *"$pin"*) echo INVALID; exit 0 ;; esac +case 09876543210 in *"$pin"*) echo INVALID; exit 0 ;; esac +rep=$box +while [ ${#rep} -lt "$len" ]; do rep=$rep$box; done +if [ -n "$box" ] && [ "$(printf %s "$rep" | cut -c1-"$len")" = "$pin" ]; then echo INVALID; exit 0; fi +echo VALID diff --git a/docs/spec/08-security-ops.md b/docs/spec/08-security-ops.md index b67c8ca..1251f65 100644 --- a/docs/spec/08-security-ops.md +++ b/docs/spec/08-security-ops.md @@ -7,7 +7,7 @@ | SIP scanners / password guessing | "friendly-scanner", sipvicious hitting 5060 | Generated 24-char SIP passwords; no anonymous endpoint; fail2ban/CrowdSec on `security.log`; optional phone ACL (§2.2); 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; `max_channels`; voip.ms-side international block + balance limits (06 §1); forward targets checked against class | | 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) | +| 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(...)` | Sanitizers (03 §2), no `System()` anywhere | | SSRF via webhooks | Webhook to `http://127.0.0.1:5038` | Private IP block by default (04 §9) | @@ -22,14 +22,20 @@ server IPs (from the voip.ms wiki list, **verify**) plus `sip.extraAllowedNets`, for setups with no remote phones. ### 2.2 Phone ACL (setting `sip.restrictPhonesToLocalNets`) -When true, the generator adds: +When true, the generator adds to every **desk** extension endpoint (not `-web`, not trunks): ```ini -[sp-phone-acl] -type = acl -contact_deny = 0.0.0.0/0.0.0.0 -contact_permit = 192.168.0.0/16 ; each network.localNets + sip.extraAllowedNets +deny = 0.0.0.0/0.0.0.0 +permit = 192.168.0.0/16 ; each network.localNets + sip.extraAllowedNets (deduplicated) ``` -and `contact_acl = sp-phone-acl` on every extension endpoint (not trunks). Golden test required. +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 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 +aren't filtered. The setting is on Settings → Security, with the provisioning switches (07 §2). ### 2.3 fail2ban / CrowdSec - `logger.conf` writes `security.log` (01 §3.3). @@ -45,6 +51,9 @@ and `contact_acl = sp-phone-acl` on every extension endpoint (not trunks). Golde - 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. - Admin TOTP (M11): RFC 6238, 30 s, 6 digits, ±1 window, 10 single-use recovery codes (stored hashed). +- 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 `