security: review fixes (dialed-number filter, browser endpoint ACL, IVR direct dial, …)

The 08 §1 threat table checked line by line against the code, plus a review by a separate agent
that hadn't written it. Findings were reproduced with a failing test before each fix:

- Critical: "011…,4" turned the route's Goto into Goto(sp-route-…,011…,4,1), starting past the
  class check, so any extension could call abroad. FILTER on the dialed number (also stops "&"
  adding a Dial target). trunk-test.ts proves it.
- High: -web endpoints (desk phone's password, any transport) skipped the phone ACL; now
  127.0.0.1 only when it's on. call-test.ts: UDP refused, WebSocket from 127.0.0.1 registers.
- IVR direct dial no longer reaches paging groups or parked calls (sp-ivr-direct).
- Trunks identify_by = ip: a From of trunk-<id> from anywhere no longer rings phones.
- .dockerignore is an allowlist, so the public source archive can't pick up stray files.
- CSRF check on the matched route (/%61pi/… got around it); Linphone tokens masked in logs;
  /api/live checks Origin and re-checks the session; *45/** filter digits; pg_dump password in
  the environment; more webhook ranges blocked.

The review and what's left for the owner are in 08 §6.

Task: M11-T8
This commit is contained in:
iamdoubz committed 2026-10-20 12:19:47 -05:00
1 parent 0ff0f9292b
commit 5c46bd0e85
46 files changed
+555 -87

No files matched your search

+31 -2
View File
@@ -39,12 +39,15 @@ function snapshot(): Snapshot {
.filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings'];
// Phone ACL on, the Docker networks allowed: the desk phones register, a "-web" login doesn't.
settings['sip.restrictPhonesToLocalNets'] = true;
settings['network.localNets'] = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
const ext = (number: '101' | '102', name: string) => ({
id: `00000000-0000-4000-8000-000000000${number}`,
number,
name,
sipPassword: PASS[number],
webrtcEnabled: false,
webrtcEnabled: number === '102',
maxContacts: 5,
ringSeconds: 25,
voicemailEnabled: false,
@@ -104,7 +107,12 @@ function phoneConfig(dir: string, ext: '101' | '102', port: number, toneHz: numb
);
writeFileSync(
join(d, 'accounts'),
`<sip:${ext}@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=auto;audio_codecs=PCMU;regint=60\n`,
`<sip:${ext}@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=auto;audio_codecs=PCMU;regint=60\n` +
// M11-T8: the desk phone's password also opens 102-web; over UDP from a phone's network
// that must fail (browsers only come through the reverse proxy on 127.0.0.1).
(ext === '102'
? `<sip:102-web@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=manual;regint=60\n`
: ''),
);
}
@@ -207,6 +215,27 @@ async function main() {
if (!/101\/sip:.*Avail/.test(contacts) || !/102\/sip:.*Avail/.test(contacts))
throw new Error(`phones did not register:\n${contacts}`);
log('101 and 102 registered');
await sleep(3000);
if (/102-web\/sip:/.test(await ami.command('pjsip show contacts')))
problems.push('102-web registered over UDP from the phones network with the ACL on');
else log('102-web over UDP: refused (phone ACL covers browser endpoints)');
// ...while a browser through the reverse proxy (WebSocket from 127.0.0.1) still registers.
const wsReg = docker(
'run',
'--rm',
'--network',
`container:${AST}`,
'-v',
`${join(import.meta.dirname, 'ws-register.mjs')}:/ws-register.mjs:ro`,
'node:24-trixie-slim',
'node',
'/ws-register.mjs',
'ws://127.0.0.1:8088/ws',
'102-web',
PASS['102'],
).out.trim();
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
else log('102-web over WebSocket from 127.0.0.1: registered');
// Real list responses with registered phones, for the live-state fixture.
recorder?.write(
'Action: PJSIPShowContacts\r\nActionID: rec-2\r\n\r\n' +