mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
161 lines
7.1 KiB
TypeScript
161 lines
7.1 KiB
TypeScript
/**
|
|
* webauthn-config.service.test.ts
|
|
*
|
|
* The RP-ID / allowed-origin resolver is the single highest-risk piece of the
|
|
* passkey feature: a wrong RP ID permanently bricks every enrolled credential.
|
|
* These tests pin the security-relevant rules — config wins over APP_URL, bare
|
|
* IPs are rejected, localhost dev uses the browser (Vite) origin, and the
|
|
* resolver NEVER reads request headers.
|
|
*/
|
|
|
|
// The real getAppUrl() NEVER returns an empty string — with nothing configured
|
|
// it falls back to `http://localhost:{PORT}` (app-url.ts). The mock default
|
|
// mirrors that; a '' default here once hid the #2147 phantom-config bug.
|
|
const { settingsStore, appUrlRef } = vi.hoisted(() => ({
|
|
settingsStore: new Map<string, string>(),
|
|
appUrlRef: { value: 'http://localhost:3001' },
|
|
}));
|
|
|
|
vi.mock('../../../src/app-config', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/app-config')>();
|
|
return { ...actual, getAppUrl: () => appUrlRef.value };
|
|
});
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
import { WebauthnConfigService, originWithinRpScope } from '../../../src/nest/auth/webauthn-config.service';
|
|
|
|
// Injected instead of vi.mocking src/db/database: the service reads exactly one
|
|
// row shape (app_settings.value), so a stub connection keeps the suite free of
|
|
// a schema and states the only DB contact the resolver has.
|
|
const conn = {
|
|
prepare: (_sql: string) => ({
|
|
get: (key: string) => {
|
|
const v = settingsStore.get(key);
|
|
return v === undefined ? undefined : { value: v };
|
|
},
|
|
}),
|
|
} as unknown as ConstructorParameters<typeof DatabaseService>[0];
|
|
|
|
const svc = new WebauthnConfigService(new DatabaseService(conn));
|
|
|
|
beforeEach(() => {
|
|
settingsStore.clear();
|
|
appUrlRef.value = 'http://localhost:3001';
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('WebauthnConfigService.resolve', () => {
|
|
it('WAC-001: derives the RP ID and single origin from a real APP_URL domain', () => {
|
|
appUrlRef.value = 'https://trek.example.org';
|
|
const cfg = svc.resolve();
|
|
expect(cfg).not.toBeNull();
|
|
expect(cfg!.rpID).toBe('trek.example.org');
|
|
expect(cfg!.origins).toEqual(['https://trek.example.org']);
|
|
expect(cfg!.explicitOrigins).toBe(false); // derived, not operator-supplied
|
|
expect(svc.isConfigured()).toBe(true);
|
|
});
|
|
|
|
it('WAC-002: returns null for a bare-IP host (IPs are not valid RP IDs)', () => {
|
|
appUrlRef.value = 'http://192.168.1.50:3001';
|
|
expect(svc.resolve()).toBeNull();
|
|
expect(svc.isConfigured()).toBe(false);
|
|
});
|
|
|
|
it('WAC-003: nothing configured resolves to the localhost dev fallback (#2147)', () => {
|
|
// getAppUrl() never yields '' — an unconfigured instance lands on
|
|
// http://localhost:{PORT}, so resolve() returns the localhost dev config.
|
|
// The remote-origin guard for that phantom config lives in PasskeyService.
|
|
const cfg = svc.resolve();
|
|
expect(cfg!.rpID).toBe('localhost');
|
|
expect(cfg!.explicitOrigins).toBe(false);
|
|
expect(svc.isConfigured()).toBe(true);
|
|
});
|
|
|
|
it('WAC-004: localhost dev uses the browser (Vite :5173) origin, not just the API port', () => {
|
|
appUrlRef.value = 'http://localhost:3001';
|
|
const cfg = svc.resolve();
|
|
expect(cfg!.rpID).toBe('localhost');
|
|
expect(cfg!.origins).toContain('http://localhost:5173');
|
|
expect(cfg!.origins).toContain('http://localhost:3001');
|
|
});
|
|
|
|
it('WAC-005: an explicit webauthn_rp_id app-setting overrides APP_URL', () => {
|
|
appUrlRef.value = 'https://internal.example.org';
|
|
settingsStore.set('webauthn_rp_id', 'public.example.org');
|
|
settingsStore.set('webauthn_origins', 'https://public.example.org');
|
|
const cfg = svc.resolve();
|
|
expect(cfg!.rpID).toBe('public.example.org');
|
|
expect(cfg!.origins).toEqual(['https://public.example.org']);
|
|
expect(cfg!.explicitOrigins).toBe(true);
|
|
});
|
|
|
|
it('WAC-006: webauthn_origins is parsed as a comma-separated, trimmed list', () => {
|
|
settingsStore.set('webauthn_rp_id', 'example.org');
|
|
settingsStore.set('webauthn_origins', 'https://a.example.org , https://b.example.org/');
|
|
const cfg = svc.resolve();
|
|
expect(cfg!.origins).toEqual(['https://a.example.org', 'https://b.example.org']);
|
|
});
|
|
|
|
it('WAC-007: the WEBAUTHN_RP_ID env var takes priority', () => {
|
|
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
|
|
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
|
|
appUrlRef.value = 'https://ignored.example.org';
|
|
const cfg = svc.resolve();
|
|
expect(cfg!.rpID).toBe('env.example.org');
|
|
expect(cfg!.origins).toEqual(['https://env.example.org']);
|
|
});
|
|
|
|
it('WAC-008: a configured RP ID with no origins falls back to the APP_URL origin', () => {
|
|
appUrlRef.value = 'https://trek.example.org';
|
|
settingsStore.set('webauthn_rp_id', 'trek.example.org');
|
|
const cfg = svc.resolve();
|
|
expect(cfg!.origins).toEqual(['https://trek.example.org']);
|
|
expect(cfg!.explicitOrigins).toBe(false); // the origin list itself is still derived
|
|
});
|
|
|
|
it('WAC-009: an operator flipping the RP ID at runtime takes effect on the next resolve', () => {
|
|
appUrlRef.value = 'https://trek.example.org';
|
|
expect(svc.resolve()!.rpID).toBe('trek.example.org');
|
|
|
|
settingsStore.set('webauthn_rp_id', 'moved.example.org');
|
|
|
|
expect(svc.resolve()!.rpID).toBe('moved.example.org');
|
|
});
|
|
|
|
it('WAC-010: the WEBAUTHN_ORIGINS env var also marks the origin list explicit', () => {
|
|
vi.stubEnv('WEBAUTHN_RP_ID', 'env.example.org');
|
|
vi.stubEnv('WEBAUTHN_ORIGINS', 'https://env.example.org');
|
|
expect(svc.resolve()!.explicitOrigins).toBe(true);
|
|
});
|
|
});
|
|
|
|
// The scope rule mirrors what the browser enforces for a ceremony: https-only
|
|
// (http for loopback dev), hostname equal to the RP ID or a subdomain of it.
|
|
// Anything laxer would weaken origin binding, so each edge is pinned.
|
|
describe('originWithinRpScope', () => {
|
|
it('WAC-011: accepts the RP domain and its subdomains over https', () => {
|
|
expect(originWithinRpScope('https://trek.example.org', 'trek.example.org')).toBe(true);
|
|
expect(originWithinRpScope('https://app.trek.example.org', 'trek.example.org')).toBe(true);
|
|
expect(originWithinRpScope('https://trek.example.org:8443', 'trek.example.org')).toBe(true);
|
|
});
|
|
|
|
it('WAC-012: rejects http on a non-localhost host, but allows loopback dev', () => {
|
|
expect(originWithinRpScope('http://trek.example.org', 'trek.example.org')).toBe(false);
|
|
expect(originWithinRpScope('http://localhost:5173', 'localhost')).toBe(true);
|
|
expect(originWithinRpScope('http://127.0.0.1:3001', '127.0.0.1')).toBe(true);
|
|
});
|
|
|
|
it('WAC-013: rejects foreign hosts, suffix tricks and garbage', () => {
|
|
expect(originWithinRpScope('https://evil.example.net', 'trek.example.org')).toBe(false);
|
|
// "eviltrek.example.org" is NOT a subdomain of "trek.example.org".
|
|
expect(originWithinRpScope('https://eviltrek.example.org', 'trek.example.org')).toBe(false);
|
|
expect(originWithinRpScope('https://trip.example.org', 'localhost')).toBe(false);
|
|
expect(originWithinRpScope('null', 'trek.example.org')).toBe(false);
|
|
expect(originWithinRpScope('ftp://trek.example.org', 'trek.example.org')).toBe(false);
|
|
});
|
|
});
|