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' +
+60 -2
View File
@@ -31,6 +31,7 @@ const GOOD = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
const BAD = '0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6';
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
const PHONES = 'sp-trunk-phones';
const ATTACKER = 'sp-trunk-attacker';
const EXT_ID = '00000000-0000-4000-8000-000000000101';
const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx';
const DID = '13125551234';
@@ -127,6 +128,16 @@ function snapshot(): Snapshot {
trunkIds: [GOOD],
callerId: null,
},
{
id: 'aaaaaaaa-0000-4000-8000-000000000002',
name: 'International',
position: 1,
enabled: true,
patterns: [{ match: '011.', strip: 0, prepend: '' }],
requiredClass: 'international',
trunkIds: [GOOD],
callerId: null,
},
],
};
}
@@ -246,7 +257,7 @@ async function main() {
);
try {
docker('rm', '-f', PBX, PROVIDER, PHONES);
docker('rm', '-f', PBX, PROVIDER, PHONES, ATTACKER);
docker('network', 'create', NET);
const prov = asterisk(PROVIDER, provConf, []);
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
@@ -387,6 +398,24 @@ async function main() {
log('class check: ss-noservice');
await ami.command('channel request hangup all');
// M11-T8: SIP allows "," in a dialed number. Unfiltered, Goto(sp-route-…,${EXTEN},1) became
// Goto(sp-route-…,011…,4,1) and started past the class check: international for anyone.
await sleep(1000);
await ami.command('channel originate Local/01144207946095,4@sp-from-ext application Wait 10');
await waitFor(
'ss-noservice for 01144207946095,4',
async () => /Playback!ss-noservice/.test(await ami.command('core show channels concise')),
10,
).catch((e: Error) => problems.push(e.message));
if (
/LAST_IN=to=011/.test(
docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out,
)
)
problems.push('a comma in the dialed number skipped the class check: 011… reached the trunk');
else log('class check: a comma in the number does not skip it');
await ami.command('channel request hangup all');
// M3-T4: 911 from a caller with no class at all still goes out, with the E911 caller ID, and
// the dialplan tells the api. (The fake provider answers it; nothing real is dialed.)
const userEvents: Record<string, string | string[]>[] = [];
@@ -437,6 +466,35 @@ async function main() {
log('911: out with the E911 caller ID, SpEmergency raised, alert plays on 101');
await ami.command('channel request hangup all');
// M11-T8: a trunk has no inbound password, so only its server's address may match it. Before
// identify_by = ip, an INVITE from anywhere with From: trunk-<id> matched it by username.
const atkConf = join(dir, 'attacker');
mkdirSync(atkConf);
for (const f of GENERATED_FILES)
writeFileSync(
join(atkConf, f),
f === 'pjsip_selfphoneng.conf'
? `[transport-udp]\ntype = transport\nprotocol = udp\nbind = 0.0.0.0:5060\n\n` +
`[pbx]\ntype = endpoint\ncontext = default\ndisallow = all\nallow = ulaw\naors = pbx\n` +
`from_user = trunk-${GOOD}\n\n[pbx]\ntype = aor\ncontact = sip:${PBX}:5060\n`
: '',
);
if (!asterisk(ATTACKER, atkConf, []).ok) throw new Error('attacker container');
await booted(ATTACKER);
dials.length = 0;
docker(
'exec',
ATTACKER,
'asterisk',
'-rx',
`channel originate PJSIP/${DID}@pbx application Wait 5`,
);
await sleep(6000);
if (dials.some((m) => String(m.DestChannel).startsWith('PJSIP/101-')))
problems.push(`an INVITE claiming From: trunk-${GOOD} from another address rang 101`);
else log('a From of trunk-<id> from another address is not taken for the trunk');
await ami.command('channel request hangup all');
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
// An RTCP report sent while a test container's network is going away fails with EPERM;
@@ -454,7 +512,7 @@ async function main() {
}
recorder?.destroy();
ami.stop();
docker('rm', '-f', PBX, PROVIDER, PHONES);
docker('rm', '-f', PBX, PROVIDER, PHONES, ATTACKER);
docker('network', 'rm', NET);
rmSync(dir, { recursive: true, force: true });
}
+66
View File
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// REGISTER over SIP WebSocket with digest auth, like the browser softphone through the reverse
// proxy. call-test.ts runs it in Asterisk's network namespace, so it comes from 127.0.0.1.
// Usage: node ws-register.mjs <ws url> <user> <password>. Prints the final status line.
import { createHash, randomUUID } from 'node:crypto';
const [url, user, pass] = process.argv.slice(2);
const md5 = (s) => createHash('md5').update(s).digest('hex');
const host = new URL(url).hostname;
const uri = `sip:${host}`;
const callId = randomUUID();
const tag = randomUUID().slice(0, 8);
const register = (cseq, auth) =>
[
`REGISTER ${uri} SIP/2.0`,
`Via: SIP/2.0/WS ws-test.invalid;branch=z9hG4bK${randomUUID().slice(0, 12)}`,
'Max-Forwards: 70',
`From: <sip:${user}@${host}>;tag=${tag}`,
`To: <sip:${user}@${host}>`,
`Call-ID: ${callId}`,
`CSeq: ${cseq} REGISTER`,
`Contact: <sip:${user}@ws-test.invalid;transport=ws>`,
'Expires: 60',
...(auth ? [`Authorization: ${auth}`] : []),
'Content-Length: 0',
'',
'',
].join('\r\n');
const ws = new WebSocket(url, 'sip');
const timer = setTimeout(() => {
console.log('TIMEOUT');
process.exit(1);
}, 10_000);
ws.onopen = () => ws.send(register(1));
ws.onerror = (e) => {
console.log(`ERROR ${e.message ?? e}`);
process.exit(1);
};
ws.onmessage = ({ data }) => {
const msg = String(data);
const status = msg.split('\r\n')[0];
const challenge = /WWW-Authenticate: Digest (.*)/i.exec(msg)?.[1];
if (status.includes(' 401 ') && challenge) {
const p = Object.fromEntries(
[...challenge.matchAll(/(\w+)="?([^",]*)"?/g)].map((m) => [m[1], m[2]]),
);
const cnonce = randomUUID().slice(0, 8);
const ha1 = md5(`${user}:${p.realm}:${pass}`);
const ha2 = md5(`REGISTER:${uri}`);
const response = md5(`${ha1}:${p.nonce}:00000001:${cnonce}:auth:${ha2}`);
ws.send(
register(
2,
`Digest username="${user}", realm="${p.realm}", nonce="${p.nonce}", uri="${uri}", ` +
`response="${response}", algorithm=MD5, qop=auth, nc=00000001, cnonce="${cnonce}"` +
(p.opaque ? `, opaque="${p.opaque}"` : ''),
),
);
return;
}
console.log(status);
clearTimeout(timer);
ws.close();
};