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

+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 });
}