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
42 changes: 42 additions & 0 deletions src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@
on: jest.fn(),
once: jest.fn(),
emit: jest.fn()
} as any;

Check warning on line 75 in src/api.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

const mockIngestManager = {
load: jest.fn().mockResolvedValue(undefined),
startPolling: jest.fn()
} as any;

Check warning on line 80 in src/api.test.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

describe('api', () => {
it('responds with hello, world!', async () => {
Expand All @@ -102,3 +102,45 @@
expect(response.body).toBe('Hello, world! I am my awesome service');
});
});

describe('security headers (CSP)', () => {
const buildServer = () =>
api({
title: 'my awesome service',
smbServerBaseUrl: 'http://localhost',
endpointIdleTimeout: '60',
publicHost: 'http://localhost',
dbManager: mockDbManager,
productionManager: mockProductionManager,
ingestManager: mockIngestManager,
coreFunctions: new CoreFunctions(
mockProductionManager,
new ConnectionQueue()
)
});

// The global helmet CSP is registered via an onRequest hook; swagger-ui's
// `staticCSP: true` registers an encapsulated onSend hook scoped to
// /api/docs that overrides it there only. These two tests lock in both
// halves of that interaction so a future refactor cannot silently weaken
// the API policy or break the docs page.
it('applies the strict baseline CSP to API routes', async () => {
const server = await buildServer();
const response = await server.inject({ method: 'GET', url: '/' });
const csp = response.headers['content-security-policy'] as string;
expect(csp).toContain("default-src 'none'");
expect(csp).toContain("base-uri 'none'");
expect(csp).toContain("form-action 'none'");
expect(csp).toContain("frame-ancestors 'none'");
});

it('does not leak the strict policy onto the Swagger docs page', async () => {
const server = await buildServer();
const response = await server.inject({ method: 'GET', url: '/api/docs/' });
const csp = response.headers['content-security-policy'] as string;
expect(response.statusCode).toBe(200);
// Swagger UI emits its own policy so the docs page keeps working.
expect(csp).toContain("default-src 'self'");
expect(csp).not.toContain("default-src 'none'");
});
});
18 changes: 16 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,19 @@ export default async (opts: ApiOptions) => {
api.register(fastifyCookie);

// register security headers
// Enable a strict baseline CSP. This is a JSON API and does not serve
// application HTML, so the policy can lock everything down to 'none'. The
// one HTML surface, the Swagger UI at /api/docs, emits its own compatible
// CSP via `staticCSP: true` below, which overrides this on that route.
api.register(helmet, {
contentSecurityPolicy: false // CSP managed per-deployment
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
baseUri: ["'none'"],
formAction: ["'none'"],
frameAncestors: ["'none'"]
}
}
});

// Dynamic CORS: permissive for WHIP/WHEP routes, restrictive for everything else
Expand Down Expand Up @@ -117,7 +128,10 @@ export default async (opts: ApiOptions) => {
}
});
api.register(swaggerUI, {
routePrefix: '/api/docs'
routePrefix: '/api/docs',
// Emit a CSP tailored to Swagger UI's own assets so the docs page keeps
// working under the strict global helmet CSP registered above.
staticCSP: true
});

api.register(healthcheck, { title: opts.title });
Expand Down
Loading