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();
};
+12
View File
@@ -73,6 +73,18 @@ describe.skipIf(!hasTestDb)('auth', () => {
expect(r.json().error.code).toBe('csrf');
});
it('M11-T8: a percent-encoded path gets the same CSRF check', async () => {
for (const url of ['/%61pi/auth/login', '/api%2Fauth/login', '//api/auth/login']) {
const r = await t.app.inject({
method: 'POST',
url,
payload: { email: ADMIN.email, password: adminPassword },
});
expect([403, 404], url).toContain(r.statusCode);
if (r.statusCode === 403) expect(r.json().error.code).toBe('csrf');
}
});
it('must_change_password blocks everything outside /api/auth/* until changed', async () => {
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
const blocked = await get('/api/admin-probe', cookie);
+4
View File
@@ -27,6 +27,10 @@ it('isPrivateAddress', () => {
'fe80::1',
'fd00::1',
'::ffff:7f00:1',
'198.18.0.1',
'192.0.0.8',
'64:ff9b::a00:1',
'2002:7f00:1::1',
])
expect(isPrivateAddress(ip), ip).toBe(true);
for (const ip of ['8.8.8.8', '203.0.113.9', '2001:4860:4860::8888', '::ffff:8.8.8.8'])
+51 -1
View File
@@ -8,7 +8,9 @@ import type { LiveMessage } from '@selfphoneng/shared';
import { AmiParser, field, type AmiMessage } from '../src/ami/events.ts';
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
import type { Db } from '../src/db/client.ts';
import { extensions, userRuntimeState } from '../src/db/schema.ts';
import { eq } from 'drizzle-orm';
import { extensions, sessions, userRuntimeState, users } from '../src/db/schema.ts';
import { timing } from '../src/live/hub.ts';
import { LiveState, extOfEndpoint, stateFromStatus } from '../src/live/state.ts';
import { createTestDb, hasTestDb } from './db.ts';
import { ADMIN, createTestApp } from './app.ts';
@@ -252,3 +254,51 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
for (const c of [admin, me]) c.ws.terminate();
});
});
// M11-T8: a socket from another site is refused; a logout, deactivation or demotion closes it.
describe.skipIf(!hasTestDb)('/api/live WebSocket re-checks', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
beforeAll(async () => {
timing.recheckMs = 200;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => {
timing.recheckMs = 30_000;
return t?.close();
});
const open = async (headers: Record<string, string>) => {
const ws = await t.app.injectWS('/api/live', { headers });
let closed: number | undefined;
ws.on('close', (code: number) => (closed = code));
return () => closed;
};
const until = async (fn: () => boolean) => {
for (let i = 0; i < 100 && !fn(); i++) await new Promise((r) => setTimeout(r, 20));
expect(fn()).toBe(true);
};
it('refuses an Origin that is not this host', async () => {
const cookie = await t.login(ADMIN.email, ADMIN.password);
const closed = await open({ cookie, origin: 'https://evil.example', host: 'pbx.test' });
await until(() => closed() === 4403);
const ok = await open({ cookie, origin: 'https://pbx.test', host: 'pbx.test' });
await new Promise((r) => setTimeout(r, 500));
expect(ok()).toBeUndefined();
});
it('closes the socket once the session is gone or the role changed', async () => {
const admin = await t.addUser('demoted@example.com', 'user-password-123', 'admin');
const closedByRole = await open({
cookie: await t.login('demoted@example.com', 'user-password-123'),
});
const loggedOut = await open({ cookie: await t.login(ADMIN.email, ADMIN.password) });
await new Promise((r) => setTimeout(r, 500));
expect(closedByRole()).toBeUndefined();
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, admin.id));
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
await t.db.delete(sessions).where(eq(sessions.userId, me!.id));
await until(() => closedByRole() === 4401 && loggedOut() === 4401);
});
});