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
176 lines
7.5 KiB
TypeScript
176 lines
7.5 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// M1-T3 acceptance (09-roadmap) plus the auth rules in 04 §2.
|
|
import { desc, eq } from 'drizzle-orm';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { auditLog, users } from '../src/db/schema.ts';
|
|
import { requireAdmin } from '../src/auth/guards.ts';
|
|
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
|
|
import { hasTestDb } from './db.ts';
|
|
import { ADMIN, H, createTestApp } from './app.ts';
|
|
|
|
describe.skipIf(!hasTestDb)('auth', () => {
|
|
let t: Awaited<ReturnType<typeof createTestApp>>;
|
|
let adminPassword = ADMIN.password;
|
|
beforeAll(async () => {
|
|
// A stand-in admin route until the real admin routes exist.
|
|
t = await createTestApp((app) =>
|
|
app.get('/api/admin-probe', { preHandler: requireAdmin }, async () => ({ ok: true })),
|
|
);
|
|
});
|
|
afterAll(() => t?.close());
|
|
beforeEach(() => resetLoginLimiter());
|
|
|
|
const login = (email: string, password: string, headers: Record<string, string> = H) =>
|
|
t.app.inject({ method: 'POST', url: '/api/auth/login', headers, payload: { email, password } });
|
|
const cookieOf = (r: Awaited<ReturnType<typeof login>>) =>
|
|
`sp_session=${r.cookies.find((c) => c.name === 'sp_session')!.value}`;
|
|
const get = (url: string, cookie?: string) =>
|
|
t.app.inject({ url, headers: cookie ? { cookie } : {} });
|
|
|
|
it('good login sets an HttpOnly, Secure, SameSite=Lax session cookie', async () => {
|
|
const r = await login(ADMIN.email, adminPassword);
|
|
expect(r.statusCode).toBe(200);
|
|
expect(r.cookies.find((x) => x.name === 'sp_session')).toMatchObject({
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'Lax',
|
|
path: '/',
|
|
maxAge: 1209600,
|
|
});
|
|
expect(r.json().user).toMatchObject({
|
|
email: ADMIN.email,
|
|
role: 'admin',
|
|
mustChangePassword: true,
|
|
});
|
|
});
|
|
|
|
it('emails match case-insensitively', async () => {
|
|
expect((await login(ADMIN.email.toUpperCase(), adminPassword)).statusCode).toBe(200);
|
|
});
|
|
|
|
it('bad password → 401 and an audited login_failed with the IP', async () => {
|
|
const r = await login(ADMIN.email, 'wrong-password');
|
|
expect(r.statusCode).toBe(401);
|
|
expect(r.json().error.code).toBe('unauthorized');
|
|
const [row] = await t.db.select().from(auditLog).orderBy(desc(auditLog.id)).limit(1);
|
|
expect(row).toMatchObject({ action: 'login_failed', actorIp: '127.0.0.1' });
|
|
expect(JSON.stringify(row)).not.toContain('wrong-password');
|
|
});
|
|
|
|
it('unknown email → the same 401', async () => {
|
|
expect((await login('nobody@example.com', 'whatever-pw')).statusCode).toBe(401);
|
|
});
|
|
|
|
it('6th failure from one IP → 429, even with the right password', async () => {
|
|
for (let i = 0; i < 5; i++) expect((await login(ADMIN.email, `bad-${i}`)).statusCode).toBe(401);
|
|
expect((await login(ADMIN.email, 'bad-6')).statusCode).toBe(429);
|
|
expect((await login(ADMIN.email, adminPassword)).statusCode).toBe(429);
|
|
});
|
|
|
|
it('POST without X-Requested-With → 403', async () => {
|
|
const r = await login(ADMIN.email, adminPassword, {});
|
|
expect(r.statusCode).toBe(403);
|
|
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);
|
|
expect(blocked.statusCode).toBe(403);
|
|
expect(blocked.json().error.code).toBe('password_change_required');
|
|
expect((await get('/api/auth/me', cookie)).statusCode).toBe(200);
|
|
|
|
const change = (newPassword: string) =>
|
|
t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/change-password',
|
|
headers: { ...H, cookie },
|
|
payload: { currentPassword: adminPassword, newPassword },
|
|
});
|
|
expect((await change('short')).statusCode).toBe(400);
|
|
expect((await change('qwertyuiop')).json().error.message).toMatch(/too common/);
|
|
expect((await change('a-much-better-passphrase')).statusCode).toBe(200);
|
|
adminPassword = 'a-much-better-passphrase';
|
|
expect((await get('/api/admin-probe', cookie)).statusCode).toBe(200);
|
|
});
|
|
|
|
it('changing the password logs out other sessions', async () => {
|
|
const a = cookieOf(await login(ADMIN.email, adminPassword));
|
|
const b = cookieOf(await login(ADMIN.email, adminPassword));
|
|
const r = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/change-password',
|
|
headers: { ...H, cookie: a },
|
|
payload: { currentPassword: adminPassword, newPassword: 'another-good-passphrase' },
|
|
});
|
|
expect(r.statusCode).toBe(200);
|
|
adminPassword = 'another-good-passphrase';
|
|
expect((await get('/api/auth/me', a)).statusCode).toBe(200);
|
|
expect((await get('/api/auth/me', b)).statusCode).toBe(401);
|
|
});
|
|
|
|
it('non-admin hitting an admin route → 403', async () => {
|
|
await t.addUser('user@example.com', 'user-password-123');
|
|
const cookie = cookieOf(await login('user@example.com', 'user-password-123'));
|
|
const r = await get('/api/admin-probe', cookie);
|
|
expect(r.statusCode).toBe(403);
|
|
expect(r.json().error.code).toBe('forbidden');
|
|
});
|
|
|
|
it('no cookie → 401; logout ends the session', async () => {
|
|
expect((await get('/api/auth/me')).statusCode).toBe(401);
|
|
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
|
|
const out = await t.app.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/logout',
|
|
headers: { ...H, cookie },
|
|
});
|
|
expect(out.statusCode).toBe(200);
|
|
expect((await get('/api/auth/me', cookie)).statusCode).toBe(401);
|
|
});
|
|
|
|
it('inactive users cannot log in', async () => {
|
|
const u = await t.addUser('gone@example.com', 'gone-password-123');
|
|
await t.db.update(users).set({ isActive: false }).where(eq(users.id, u.id));
|
|
expect((await login('gone@example.com', 'gone-password-123')).statusCode).toBe(401);
|
|
});
|
|
|
|
it('every login event is audited', async () => {
|
|
const actions = (await t.db.select({ a: auditLog.action }).from(auditLog)).map((r) => r.a);
|
|
expect(actions).toEqual(
|
|
expect.arrayContaining(['create', 'login', 'login_failed', 'logout', 'password_changed']),
|
|
);
|
|
});
|
|
|
|
it('healthz checks the DB; the SPA fallback skips /api and /prov', async () => {
|
|
expect((await get('/healthz')).json()).toEqual({ ok: true });
|
|
expect((await get('/admin/users')).body).toBe('<h1>spa</h1>');
|
|
expect((await get('/api/nope')).statusCode).toBe(404);
|
|
expect((await get('/prov/x.cfg')).statusCode).toBe(404);
|
|
});
|
|
|
|
it('M11-T1: security headers on every response (08 §2.4)', async () => {
|
|
for (const url of ['/admin/users', '/api/nope', '/healthz']) {
|
|
const h = (await get(url)).headers;
|
|
expect(h['content-security-policy'], url).toBe(
|
|
"default-src 'self'; connect-src 'self' wss://pbx.test; media-src 'self' blob:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
|
|
);
|
|
expect(h['x-content-type-options']).toBe('nosniff');
|
|
expect(h['referrer-policy']).toBe('same-origin');
|
|
expect(h['permissions-policy']).toBe('microphone=(self), camera=()');
|
|
}
|
|
});
|
|
});
|