Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
8 changes: 8 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!'
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/config/csrf-origin.ts
Original file line number Diff line number Diff line change
@@ -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;
}
135 changes: 135 additions & 0 deletions src/csrf.test.ts
Original file line number Diff line number Diff line change
@@ -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<FastifyInstance> => {
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();
});
});
99 changes: 99 additions & 0 deletions src/csrf.ts
Original file line number Diff line number Diff line change
@@ -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' });
};
}
Loading