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:
1 parent
0ff0f9292b
commit
5c46bd0e85
46 files changed
+555
-87
No files matched your search
@@ -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' +
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user