security: fraud alerts (M11-T10), admin access spec (M11-T9)

This commit is contained in:
iamdoubz committed 2026-10-29 18:35:06 -05:00
1 parent 63286a1954
commit 08bc611dec
11 files changed
+731 -5

No files matched your search

+55 -2
View File
@@ -2,8 +2,8 @@
// voip.ms REST/JSON API (06 §5.1): GET rest.php?api_username&api_password&method&…, JSON back with
// `status` = "success" or an error code. The password is in the query string, so the URL is never
// logged or put in an error.
// TODO(spec): response shapes and error codes are from the 06 spec, not a real account yet
// (voip.ms's API docs need a login); check them with the owner's account and fix here.
// TODO(spec): most response shapes and error codes are from the 06 spec, not a real account yet;
// voip.ms's example bundle (docs/voip.ms-api) only shows getCDR's. Check with the owner's account.
import { normalizeNumber } from '@selfphoneng/shared';
export type VoipmsCreds = { username: string; password: string };
@@ -106,6 +106,59 @@ export async function getDids(baseUrl: string, creds: VoipmsCreds): Promise<Voip
});
}
export type VoipmsCall = {
at: Date;
destination: string;
account: string;
disposition: string;
seconds: number;
total: string;
uniqueid: string;
};
/**
* One sub-account's call records between two UTC dates (getCDR, 06 §5.1). `timezone=0` makes
* `date` UTC. The request, `no_cdr` and most fields are in voip.ms's examples; `account`,
* `disposition` and `uniqueid` aren't (verify), and come back '' if missing.
*/
export async function getCdr(
baseUrl: string,
creds: VoipmsCreds,
q: { from: string; to: string; account: string },
): Promise<VoipmsCall[]> {
let r: Record<string, unknown>;
try {
r = await voipmsApi(baseUrl, creds, 'getCDR', {
date_from: q.from,
date_to: q.to,
answered: '1',
noanswer: '1',
busy: '1',
failed: '1',
timezone: '0',
account: q.account,
});
} catch (e) {
if (e instanceof VoipmsError && e.status === 'no_cdr') return [];
throw e;
}
return list(r.cdr).flatMap((c) => {
const at = new Date(`${str(c.date).replace(' ', 'T')}Z`);
if (Number.isNaN(at.getTime())) return [];
return [
{
at,
destination: str(c.destination),
account: str(c.account),
disposition: str(c.disposition),
seconds: Number(str(c.seconds)) || 0,
total: str(c.total),
uniqueid: str(c.uniqueid),
},
];
});
}
/**
* The account's balance (getBalance → balance.current_balance, in the account's currency).
* (verify) against a real account: the shape is from voip.ms's API docs (06 §5.1).
+14 -1
View File
@@ -31,6 +31,7 @@ import { logCallsToContrack } from './contacts/timeline.ts';
import { checkDirectory } from './sso/directory.ts';
import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { createFraudWatch } from './services/fraud.ts';
import { spamQuotaCheck } from './spam/check.ts';
import { ftcSync } from './spam/ftc.ts';
import { deliverDue } from './services/webhooks.ts';
@@ -130,6 +131,13 @@ export async function retentionCleanup(d: JobDeps) {
export function createJobs(d: JobDeps) {
const now = d.now ?? (() => new Date());
const tz = () => getSetting(d.db, 'general.timezone');
const fraud = createFraudWatch({
db: d.db,
voipmsUrl: d.config.VOIPMS_API_URL,
mail: (subject, text) => mailAdmins(d, subject, text),
log: d.log,
now,
});
// Holidays roll over at midnight (03 §6.10): render again when the local date changes.
// Retention runs then too (and at start), not at 02:00.
@@ -195,7 +203,8 @@ export function createJobs(d: JobDeps) {
`Public IP changed to ${ip}`,
`The phone system's public IP changed from ${old ?? 'none'} to ${ip}.\n` +
'Restart telephony so calls use the new address: Admin → System says how (it drops calls in progress).\n' +
'Phones and the provider may need the new IP if they were set up with the old one.',
'Phones and the provider may need the new IP if they were set up with the old one ' +
'(voip.ms: the sub-account’s IP restriction or IP authentication, and the API whitelist).',
);
};
@@ -296,6 +305,8 @@ export function createJobs(d: JobDeps) {
publicIpWatch,
trunkWatch,
diskWatch,
riskyCallWatch: fraud.riskyCallWatch,
providerWatch: fraud.providerWatch,
callReports,
contactsWarm,
contrackLog,
@@ -321,6 +332,8 @@ export function startJobs(d: JobDeps): () => void {
run('trunkWatch', j.trunkWatch);
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('riskyCallWatch', j.riskyCallWatch);
if (minute % 15 === 0) run('providerWatch', j.providerWatch);
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Fraud alerts (08 §8.3): risky calls the PBX placed, voip.ms calls it didn't place, and a
// balance that drops fast. Each is an email to every admin and a warning event.
import { and, eq, gt, gte, isNotNull, like, lte } from 'drizzle-orm';
import { formatNumber } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { calls, cdr, dids, trunks } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import {
getBalance,
getCdr,
VoipmsError,
type VoipmsCall,
type VoipmsCreds,
} from '../integrations/voipms/client.ts';
import { logEvent } from './events.ts';
import { getSetting } from './settings.ts';
/** NANP area codes outside the US and Canada: dialed like a home call, billed like abroad. (verify) */
const ABROAD_NANP = new Set(
'242 246 264 268 284 345 441 473 649 658 664 721 758 767 784 809 829 849 868 869 876'.split(' '),
);
/** Why a dialed number is worth an email, or null. 10 digits count as 1 + them. */
export function riskyDestination(dialed: string): string | null {
const n = /^[2-9]\d{9}$/.test(dialed) ? `1${dialed}` : dialed;
if (n.startsWith('011')) return 'international';
const m = /^1(\d{3})(\d{3})\d{4}$/.exec(n);
if (!m) return null;
if (m[1] === '900' || m[2] === '976') return 'premium';
if (ABROAD_NANP.has(m[1]!)) return 'international rates (outside the US and Canada)';
return null;
}
/** The number a trunk Dial sent (`PJSIP/<number>@trunk-<id>,120`), or null. */
export const trunkDialNumber = (lastdata: string | null): string | null =>
/^PJSIP\/([^@]+)@trunk-/.exec(lastdata ?? '')?.[1] ?? null;
const tail = (n: string) => n.replace(/\D/g, '').slice(-10);
const MIN = 60_000;
const day = (d: Date) => d.toISOString().slice(0, 10);
export type FraudDeps = {
db: Db;
voipmsUrl: string;
mail: (subject: string, text: string) => Promise<void>;
log: { warn(o: object, m: string): void };
now: () => Date;
};
export function createFraudWatch(d: FraudDeps) {
const when = async (at: Date) =>
new Intl.DateTimeFormat('en-US', {
dateStyle: 'short',
timeStyle: 'short',
timeZone: await getSetting(d.db, 'general.timezone'),
}).format(at);
const warn = (code: string, source: string, message: string) =>
logEvent(d.db, { severity: 'warning', code, source, message });
// Calls are read 10 s behind the clock, so a row whose insert is still committing isn't skipped.
// ponytail: the cursor starts at api start; a call made while the api was down has no row anyway.
let since = new Date(d.now().getTime() - 10_000);
const riskyCallWatch = async () => {
const until = new Date(d.now().getTime() - 10_000);
const rows = await d.db
.select()
.from(calls)
.where(
and(
eq(calls.direction, 'outbound'),
gt(calls.createdAt, since),
lte(calls.createdAt, until),
),
)
.orderBy(calls.createdAt);
since = until;
if (!(await getSetting(d.db, 'alerts.riskyCalls'))) return;
const lines: string[] = [];
for (const c of rows) {
const why = riskyDestination(c.toNumber ?? '');
if (why)
lines.push(
`${await when(c.startedAt)} from ${c.fromNumber ?? '?'} to ${formatNumber(c.toNumber!)}: ` +
`${why}, ${c.status}, ${c.talkSeconds} s talking`,
);
}
if (!lines.length) return;
await warn(
'fraud.risky_call',
'Fraud alerts',
`${lines.length} call(s) to international or premium numbers`,
);
await d.mail(
`Calls to risky numbers (${lines.length})`,
'The phone system placed calls to numbers that can cost a lot:\n\n' +
`${lines.join('\n')}\n\n` +
"If you don't recognise them, someone may have a phone's password: change it " +
'(Extensions → the extension → New SIP password) and check Outbound rules.\n' +
'These emails can be turned off on Settings → Security → Fraud alerts.',
);
};
// voip.ms records already reported, for 3 hours (08 §8.3).
const reported = new Map<string, number>();
async function callCheck(t: { name: string; username: string }, creds: VoipmsCreds) {
const now = d.now().getTime();
for (const [id, at] of reported) if (at < now - 180 * MIN) reported.delete(id);
const from = new Date(now - 120 * MIN);
const to = new Date(now - 10 * MIN);
// uniqueid isn't in voip.ms's examples (06 §5.1), so the time and number make the key too.
const key = (r: VoipmsCall) => `${r.uniqueid}|${r.at.getTime()}|${r.destination}`;
const records = (
await getCdr(d.voipmsUrl, creds, { from: day(from), to: day(to), account: t.username })
).filter((r) => r.at >= from && r.at <= to && !reported.has(key(r)));
if (!records.length) return;
const ours = new Set((await d.db.select({ n: dids.number }).from(dids)).map((x) => tail(x.n)));
// Every call the PBX sent to a trunk, forwards and 911 included (03 §6.12, §6.14).
const sent = (
await d.db
.select({ at: cdr.startAt, data: cdr.lastdata })
.from(cdr)
.where(
and(
like(cdr.lastdata, 'PJSIP/%@trunk-%'),
gte(cdr.startAt, new Date(from.getTime() - 5 * MIN)),
lte(cdr.startAt, new Date(to.getTime() + 5 * MIN)),
),
)
).flatMap((r) => {
const n = trunkDialNumber(r.data);
return n && r.at ? [{ n: tail(n), at: r.at.getTime() }] : [];
});
const strange: VoipmsCall[] = [];
for (const r of records) {
reported.set(key(r), r.at.getTime());
if (r.account && r.account !== t.username) continue;
if (ours.has(tail(r.destination))) continue; // incoming, to one of our numbers
const i = sent.findIndex(
(s) => s.n === tail(r.destination) && Math.abs(s.at - r.at.getTime()) <= 5 * MIN,
);
if (i >= 0) sent.splice(i, 1);
else strange.push(r);
}
if (!strange.length) return;
const lines = await Promise.all(
strange.map(
async (r) =>
`${await when(r.at)} to ${r.destination}: ${r.disposition.toLowerCase() || '?'}, ` +
`${r.seconds} s, cost ${r.total}`,
),
);
await warn(
'fraud.provider_call',
`Trunk ${t.name}`,
`voip.ms has ${strange.length} call(s) on ${t.username} that this phone system didn't make`,
);
await d.mail(
`voip.ms calls this phone system didn't make (${strange.length})`,
`voip.ms billed these calls on sub-account ${t.username} (trunk ${t.name}), and this ` +
'phone system has no record of making them:\n\n' +
`${lines.join('\n')}\n\n` +
'Someone else may have the sub-account password. At voip.ms: change the sub-account ' +
"password (then paste it on Trunks), and turn on its IP restriction with this server's " +
'public IP. Check the voip.ms call records too.\n' +
'A call made while SelfPhoneNG was restarting, or by another device using the same ' +
'sub-account, also shows up here.\n' +
'These emails can be turned off on Settings → Security → Fraud alerts.',
);
}
// Balance samples per API login (one account can have several trunks), the last hour's.
const balances = new Map<string, { at: number; v: number }[]>();
const balanceMailed = new Map<string, number>();
async function balanceCheck(creds: VoipmsCreds, limit: number) {
const now = d.now().getTime();
const v = await getBalance(d.voipmsUrl, creds);
const samples = [
...(balances.get(creds.username) ?? []).filter((s) => s.at >= now - 60 * MIN),
{ at: now, v },
];
balances.set(creds.username, samples);
const high = Math.max(...samples.map((s) => s.v));
if (high - v <= limit || (balanceMailed.get(creds.username) ?? 0) > now - 60 * MIN) return;
balanceMailed.set(creds.username, now);
const drop = (high - v).toFixed(2);
await warn(
'fraud.balance_drop',
'Fraud alerts',
`The voip.ms balance dropped ${drop} in an hour`,
);
await d.mail(
`voip.ms balance dropped ${drop} in an hour`,
`The voip.ms balance of ${creds.username} went from ${high.toFixed(2)} to ${v.toFixed(2)} ` +
`within the last hour, more than the ${limit} set on Settings → Security → Fraud alerts.\n\n` +
"If that isn't your own calling, check the call records at voip.ms, change the " +
'sub-account and API passwords, and turn on IP restriction on the sub-account.',
);
}
const providerWatch = async () => {
const checkCalls = await getSetting(d.db, 'alerts.providerCalls');
const limit = await getSetting(d.db, 'alerts.balanceDropPerHour');
if (!checkCalls && !limit) return;
const rows = await d.db
.select({
name: trunks.name,
username: trunks.username,
apiUser: trunks.voipmsApiUsername,
apiPass: trunks.voipmsApiPasswordEnc,
})
.from(trunks)
.where(
and(
eq(trunks.enabled, true),
eq(trunks.provider, 'voipms'),
isNotNull(trunks.voipmsApiUsername),
isNotNull(trunks.voipmsApiPasswordEnc),
),
);
const balanceAsked = new Set<string>();
for (const t of rows) {
const creds = { username: t.apiUser!, password: decrypt(t.apiPass!) };
try {
if (checkCalls && t.username)
await callCheck({ name: t.name, username: t.username }, creds);
if (limit && !balanceAsked.has(creds.username)) {
balanceAsked.add(creds.username);
await balanceCheck(creds, limit);
}
} catch (e) {
if (!(e instanceof VoipmsError)) throw e;
d.log.warn({ trunk: t.name, status: e.status }, 'voip.ms fraud check failed');
}
}
};
return { riskyCallWatch, providerWatch };
}
+231
View File
@@ -0,0 +1,231 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T10: fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, cdr, dids } from '../src/db/schema.ts';
import { createFraudWatch, riskyDestination, trunkDialNumber } from '../src/services/fraud.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe('riskyDestination', () => {
it('flags international, premium and NANP numbers billed like abroad', () => {
expect(riskyDestination('01144207946095')).toBe('international');
expect(riskyDestination('19005551234')).toBe('premium');
expect(riskyDestination('13129765555')).toBe('premium'); // exchange 976
expect(riskyDestination('18765551234')).toMatch(/international rates/); // Jamaica
expect(riskyDestination('8095551234')).toMatch(/international rates/); // 10 digits
expect(riskyDestination('13125551234')).toBeNull();
expect(riskyDestination('18005551234')).toBeNull();
expect(riskyDestination('101')).toBeNull();
});
it('reads the number out of a trunk Dial', () => {
expect(trunkDialNumber('PJSIP/13125550000@trunk-7d3e9a52,120')).toBe('13125550000');
expect(trunkDialNumber('PJSIP/911@trunk-7d3e9a52')).toBe('911');
expect(trunkDialNumber('PJSIP/101,30')).toBeNull();
expect(trunkDialNumber(null)).toBeNull();
});
});
describe.skipIf(!hasTestDb)('fraud watches', () => {
const at = (iso: string) => new Date(iso);
let clock = Date.parse('2026-10-29T12:00:00Z');
let balance = '100.00';
const cdrs: Record<string, unknown>[] = [];
const seen: URLSearchParams[] = [];
const fake = createServer((req, res) => {
const q = new URL(req.url!, 'http://x').searchParams;
seen.push(q);
res.setHeader('content-type', 'application/json');
const m = q.get('method');
res.end(
JSON.stringify(
m === 'getBalance'
? { status: 'success', balance: { current_balance: balance } }
: m === 'getCDR'
? cdrs.length
? { status: 'success', cdr: cdrs }
: { status: 'no_cdr' }
: { status: 'invalid_method' },
),
);
});
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let trunkId: string;
let url: string;
const mails: { subject: string; text: string }[] = [];
const watch = () =>
createFraudWatch({
db: t.db,
voipmsUrl: url,
mail: async (subject, text) => void mails.push({ subject, text }),
log: { warn() {} },
now: () => new Date(clock),
});
beforeAll(async () => {
await new Promise<void>((r) => fake.listen(0, '127.0.0.1', r));
url = `http://127.0.0.1:${(fake.address() as AddressInfo).port}/api/v1/rest.php`;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
req = (method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
const r = await req('POST', '/api/trunks', {
name: 'voip.ms',
serverHost: '127.0.0.1',
username: '123456_pbx',
password: 'Sub-account pw!',
});
trunkId = r.json().id;
await req('PATCH', `/api/trunks/${trunkId}`, {
voipmsApiUsername: 'me@example.com',
voipmsApiPassword: 'api-pw',
});
await t.db.insert(dids).values({
number: '13125551000',
trunkId,
destination: { type: 'hangup' },
} as typeof dids.$inferInsert);
});
afterAll(async () => {
fake.close();
await t.close();
});
it('riskyCallWatch emails each risky outside call once, and not when turned off', async () => {
const w = watch();
mails.length = 0;
const call = (linkedid: string, to: string, created: string) => ({
linkedid,
direction: 'outbound' as const,
status: 'answered' as const,
startedAt: at(created),
endedAt: at(created),
fromNumber: '101',
toNumber: to,
talkSeconds: 125,
createdAt: at(created),
});
await t.db
.insert(calls)
.values([
call('r1', '01144207946095', '2026-10-29T12:00:20Z'),
call('r2', '18765551234', '2026-10-29T12:00:30Z'),
call('r3', '13125551234', '2026-10-29T12:00:40Z'),
]);
clock = Date.parse('2026-10-29T12:01:00Z');
await w.riskyCallWatch();
expect(mails.map((m) => m.subject)).toEqual(['Calls to risky numbers (2)']);
expect(mails[0]!.text).toMatch(/from 101 to 01144207946095: international, answered/);
expect(mails[0]!.text).toMatch(/\(876\) 555-1234: international rates/);
expect(mails[0]!.text).not.toMatch(/\(312\) 555-1234/);
clock += 60_000;
await w.riskyCallWatch(); // nothing new
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.riskyCalls', false);
await t.db.insert(calls).values(call('r4', '19005551234', '2026-10-29T12:01:30Z'));
clock += 60_000;
await w.riskyCallWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.riskyCalls', true);
});
it('providerWatch reports voip.ms calls the PBX never sent to the trunk, once', async () => {
await setSetting(t.db, 'alerts.balanceDropPerHour', 0);
const w = watch();
mails.length = 0;
clock = Date.parse('2026-10-29T14:00:00Z');
// The PBX sent one call to the trunk at 13:00 (a forward would look the same).
await t.db.insert(cdr).values({
uniqueid: 'u1',
linkedid: 'u1',
startAt: at('2026-10-29T13:00:05Z'),
lastdata: `PJSIP/13125550000@trunk-${trunkId},120`,
raw: {},
});
const rec = (uniqueid: string, date: string, destination: string) => ({
date,
callerid: '"Office" <3125551000>',
destination,
description: 'Outgoing',
account: '123456_pbx',
disposition: 'ANSWERED',
duration: '00:03:00',
seconds: '180',
rate: '0.25',
total: '0.7500',
uniqueid,
});
cdrs.push(
rec('v1', '2026-10-29 13:00:09', '3125550000'), // ours
rec('v2', '2026-10-29 13:10:00', '3125551000'), // incoming, to our DID
rec('v3', '2026-10-29 13:20:00', '01144207946095'), // not ours
rec('v4', '2026-10-29 11:00:00', '01144207946096'), // before the window
{ ...rec('v5', '2026-10-29 13:30:00', '01144207946097'), account: '123456_other' },
);
await w.providerWatch();
const q = seen.findLast((s) => s.get('method') === 'getCDR')!;
expect(
Object.fromEntries(['date_from', 'date_to', 'timezone', 'account'].map((k) => [k, q.get(k)])),
).toEqual({
date_from: '2026-10-29',
date_to: '2026-10-29',
timezone: '0',
account: '123456_pbx',
});
expect(mails.map((m) => m.subject)).toEqual([
"voip.ms calls this phone system didn't make (1)",
]);
expect(mails[0]!.text).toMatch(/to 01144207946095: answered, 180 s, cost 0\.7500/);
expect(mails[0]!.text).not.toMatch(/3125550000|3125551000|946096|946097/);
clock += 15 * 60_000;
await w.providerWatch(); // v3 again: already reported
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', false);
cdrs.push(rec('v6', '2026-10-29 14:00:00', '01144207946098'));
clock += 15 * 60_000;
await w.providerWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', true);
cdrs.length = 0;
});
it('balanceWatch emails once an hour when the balance drops more than the limit', async () => {
await setSetting(t.db, 'alerts.providerCalls', false);
await setSetting(t.db, 'alerts.balanceDropPerHour', 10);
const w = watch();
mails.length = 0;
clock = Date.parse('2026-10-29T16:00:00Z');
balance = '100.00';
await w.providerWatch();
clock += 15 * 60_000;
balance = '95.00';
await w.providerWatch();
expect(mails).toEqual([]);
clock += 15 * 60_000;
balance = '85.00';
await w.providerWatch();
expect(mails.map((m) => m.subject)).toEqual(['voip.ms balance dropped 15.00 in an hour']);
clock += 15 * 60_000;
balance = '70.00';
await w.providerWatch(); // within the hour of the last email
expect(mails).toHaveLength(1);
clock += 60 * 60_000;
balance = '200.00'; // a deposit
await w.providerWatch();
expect(mails).toHaveLength(1);
await setSetting(t.db, 'alerts.providerCalls', true);
});
});
+36
View File
@@ -796,6 +796,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
const [provOn, setProvOn] = useState(s['provisioning.enabled']);
const [perExt, setPerExt] = useState(String(s['calls.maxPerExtension']));
const [total, setTotal] = useState(String(s['calls.maxTotal']));
const [riskyCalls, setRiskyCalls] = useState(s['alerts.riskyCalls']);
const [providerCalls, setProviderCalls] = useState(s['alerts.providerCalls']);
const [balanceDrop, setBalanceDrop] = useState(String(s['alerts.balanceDropPerHour']));
const box = (checked: boolean, set: (v: boolean) => void, label: string) => (
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={checked} onChange={(e) => set(e.target.checked)} />
@@ -814,6 +817,9 @@ function SecurityTab({ s }: { s: SettingsDto }) {
'provisioning.enabled': provOn,
'calls.maxPerExtension': Number(perExt),
'calls.maxTotal': Number(total),
'alerts.riskyCalls': riskyCalls,
'alerts.providerCalls': providerCalls,
'alerts.balanceDropPerHour': Number(balanceDrop),
'sip.udpPort': Number(sipPort),
'sip.tcpEnabled': tcpOn,
'sip.tlsEnabled': tlsOn,
@@ -873,6 +879,36 @@ function SecurityTab({ s }: { s: SettingsDto }) {
)}
</Field>
</div>
<h2 className="font-medium">Fraud alerts</h2>
<p className="-mt-2 text-xs text-muted">
Emails to every admin. The last two need the voip.ms API login on the trunk (Trunks).
</p>
{box(riskyCalls, setRiskyCalls, 'Calls to international, premium and Caribbean numbers')}
{box(
providerCalls,
setProviderCalls,
"Calls voip.ms billed that this phone system didn't make",
)}
<p className="-mt-2 text-xs text-muted">
Checked every 15 minutes. That&apos;s what a stolen trunk password looks like: the calls
never pass through this server, so no limit here can stop them.
</p>
<Field
label="voip.ms balance drop in an hour"
hint="In your account's currency. 0 = no email."
>
{(p) => (
<input
{...p}
className={`${inputClass} max-w-32`}
type="number"
min={0}
step="any"
value={balanceDrop}
onChange={(e) => setBalanceDrop(e.target.value)}
/>
)}
</Field>
<h2 className="font-medium">Phone setup files</h2>
{box(provOn, setProvOn, 'Serve setup files to phones (Phones page)')}
{box(lanOnly, setLanOnly, 'Only to phones on the local networks')}
+3
View File
@@ -684,6 +684,9 @@ with a zod schema per key and a default. Missing rows return the default.
| `sip.extraAllowedNets` | string[] | `[]` | e.g. a VPN subnet |
| `calls.maxPerExtension` | number | 3 | outside calls going out at once per extension (the caller, or whose forward it is); 0 = no limit (08 §1) |
| `calls.maxTotal` | number | 10 | outside calls going out at once in total; 0 = no limit. 911 counts toward neither |
| `alerts.riskyCalls` | boolean | true | email admins about calls to international, premium and Caribbean numbers (08 §8.3) |
| `alerts.providerCalls` | boolean | true | email admins about calls voip.ms billed that this PBX didn't make (08 §8.3) |
| `alerts.balanceDropPerHour` | number | 10 | email admins when the voip.ms balance drops more than this in an hour; 0 = off (08 §8.3) |
| `provisioning.lanOnly` | boolean | false | only serve `/prov` to clients from local nets |
| `provisioning.enabled` | boolean | true | |
| `system.restartRequired` | boolean | false | set by generator |
+4 -1
View File
@@ -64,7 +64,8 @@ an admin (`requireAdmin` refuses them). See `13-mobile.md` §2 and §4.
### 2.4 Guards
Fastify `preHandler` hooks in `auth/guards.ts`:
- `requireAuth`: sets `req.user`.
- `requireAuth`: sets `req.user`. An admin outside `auth.adminAllowedNets` is set as a user
(`req.adminElsewhere`), and `auth.requireAdminTotp` adds `totp_setup_required` (08 §8.2, M11-T9).
- `requireAdmin`.
- `requireSelfOrAdmin(paramName)`: for `/api/me/*` and per-user resources.
@@ -481,6 +482,8 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `webhookDelivery` | every 10 s | send pending deliveries (§9) |
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
| `diskWatch` | hourly | if free disk < 10% → email admins once a day |
| `riskyCallWatch` | every 60 s | new outside calls to risky numbers → email admins (08 §8.3) |
| `providerWatch` | every 15 min | voip.ms calls the PBX didn't make, and a balance drop → email admins (08 §8.3) |
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
+8
View File
@@ -59,6 +59,14 @@ to the next one. Steps can be skipped where noted.
2. Company name, timezone (default from the browser), language.
3. Network: "Detect public IP", LAN IP (prefilled from `/api/system/status` if detectable), local nets.
A big note about SIP ALG and port forwarding (01 §4.2) with a checkbox "I've done this".
3a. Admin access (owner, 2026-10-29; 08 §8.2, M11-T9), skippable. Both switches start on:
- "Admin pages only from these networks" (`auth.adminAllowedNets`), the list prefilled with
step 3's local nets, plus the address in use now when it's outside them (else the save
would be refused with `would_lock_out`), marked "the address you're using now: remove it
if it changes".
- "Require two-step sign-in for admins" (`auth.requireAdminTotp`): turning it on sets up the
wizard admin's own two-step sign-in right here (QR, one code, recovery codes), so
`totp_first` is met. Skipping that setup leaves the switch off.
4. Email (SMTP), skippable. Test button.
5. Trunk: voip.ms form (06 §2), with the voip.ms portal checklist next to it. "Test" must show Registered before Next is enabled. Skippable for now ("I'll add it later").
6. Phone numbers: Import from voip.ms (if API creds were given) or add manually.
+134 -1
View File
@@ -7,12 +7,13 @@
| SIP scanners / password guessing | "friendly-scanner", sipvicious hitting 5060 | Generated 24-char SIP passwords; no anonymous endpoint; trunks matched by source address only (`identify_by = ip`, M11-T8); fail2ban/CrowdSec on `security.log`; optional phone ACL (§2.2), browser endpoints included; firewall allow-list |
| **Toll fraud** (the expensive one) | Stolen SIP creds used to call premium international numbers | International route disabled by default; per-user outbound class; outside calls at once per extension (3) and in total (10), Settings → Security; trunk `max_channels`; voip.ms-side international block + balance limits (06 §1); forward targets checked against class; dialed numbers `FILTER`ed before the route's Goto (M11-T8: a "," skipped the class check) |
| Web credential stuffing | Login brute force | argon2id, rate limits, audit log, TOTP for admins (M11) |
| Admin panel bugs | FreePBX 2025–26: an auth bypass + SQL injection, then webshells and stolen trunk passwords (§8) | Admin pages only from listed networks, two-step sign-in required for admins (§8.2, M11-T9) |
| Voicemail PIN guessing | Calling *98 and trying PINs | 6-digit default PIN, `maxlogins=3`, reject trivial PINs (`000000`, `123456`, the extension number repeated). As built (M11-T1): also for PINs changed from the phone's voicemail menu: `externpasscheck = /usr/local/bin/sp-vm-pincheck` (asterisk image, same rules, a unit test keeps it in step with `isTrivialPin`), `minpassword = 4`; the api stores accepted ones from AMI `VoicemailPasswordChange` (`services/vmpin.ts`), otherwise the next config write would put the old PIN back |
| Provisioning leaks | Fetching `/prov/<mac>.cfg` to steal SIP passwords | Per-device Basic auth, 404 on unknown MAC, rate limit + temporary IP block, optional LAN-only |
| Dialplan injection | Name `x); System(...)`; dialed `011…,4` or `…&PJSIP/…` | Sanitizers (03 §2), no `System()` anywhere; at call time, `FILTER` on every `${EXTEN}` from a phone that reaches an app's arguments (M11-T8) |
| SSRF via webhooks | Webhook to `http://127.0.0.1:5038` | Private IP block by default (04 §9), NAT64/6to4 included |
| Stolen backup | Backup file copied | Backups exclude generated plaintext configs; DB secrets are encrypted with `APP_SECRET` (kept in `.env`, not in the backup); mode 0600 |
| AMI exposure | AMI reachable from network | Bound to 127.0.0.1, `permit` 127.0.0.1 only, random secret |
| AMI exposure | AMI reachable from network | Bound to 127.0.0.1, `permit` 127.0.0.1 only, random secret; only the classes the api uses, and no module in the image that runs host commands (§8.1) |
## 2. Required controls
@@ -276,3 +277,135 @@ encrypted provider tokens; LDAP filter escaping and no empty binds; the caller-n
for Asterisk on this machine with its token; phonebook XML and timeline HTML escaped, report
emails plain text; AI and Contrack addresses literal local IPs with redirects refused; the spam
provider key kept out of errors; every new raw SQL parameterized; no secret in any GET.
## 8. After the FreePBX campaign (owner, 2026-10-29)
INJ3CTOR3 got into internet-facing FreePBX through its admin panel: CVE-2025-57819 (Endpoint
module, an auth bypass + SQL injection, no sign-in needed) and CVE-2025-64328 (filestore, command
injection after sign-in). It left PHP webshells, cron jobs and backdoor accounts, then copied out
the trunk, AMI, database and mail passwords. With the trunk password it registers to the provider
from its own machine, so the calls skip the PBX entirely. Our stack has no PHP and no shell in
reach of the web (§6, §7). Two changes follow.
### 8.1 AMI: only the classes we use (as built)
| Class | Actions the api sends | Events the api reads |
|---|---|---|
| `system` | DBPut, DBDel, DBDelTree, PJSIPShowContacts, PJSIPShowEndpoint, PJSIPShowRegistrationsOutbound, PJSIPQualify, CoreShowChannels | ContactStatus, Registry |
| `call` | Redirect, Hangup, ParkedCalls, ExtensionStateList | Newchannel, Newstate, NewCallerid, NewConnectedLine, BridgeEnter/Leave, Hold/Unhold, Hangup, ParkedCall, ParkedCallTimeOut, ParkedCallGiveUp, UnParkedCall, ExtensionStatus, MessageWaiting |
| `command` | Command (reloads, `core show uptime`, `core restart`, `pjsip show transports`) | |
| `originate` | Originate | |
| `agent` | QueuePause | QueueCaller*, QueueMember*, AgentConnect |
| `user` | VoicemailMove, VoicemailRemove | UserEvent, VoicemailPasswordChange |
| `cdr` | | Cdr |
| `dialplan` | | VarSet, Newexten |
Ping, Login, Logoff and QueueStatus need no class. Action classes come from `manager show command`
on 24.0.0-rc3; event classes from Asterisk's AMI event docs.
Measured on 24.0.0-rc3: an action is allowed when the user has **any one** of its classes. So
`system`, which DB* and PJSIP* need, still allows GetConfig (any config file, trunk passwords
included) and ModuleLoad, and ModuleLoad loaded `func_shell.so` past `noload` in modules.conf
(`Command: module load` does the same). Class limits are no barrier (Asterisk's docs say not to
rely on them). What dropping the rest takes away is `config`: UpdateConfig and CreateConfig, a
way to write a trunk into a file the admin panel never shows. The barrier is the image itself:
`asterisk/Dockerfile` deletes `app_system`, `func_shell`, `app_disa`, `res_agi` and
`app_externalivr`, so nothing is left for ModuleLoad to load. The AMI secret is still the whole
key (Originate is enough for toll fraud), so AMI stays on 127.0.0.1.
The Asterisk tests mount the real template with only `bindaddr` and `permit` opened
(`test/asterisk/manager-conf.ts`), so a class the api needs and the template lacks fails them.
### 8.2 Admin access (to build: M11-T9)
Both FreePBX bugs were reached through an admin panel open to the internet, and Sangoma's first
advice was to block it. SelfPhoneNG serves the admin pages, the user portal and the browser
phone's WebSocket on one host and port, so a firewall can't keep one out and let the others in. Two settings on
Settings → Security, both off by default (today's behaviour):
**`auth.adminAllowedNets`**: CIDR list, IPv4 and IPv6 (`z.union([z.cidrv4(), z.cidrv6()])`).
Empty means admin from anywhere.
- `requireAuth` (`auth/guards.ts`), once `req.user` is set: an admin whose `req.ip` is outside the
list, and isn't 127.0.0.1 or ::1, gets `req.user = { ...user, role: 'user' }` and
`req.adminElsewhere = true`, the same way a paired phone is never an admin (13 §2). Every role
check after that sees a user: `requireAdmin`, recordings (`role === 'admin'`), the OpenID
`next` default. Sessions and API tokens alike (a token acts as its admin, 04 §2.3).
- Matching: node:net `BlockList`, as `/prov` does (`isLocal` in `routes/provisioning.ts`, which
becomes the shared `inNets(ip, nets)`), with `::ffff:`-mapped IPv4 unwrapped. The list is read
with `getSetting` only when the user is an admin (one row by key).
- `/api/live` (`live/hub.ts`) reads the session itself: `admin` at connect uses the same rule
with the socket's `req.ip`. The 30 s re-check compares the same way and closes with 4401 when
the list changes under it.
- Two-step routes (`/api/auth/totp/*`) check the stored role, not the downgraded one: an admin
away from the office can still look after their own sign-in.
- Signing in works from anywhere. An admin outside the list gets their own portal (My phone,
voicemail, calls). `GET /api/auth/me` adds `adminElsewhere: true`, and the web app shows
one line where the admin menu would be: "Admin pages open only from an allowed network
(Settings → Security)."
- 127.0.0.1 and ::1 always count as allowed. That's the way back in when the list is wrong:
`ssh -L 8080:127.0.0.1:8080 <server>`, then `http://localhost:8080` (browsers keep `Secure`
cookies on localhost). The admin guide shows it.
- `PATCH /api/settings` refuses, like `sip.tlsEnabled` (§2.1):
- 409 `would_lock_out` when the new list isn't empty and the request's own address isn't in it
(loopback counts as in);
- 409 `trust_proxy_off` when `req.ip` is loopback and the request has `X-Forwarded-For`: behind
a proxy without `TRUST_PROXY=true` every request looks local and the list would do nothing.
- The form offers "Add the local networks" (`network.localNets`) and the address you're using now.
Its help text: integrations calling admin routes with an API token need their address listed
too; a VPN into the office network is the usual answer for working away.
- Diagnostics (§2.6): a warning when the list is empty ("Admin pages open from anywhere");
otherwise the list.
**`auth.requireAdminTotp`**: boolean.
- When on, an admin without two-step sign-in (`totpSecretEnc` null) whose session came from a
password or the directory gets a session that works like `must_change_password` (04 §2.2):
every route outside `/api/auth/*` → 403 `totp_setup_required`, and the web app sends them to
My account → Two-step sign-in. The check reads the user row on each request, so the session
turns normal once two-step is on. `mustChange` in `auth/sessions.ts` grows this case, so
`/api/live` follows it too.
- Exempt: OpenID sessions (`sessions.method = 'oidc'`, the provider does the second factor, 12)
and API tokens (made by an admin who signed in).
- Turning it on needs the saving admin to have two-step on (409 `totp_first`).
- "Turn off for a lost phone" (`POST /api/users/:id/reset-two-step`) still works: that admin is
sent to set it up again at their next sign-in.
Tests (each fails without its rule): an admin outside the list gets 403 on an admin route and
200 on `/api/me`, from 127.0.0.1 and from inside it is an admin (IPv4, IPv6, `::ffff:`); an API
token the same; `/api/live` gives `admin: false` outside; a recording of someone else refused
outside; `would_lock_out` and `trust_proxy_off`; `totp_setup_required` until two-step is on,
then 200; an OpenID session exempt; `totp_first`.
Setup (owner, 2026-10-29): the first-run wizard offers both, switched on, with the list
prefilled from the local networks (05 §2.1 step 3a). The settings' own defaults stay off, so an
update changes nothing on an existing install.
### 8.3 Fraud alerts (owner, 2026-10-29; M11-T10)
Until now an admin learned of toll fraud from voip.ms's balance alert or the bill. Fraud with a
stolen trunk password leaves no call in our records at all: the calls never touch the PBX. Three
checks, each an email to every admin plus a `warning` system event; all three are on by default
and on Settings → Security, "Fraud alerts". In `services/fraud.ts`, run from `jobs.ts`.
- **`riskyCallWatch`** (every minute; `alerts.riskyCalls`): outside calls the PBX placed, new
since the last run (`calls.created_at`), to a number `riskyDestination` calls risky: `011…`
(international), area code 900 or exchange 976 (premium), and the NANP area codes outside the
US and Canada, which are dialed like a domestic call but billed at international rates and are
the usual one-ring-scam targets: 242 246 264 268 284 345 441 473 649 658 664 721 758 767 784
809 829 849 868 869 876 **(verify the list)**. One email per run listing the calls. The
cursor starts when the api starts: calls made while it was down have no row anyway (their CDR
events are lost).
- **`providerCallCheck`** (every 15 minutes; `alerts.providerCalls`; voip.ms trunks with an API
login, 06 §5.1): voip.ms's call records for the trunk's sub-account (`getCDR`,
`account = trunks.username`, `timezone = 0` so times are UTC) from 2 hours to 10 minutes ago.
Records to one of our DIDs are incoming and skipped. Every other record must match an
outbound `calls` row: same last 10 digits of the number, started within 5 minutes. One that
doesn't is a call voip.ms billed that this PBX didn't make: the email lists them and says to
change the sub-account password, turn on its IP restriction (06 §1), and that a call made while
the api was restarting, or by another device on the same sub-account, also shows here. Each
voip.ms record is reported once (by `uniqueid`, time and number, kept in memory for 3 hours).
- **`balanceWatch`** (every 15 minutes, same API logins; `alerts.balanceDropPerHour`, 0 = off):
the balance (`getBalance`) more than that amount below its highest in the last hour → one email
per hour. It catches what the call check can't: another sub-account, or one made with a stolen
API password. A deposit only raises the balance, so it never alerts.
An API error is logged (`warn`), not emailed: Reports already shows why the balance can't be read.
+2
View File
@@ -265,6 +265,8 @@ As built: all four tasks done and tested with simulated phones (`scripts/e2e/m10
**M11-T6** Reports page (05 §2).
**M11-T7** `docs/admin-guide/`: install, voip.ms setup with screenshots, NAT/SIP ALG, firewall + fail2ban/CrowdSec, E911 duties, recording law notice, troubleshooting (08 §3.3), updating, backup/restore.
**M11-T8** Security review: run through 08 §1 line by line, and get a review from a separate agent that didn't write the code. Fix findings.
**M11-T9** (owner, 2026-10-29) Admin access: `auth.adminAllowedNets` and `auth.requireAdminTotp` (08 §8.2), Settings → Security, setup wizard step 3a (05 §2.1), Diagnostics line, admin guide (the SSH way back in).
**M11-T10** (owner, 2026-10-29) Fraud alerts (08 §8.3): risky calls, voip.ms calls the PBX didn't make, balance drop.
✅ An outside tester follows only the admin guide and gets inbound/outbound calls working on a fresh VM.
---
+5
View File
@@ -103,6 +103,11 @@ export const SettingsSchemas = {
// Toll fraud (08 §1): outside calls going out at once, per extension and in total. 0 = no limit.
'calls.maxPerExtension': z.number().int().min(0).max(100).default(3),
'calls.maxTotal': z.number().int().min(0).max(1000).default(10),
// Fraud alerts (08 §8.3): emails to admins.
'alerts.riskyCalls': z.boolean().default(true),
'alerts.providerCalls': z.boolean().default(true),
/** In the voip.ms account's currency; 0 = off. */
'alerts.balanceDropPerHour': z.number().min(0).max(100000).default(10),
'provisioning.lanOnly': z.boolean().default(false),
'provisioning.enabled': z.boolean().default(true),
'system.restartRequired': z.boolean().default(false),