diff --git a/src/api_auth_guard.test.ts b/src/api_auth_guard.test.ts new file mode 100644 index 0000000..0e2f707 --- /dev/null +++ b/src/api_auth_guard.test.ts @@ -0,0 +1,128 @@ +jest.mock('./log', () => ({ + Log: () => ({ + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn() + }) +})); + +import api from './api'; +import { CoreFunctions } from './api_productions_core_functions'; +import { ConnectionQueue } from './connection_queue'; + +// Minimal manager mocks — requireApiKey rejects in the preHandler before any +// handler runs, so the managers only need to satisfy api() construction. +const mockDbManager = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + getProductions: jest.fn().mockResolvedValue([]), + getProductionsLength: jest.fn().mockResolvedValue(0) +} as any; + +const mockProductionManager = { + load: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + once: jest.fn(), + emit: jest.fn() +} as any; + +const mockIngestManager = { + load: jest.fn().mockResolvedValue(undefined), + startPolling: jest.fn() +} as any; + +const buildServer = () => + api({ + title: 'auth-guard-test', + smbServerBaseUrl: 'http://localhost', + endpointIdleTimeout: '60', + publicHost: 'http://localhost', + dbManager: mockDbManager, + productionManager: mockProductionManager, + ingestManager: mockIngestManager, + coreFunctions: new CoreFunctions( + mockProductionManager, + new ConnectionQueue() + ) + }); + +// Previously-unguarded production/session mutation endpoints that must now +// reject unauthenticated requests when API_KEY is set (#222). These requests +// carry no Origin/Referer header, so the CSRF hook lets them through. Each +// payload is schema-valid so the request clears body validation (which runs +// before preHandler in Fastify) and reaches the requireApiKey guard, proving +// it is the guard — not schema validation — that rejects with 401. +const guardedRoutes: { method: string; url: string; payload?: unknown }[] = [ + { + method: 'POST', + url: '/api/v1/production/1/line', + payload: { name: 'Line X' } + }, + { + method: 'PATCH', + url: '/api/v1/production/1/line/line-a', + payload: { name: 'Line X' } + }, + { method: 'DELETE', url: '/api/v1/production/1/line/line-a' }, + { + method: 'POST', + url: '/api/v1/production/1/line/line-a/participants/sess-1/disconnect' + }, + { + method: 'PATCH', + url: '/api/v1/session/sess-1', + payload: { sdpAnswer: 'v=0' } + } +]; + +describe('management endpoints require API key when API_KEY is set (#222)', () => { + const originalApiKey = process.env.API_KEY; + + beforeAll(() => { + process.env.API_KEY = 'secret-guard-test'; + }); + + afterAll(() => { + if (originalApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = originalApiKey; + } + }); + + it.each(guardedRoutes)( + 'rejects unauthenticated $method $url with 401', + async ({ method, url, payload }) => { + const server = await buildServer(); + + const response = await server.inject({ + method: method as any, + url, + payload: payload ?? {} + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'Unauthorized' }); + + await server.close(); + } + ); + + it('allows an authenticated request past the API key guard', async () => { + const server = await buildServer(); + + // With a valid Bearer token the requireApiKey guard passes; the request + // then fails downstream (no such production), i.e. it is no longer a 401. + const response = await server.inject({ + method: 'POST', + url: '/api/v1/production/1/line', + headers: { authorization: 'Bearer secret-guard-test' }, + payload: { name: 'Line X' } + }); + + expect(response.statusCode).not.toBe(401); + + await server.close(); + }); +}); diff --git a/src/api_groups.ts b/src/api_groups.ts index 8cdb999..89c98c9 100644 --- a/src/api_groups.ts +++ b/src/api_groups.ts @@ -2,6 +2,7 @@ import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { Type } from '@sinclair/typebox'; import { FastifyPluginCallback } from 'fastify'; import { DbManager } from './db/interface'; +import { requireApiKey } from './auth'; import { ErrorResponse, NewPreset, @@ -36,6 +37,7 @@ const apiGroups: FastifyPluginCallback = ( instance.get( '/preset', { + preHandler: requireApiKey, schema: { response: { 200: PresetListResponse @@ -51,6 +53,7 @@ const apiGroups: FastifyPluginCallback = ( instance.post( '/preset', { + preHandler: requireApiKey, schema: { body: NewPreset, response: { @@ -79,6 +82,7 @@ const apiGroups: FastifyPluginCallback = ( instance.get( '/preset/:id', { + preHandler: requireApiKey, schema: { params: Type.Object({ id: Type.String({ maxLength: 128 }) }), response: { @@ -98,6 +102,7 @@ const apiGroups: FastifyPluginCallback = ( instance.patch( '/preset/:id', { + preHandler: requireApiKey, schema: { params: Type.Object({ id: Type.String({ maxLength: 128 }) }), body: UpdatePreset, @@ -142,6 +147,7 @@ const apiGroups: FastifyPluginCallback = ( instance.delete( '/preset/:id', { + preHandler: requireApiKey, schema: { params: Type.Object({ id: Type.String({ maxLength: 128 }) }), response: { diff --git a/src/api_ingests.ts b/src/api_ingests.ts index e4b38dc..6059d7a 100644 --- a/src/api_ingests.ts +++ b/src/api_ingests.ts @@ -11,6 +11,7 @@ import { import { IngestManager } from './ingest_manager'; import { Log } from './log'; import { DbManager } from './db/interface'; +import { requireApiKey } from './auth'; export interface ApiIngestsOptions { dbManager: DbManager; @@ -35,6 +36,11 @@ const apiIngests: FastifyPluginCallback = ( }>( '/ingest', { + // NOTE: this route-level guard is currently unreachable — the global + // preHandler hook above 501-gates every ingest route before it runs. It + // is kept (rather than removed) so the guard is already in place if the + // Ingest API is ever enabled. + preHandler: requireApiKey, schema: { description: 'Create a new Ingest. The device data will be fetched from the specified IP address.', diff --git a/src/api_productions.ts b/src/api_productions.ts index a74764a..97a6420 100644 --- a/src/api_productions.ts +++ b/src/api_productions.ts @@ -25,6 +25,7 @@ import { } from './models'; import { ProductionManager } from './production_manager'; import { ISmbProtocol, SmbProtocol } from './smb'; +import { requireApiKey } from './auth'; export interface ApiProductionsOptions { smbServerBaseUrl: string; @@ -130,6 +131,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/production', { + preHandler: requireApiKey, schema: { description: 'Create a new Production.', body: NewProduction, @@ -355,6 +357,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/production/:productionId', { + preHandler: requireApiKey, schema: { description: 'Modify an existing Production line.', params: ProductionIdParams, @@ -467,6 +470,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/production/:productionId/line', { + preHandler: requireApiKey, schema: { description: 'Add a new Line to a Production.', params: ProductionIdParams, @@ -585,6 +589,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/production/:productionId/line/:lineId', { + preHandler: requireApiKey, schema: { description: 'Modify an existing Production line.', params: ProductionLineParams, @@ -653,6 +658,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/production/:productionId/line/:lineId', { + preHandler: requireApiKey, schema: { description: 'Removes a line from a production.', params: ProductionLineParams, @@ -703,6 +709,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/session', { + preHandler: requireApiKey, schema: { description: 'Initiate connection protocol. Generates sdp offer describing remote SMB instance.', @@ -815,6 +822,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/session/:sessionId', { + preHandler: requireApiKey, schema: { description: 'Provide client local SDP description as request body to finalize connection protocol.', @@ -903,6 +911,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/production/:productionId', { + preHandler: requireApiKey, schema: { description: 'Deletes a Production.', params: ProductionIdParams, @@ -946,6 +955,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/session/:sessionId', { + preHandler: requireApiKey, schema: { description: 'Deletes a Connection from ProductionManager.', params: SessionIdParams, @@ -1063,6 +1073,7 @@ const apiProductions: FastifyPluginCallback = ( }>( '/production/:productionId/line/:lineId/participants/:sessionId/disconnect', { + preHandler: requireApiKey, schema: { description: 'Force-disconnect a participant from a line by backend session id. ' + diff --git a/src/auth.test.ts b/src/auth.test.ts new file mode 100644 index 0000000..21a7b84 --- /dev/null +++ b/src/auth.test.ts @@ -0,0 +1,131 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import { requireApiKey } from './auth'; + +// Build a minimal Fastify instance with a single route guarded by the +// requireApiKey preHandler so the hook can be exercised end-to-end via inject. +const createServer = async (): Promise => { + const app = Fastify(); + app.post('/protected', { preHandler: requireApiKey }, async (_req, reply) => { + reply.code(200).send({ ok: true }); + }); + await app.ready(); + return app; +}; + +describe('requireApiKey preHandler (#222)', () => { + const originalApiKey = process.env.API_KEY; + + afterEach(() => { + if (originalApiKey === undefined) { + delete process.env.API_KEY; + } else { + process.env.API_KEY = originalApiKey; + } + }); + + test('allows the request through when API_KEY is unset (auth disabled)', async () => { + delete process.env.API_KEY; + const server = await createServer(); + + const response = await server.inject({ + method: 'POST', + url: '/protected' + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + + await server.close(); + }); + + test('allows the request through when API_KEY is empty/whitespace', async () => { + process.env.API_KEY = ' '; + const server = await createServer(); + + const response = await server.inject({ + method: 'POST', + url: '/protected' + }); + + expect(response.statusCode).toBe(200); + + await server.close(); + }); + + test('returns 401 when the Authorization header is missing', async () => { + process.env.API_KEY = 'secret-123'; + const server = await createServer(); + + const response = await server.inject({ + method: 'POST', + url: '/protected' + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'Unauthorized' }); + + await server.close(); + }); + + test('returns 401 with a wrong bearer token', async () => { + process.env.API_KEY = 'secret-123'; + const server = await createServer(); + + const response = await server.inject({ + method: 'POST', + url: '/protected', + headers: { authorization: 'Bearer wrong-key' } + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ error: 'Unauthorized' }); + + await server.close(); + }); + + test('returns 401 with a malformed authorization header (no Bearer prefix)', async () => { + process.env.API_KEY = 'secret-123'; + const server = await createServer(); + + const response = await server.inject({ + method: 'POST', + url: '/protected', + headers: { authorization: 'secret-123' } + }); + + expect(response.statusCode).toBe(401); + + await server.close(); + }); + + test('returns 401 when the token is a proper prefix of the key', async () => { + process.env.API_KEY = 'secret-123'; + const server = await createServer(); + + const response = await server.inject({ + method: 'POST', + url: '/protected', + headers: { authorization: 'Bearer secret-12' } + }); + + expect(response.statusCode).toBe(401); + + await server.close(); + }); + + test('passes the request through with a correct Bearer token', async () => { + process.env.API_KEY = 'secret-123'; + const server = await createServer(); + + const response = await server.inject({ + method: 'POST', + url: '/protected', + headers: { authorization: 'Bearer secret-123' } + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ok: true }); + + await server.close(); + }); +}); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..147f96c --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,48 @@ +import { timingSafeEqual } from 'crypto'; +import { preHandlerHookHandler } from 'fastify'; + +/** + * Fastify `preHandler` hook that guards management endpoints with a static API + * key read from the `API_KEY` environment variable. See #222. + * + * Behaviour: + * - When `API_KEY` is unset or empty (after trimming) authentication is + * disabled and the request is allowed through unchanged. This preserves the + * current (dev) behaviour and keeps existing tests green. + * - Otherwise the request must present an `Authorization: Bearer ` + * header. A missing header, a missing/malformed `Bearer ` prefix or a token + * that does not match the configured key results in a generic `401`. + * + * The key is never logged and is only ever read from `process.env`. The token + * comparison is constant-time (via `timingSafeEqual`) to avoid leaking the key + * length/contents through timing side channels, mirroring the WHIP/WHEP and + * reauth guards elsewhere in this codebase. + */ +export const requireApiKey: preHandlerHookHandler = (request, reply, done) => { + const apiKey = process.env.API_KEY?.trim(); + + // Auth disabled when no API_KEY is configured (dev mode). + if (!apiKey) { + done(); + return; + } + + const authHeader = request.headers['authorization']; + const prefix = 'Bearer '; + const token = + typeof authHeader === 'string' && authHeader.startsWith(prefix) + ? authHeader.slice(prefix.length).trim() + : ''; + + const tokenBuf = Buffer.from(token); + const keyBuf = Buffer.from(apiKey); + const isValid = + tokenBuf.length === keyBuf.length && timingSafeEqual(tokenBuf, keyBuf); + + if (!isValid) { + reply.code(401).send({ error: 'Unauthorized' }); + return; + } + + done(); +}; diff --git a/src/server.ts b/src/server.ts index b4b700c..bfba17b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -57,6 +57,12 @@ if ( ); } +if (!process.env.API_KEY?.trim()) { + Log().warn( + 'SECURITY: API_KEY not set - management endpoints (production/line/session mutations) are UNAUTHENTICATED. Anyone who can reach this server can create, modify and delete productions, lines and sessions. Set API_KEY to a non-empty secret to require a Bearer token.' + ); +} + const ENDPOINT_IDLE_TIMEOUT_S: string = process.env.ENDPOINT_IDLE_TIMEOUT_S ?? '60';