diff --git a/.env.example b/.env.example index 1d7608d..c6e4058 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,14 @@ ICE_SERVERS= # restricted to a specific origin. CORS_ORIGIN=http://localhost:5173 +# Comma-separated allowlist of trusted origins for CSRF protection. When set, +# state-mutating requests (POST/PUT/PATCH/DELETE) that carry an Origin (or, if +# absent, Referer) header whose origin is NOT in this list are rejected with +# 403. Requests with no Origin/Referer header (native/server-to-server clients) +# are always allowed. Optional; when unset/empty the check is a no-op and +# nothing is blocked. Example: CSRF_TRUSTED_ORIGINS=https://app.example.com +CSRF_TRUSTED_ORIGINS= + # Shared auth key required to publish WHIP ingest streams. Optional; if set, # WHIP clients must present it. WHIP_AUTH_KEY= diff --git a/readme.md b/readme.md index c20710f..8cdc41d 100644 --- a/readme.md +++ b/readme.md @@ -69,6 +69,7 @@ The `osc_eyevinn_intercom_manager` resource requires these variables: | `PUBLIC_HOST` | Hostname for frontend application for generating URLs to share (default: `http://localhost:8000`) | | `CORS_ORIGIN` | Comma-separated list of allowed CORS origins, e.g. `http://localhost:5173,http://localhost:5174`. When unset (or empty) the manager falls back to `OSC_HOSTNAME` (auto-injected on Open Source Cloud): a bare hostname is prefixed with `https://` and a full URL has any trailing slash stripped, yielding the instance's own origin. Startup fails only when NEITHER `CORS_ORIGIN` nor `OSC_HOSTNAME` is set. Needed for local development when the frontend runs on a different port | | `WHIP_AUTH_KEY` | When set, WHIP and WHEP endpoints require a `Bearer` token matching this key (optional) | +| `CSRF_TRUSTED_ORIGINS` | Comma-separated allowlist of trusted origins for CSRF protection. When set, state-mutating requests (POST/PUT/PATCH/DELETE) carrying an `Origin` (or, if absent, `Referer`) header whose origin is not in this list are rejected with `403`. Requests with no `Origin`/`Referer` header (native/server-to-server clients) are always allowed. WHIP/WHEP routes are exempt. When unset/empty the check is a no-op (nothing is blocked). Example: `https://app.example.com` (optional) | | `ENDPOINT_IDLE_TIMEOUT_S` | Idle timeout in seconds for SMB endpoints (default: `60`) | | `REAUTH_AUTH_KEY` | When set, the `GET /api/v1/reauth` endpoint requires a `Bearer` token matching this key. Defaults to `WHIP_AUTH_KEY` when unset; when neither is set the endpoint is unauthenticated (optional, but strongly recommended whenever `OSC_ACCESS_TOKEN` is set) | | `OSC_ACCESS_TOKEN` | Personal Access Token from OSC for link sharing and reauthenticating (optional) | diff --git a/src/api.ts b/src/api.ts index 38b0705..bbf976e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,6 +18,7 @@ import { DbManager } from './db/interface'; import { IngestManager } from './ingest_manager'; import { ProductionManager } from './production_manager'; import { resolveCorsOrigin } from './config/cors-origin'; +import { createCsrfOriginHook } from './csrf'; const HelloWorld = Type.String({ description: 'The magical words!' @@ -121,6 +122,13 @@ export default async (opts: ApiOptions) => { global: false // Only apply to specific routes }); + // CSRF protection: verify the Origin/Referer header of state-mutating + // requests against the CSRF_TRUSTED_ORIGINS allowlist (see src/csrf.ts). + // Non-breaking: a no-op when CSRF_TRUSTED_ORIGINS is unset, and requests with + // no Origin/Referer header are always allowed through. Registered on the root + // instance so it applies to all API routes. + api.addHook('onRequest', createCsrfOriginHook()); + // Gate the Swagger/OpenAPI docs so the full API surface is not exposed // unauthenticated in production. The docs are registered when NOT running in // production, or when explicitly opted in via ENABLE_SWAGGER=true (an escape diff --git a/src/config/csrf-origin.ts b/src/config/csrf-origin.ts new file mode 100644 index 0000000..b2526cc --- /dev/null +++ b/src/config/csrf-origin.ts @@ -0,0 +1,32 @@ +// Resolver for the allowlist of trusted origins used by the CSRF +// Origin/Referer check (see src/csrf.ts). The allowlist is configured via the +// CSRF_TRUSTED_ORIGINS environment variable (comma-separated). When the +// variable is unset/empty the check is a no-op and every request is allowed, +// so existing deployments are unaffected by default. + +/** + * Normalize an origin for comparison: trim whitespace and strip any trailing + * slashes so that `https://example.com/` and `https://example.com` are equal. + */ +export function normalizeOrigin(origin: string): string { + return origin.trim().replace(/\/+$/, ''); +} + +/** + * Resolve the allowlist of trusted origins from CSRF_TRUSTED_ORIGINS. + * + * @returns an array of normalized, non-empty origins, or null when + * CSRF_TRUSTED_ORIGINS is unset/empty/whitespace (i.e. the CSRF check should + * be a no-op). + */ +export function resolveTrustedOrigins(): string[] | null { + const raw = process.env.CSRF_TRUSTED_ORIGINS; + if (!raw || !raw.trim()) { + return null; + } + const origins = raw + .split(',') + .map((o) => normalizeOrigin(o)) + .filter((o) => o.length > 0); + return origins.length > 0 ? origins : null; +} diff --git a/src/csrf.test.ts b/src/csrf.test.ts new file mode 100644 index 0000000..b95f3f4 --- /dev/null +++ b/src/csrf.test.ts @@ -0,0 +1,135 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import { createCsrfOriginHook } from './csrf'; + +jest.mock('./log', () => ({ + Log: () => ({ + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn() + }) +})); + +// Build a minimal app with the CSRF onRequest hook registered on the root +// instance (mirroring how api.ts wires it) plus a couple of routes covering a +// state-mutating method and a safe method. The hook resolves the allowlist from +// the environment when it is created, so each test sets CSRF_TRUSTED_ORIGINS +// before building the app. +const buildApp = async (): Promise => { + const app = Fastify(); + app.addHook('onRequest', createCsrfOriginHook()); + app.post('/api/v1/productions', async () => ({ ok: true })); + app.delete('/api/v1/productions/1', async () => ({ ok: true })); + app.get('/api/v1/productions', async () => ({ ok: true })); + app.post('/api/v1/whip/abc', async () => ({ ok: true })); + await app.ready(); + return app; +}; + +describe('CSRF Origin/Referer verification hook', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + it('allows a state-mutating request with no Origin/Referer header', async () => { + process.env.CSRF_TRUSTED_ORIGINS = 'https://app.example.com'; + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/productions' + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); + + it('allows a request whose Origin is in the allowlist', async () => { + process.env.CSRF_TRUSTED_ORIGINS = + 'https://app.example.com,https://other.example.com'; + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/productions', + headers: { origin: 'https://app.example.com' } + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); + + it('allows a request whose Origin (with trailing slash) matches after normalization', async () => { + process.env.CSRF_TRUSTED_ORIGINS = 'https://app.example.com/'; + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/productions', + headers: { origin: 'https://app.example.com' } + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); + + it('rejects a state-mutating request whose Origin is not in the allowlist', async () => { + process.env.CSRF_TRUSTED_ORIGINS = 'https://app.example.com'; + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/productions', + headers: { origin: 'https://evil.example.com' } + }); + expect(res.statusCode).toBe(403); + await app.close(); + }); + + it('falls back to the Referer origin and rejects a disallowed one', async () => { + process.env.CSRF_TRUSTED_ORIGINS = 'https://app.example.com'; + const app = await buildApp(); + const res = await app.inject({ + method: 'DELETE', + url: '/api/v1/productions/1', + headers: { referer: 'https://evil.example.com/some/path' } + }); + expect(res.statusCode).toBe(403); + await app.close(); + }); + + it('never blocks GET requests, even from a disallowed origin', async () => { + process.env.CSRF_TRUSTED_ORIGINS = 'https://app.example.com'; + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/v1/productions', + headers: { origin: 'https://evil.example.com' } + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); + + it('is a no-op when CSRF_TRUSTED_ORIGINS is unset (allows any origin)', async () => { + delete process.env.CSRF_TRUSTED_ORIGINS; + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/productions', + headers: { origin: 'https://evil.example.com' } + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); + + it('exempts WHIP routes from the Origin check', async () => { + process.env.CSRF_TRUSTED_ORIGINS = 'https://app.example.com'; + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/v1/whip/abc', + headers: { origin: 'https://evil.example.com' } + }); + expect(res.statusCode).toBe(200); + await app.close(); + }); +}); diff --git a/src/csrf.ts b/src/csrf.ts new file mode 100644 index 0000000..2139182 --- /dev/null +++ b/src/csrf.ts @@ -0,0 +1,99 @@ +// CSRF protection via Origin/Referer verification for state-mutating requests. +// +// This is a deliberately non-breaking, defence-in-depth check (see issue #224). +// For state-mutating methods (POST, PUT, PATCH, DELETE) the request's Origin +// header (falling back to the origin of the Referer header) is compared against +// a configured allowlist (CSRF_TRUSTED_ORIGINS). A request is rejected with 403 +// ONLY when it carries an Origin/Referer whose origin is not in the allowlist. +// +// Requests that carry no Origin/Referer header at all (non-browser/native API +// clients, server-to-server calls, tests) are always allowed through, and when +// CSRF_TRUSTED_ORIGINS is unset the check is a complete no-op. Together this +// keeps the check from breaking existing clients or deployments. +// +// This intentionally does NOT use cookies/sessions or @fastify/csrf-protection: +// cookie-based auth is still being decided separately and such a token model +// would conflict with it. + +import { FastifyRequest, onRequestHookHandler } from 'fastify'; +import { Log } from './log'; +import { normalizeOrigin, resolveTrustedOrigins } from './config/csrf-origin'; + +const STATE_MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +// WHIP/WHEP are media ingest/egress endpoints that are intentionally reachable +// cross-origin (they are served with a permissive `origin: '*'` CORS policy in +// api.ts). Applying an Origin allowlist to them would break legitimate +// cross-origin media clients, so they are exempt from the CSRF check, mirroring +// the CORS carve-out. +function isExemptPath(url: string): boolean { + const path = url.split('?')[0]; + return ( + path.startsWith('/api/v1/whip') || + path.startsWith('/api/v1/whep') || + path.startsWith('/whip') + ); +} + +/** + * Extract the origin of a request from its Origin header, falling back to the + * origin of the Referer header. Returns null when neither header is present or + * parseable, which signals the caller to allow the request through. + */ +function requestOrigin(request: FastifyRequest): string | null { + const origin = request.headers.origin; + if (typeof origin === 'string' && origin.trim()) { + return normalizeOrigin(origin); + } + + const referer = request.headers.referer; + if (typeof referer === 'string' && referer.trim()) { + try { + return normalizeOrigin(new URL(referer).origin); + } catch { + return null; + } + } + + return null; +} + +/** + * Create an `onRequest` hook that enforces Origin/Referer verification against + * the CSRF_TRUSTED_ORIGINS allowlist. The allowlist is resolved once, when the + * hook is created (i.e. when the app is built). + */ +export function createCsrfOriginHook(): onRequestHookHandler { + const trustedOrigins = resolveTrustedOrigins(); + + return async function csrfOriginHook(request, reply) { + // No allowlist configured → no-op (allow everything). + if (!trustedOrigins) { + return; + } + // Only guard state-mutating methods; safe methods (GET, HEAD, OPTIONS) are + // never blocked. + if (!STATE_MUTATING_METHODS.has(request.method)) { + return; + } + if (isExemptPath(request.url)) { + return; + } + + const origin = requestOrigin(request); + // No Origin/Referer header → allow (non-browser/native/server-to-server). + if (origin === null) { + return; + } + if (trustedOrigins.includes(origin)) { + return; + } + + Log().warn( + `CSRF: rejected ${request.method} ${request.url} from disallowed origin ${origin}` + ); + return reply + .code(403) + .send({ error: 'CSRF validation failed: origin not allowed' }); + }; +}