Files
selfphoneng/apps/api/src/routes/provisioning.ts
T
iamdoubz 97e67e347d
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s
auth: security audit fixes, rate limits on every route
Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
2026-10-26 09:46:23 -05:00

158 lines
6.6 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// GET /prov/... (07 §2): phones fetch their config with HTTP Basic auth (user = MAC, password =
// the device's provisioning password). No session. Unknown MAC or bad auth → 401, alike; wrong
// credentials are logged with the IP and count toward an IP block (provisioning/limiter.ts).
import { timingSafeEqual } from 'node:crypto';
import { BlockList, isIPv4 } from 'node:net';
import { eq, sql } from 'drizzle-orm';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { decrypt } from '../crypto/secrets.ts';
import { devices, extensions } from '../db/schema.ts';
import { linphoneXml, verifyLinphoneToken } from '../provisioning/linphone.ts';
import { loadContext, loadPhonebook, type ProvisionContext } from '../provisioning/context.ts';
import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts';
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
import { yealink, yealinkCommon, yealinkPhonebook } from '../provisioning/templates/yealink.ts';
import { getSetting } from '../services/settings.ts';
import { NO_LIMIT } from '../auth/guards.ts';
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
const FILES: {
re: RegExp;
/** null: the phonebook, in the device's own vendor's format. */
vendor: ProvisionContext['vendor'] | null;
type: string;
}[] = [
{ re: /^([0-9a-f]{12})\.cfg$/, vendor: 'yealink', type: 'text/plain' },
{ re: /^cfg([0-9a-f]{12})\.xml$/, vendor: 'grandstream', type: 'application/xml' },
// Yealink: phonebook/<mac>.xml; Grandstream appends /phonebook.xml to the path it's given.
{
re: /^phonebook\/([0-9a-f]{12})(?:\.xml|\/phonebook\.xml)$/,
vendor: null,
type: 'application/xml',
},
];
const YEALINK_COMMON = /^y0{10}[0-9a-f]{2}\.cfg$/;
/** The Basic auth credentials, or null when there are none. */
function basicAuth(req: FastifyRequest): { user: string; pass: string } | null {
const m = /^Basic ([A-Za-z0-9+/=]+)$/.exec(req.headers.authorization ?? '');
if (!m) return null;
const s = Buffer.from(m[1]!, 'base64').toString('utf8');
const i = s.indexOf(':');
return i < 0 ? null : { user: s.slice(0, i), pass: s.slice(i + 1) };
}
const same = (a: string, b: string) => {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
};
export async function provisioningRoutes(app: FastifyInstance) {
const { db } = app;
const ipv4 = (ip: string) => ip.replace(/^::ffff:/, '');
async function isLocal(ip: string) {
const v4 = ipv4(ip);
if (!isIPv4(v4)) return false;
const nets = new BlockList();
for (const n of await getSetting(db, 'network.localNets')) {
const [addr, bits] = n.split('/');
nets.addSubnet(addr!, Number(bits), 'ipv4');
}
return nets.check(v4, 'ipv4');
}
/** The config, or (phonebook) the directory in the phone's vendor's format. */
async function render(c: ProvisionContext, phonebook = false): Promise<string> {
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
const entries = await loadPhonebook(db, c.ownerUserId);
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
}
const deny = (reply: FastifyReply, code: number, text: string) =>
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
app.get('/prov/*', { config: NO_LIMIT }, async (req, reply) => {
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
const local = await isLocal(req.ip);
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
return deny(reply, 403, 'Forbidden');
if (!allowProvRequest(req.ip, local)) return deny(reply, 429, 'Too many requests');
const raw = (req.params as { '*': string })['*'];
// Linphone (07 §5): the token is the credential (case matters), no Basic auth.
const lin = /^linphone\/([A-Za-z0-9_-]{20,200})\.xml$/.exec(raw);
if (lin) {
const extId = verifyLinphoneToken(app.config.APP_SECRET, lin[1]!);
const [e] = extId ? await db.select().from(extensions).where(eq(extensions.id, extId)) : [];
if (!e) {
recordProvFailure(req.ip);
req.log.warn({ ip: req.ip }, 'provisioning: bad or expired Linphone token');
return deny(reply, 404, 'Not found');
}
req.log.info({ ip: req.ip, ext: e.number }, 'provisioning: Linphone config served');
return reply
.header('Cache-Control', 'no-store')
.type('application/xml')
.send(
linphoneXml({
host: app.config.PUBLIC_HOSTNAME,
sipPort: await getSetting(db, 'sip.udpPort'),
transport: 'udp',
number: e.number,
name: e.name,
sipPassword: decrypt(e.sipPasswordEnc),
}),
);
}
const file = raw.toLowerCase();
if (YEALINK_COMMON.test(file))
return reply.header('Cache-Control', 'no-store').type('text/plain').send(yealinkCommon());
const kind = FILES.map((f) => ({ f, m: f.re.exec(file) })).find((x) => x.m);
if (!kind) return deny(reply, 404, 'Not found');
const mac = kind.m![1]!;
const loaded = await loadContext(db, app.config, mac);
const found =
loaded &&
loaded.ctx.vendor !== 'generic' &&
(!kind.f.vendor || loaded.ctx.vendor === kind.f.vendor)
? loaded
: null;
const auth = basicAuth(req);
const ok =
found !== null &&
auth !== null &&
auth.user.toLowerCase() === mac &&
same(auth.pass, found.ctx.provPassword);
if (!ok) {
// An unknown MAC gets the same 401 as a known one, so the answers don't tell which MACs
// exist (08 §6). A phone asks without credentials first and retries: not a failure.
if (auth) {
recordProvFailure(req.ip);
req.log.warn(
{ ip: req.ip, mac },
found ? 'provisioning: wrong credentials' : 'provisioning: unknown MAC',
);
}
return reply
.code(401)
.header('WWW-Authenticate', 'Basic realm="selfphoneng"')
.header('Cache-Control', 'no-store')
.type('text/plain')
.send('Unauthorized');
}
await db
.update(devices)
.set({ lastProvisionedAt: sql`now()`, lastProvisionedIp: ipv4(req.ip) })
.where(eq(devices.id, found.id));
req.log.info({ ip: req.ip, mac }, 'provisioning: served');
return reply
.header('Cache-Control', 'no-store')
.type(kind.f.type)
.send(await render(found.ctx, kind.f.vendor === null));
});
}