dorfteich/apps/api/src/common/security-headers.e2e.test.ts
Claude Fable 5 3c62b7b773
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m15s
CI / Build container images (pull_request) Successful in 1m9s
CI / Auth e2e pack (pull_request) Successful in 7m43s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m29s
CD / Promote to Int (push) Successful in 14s
CI / Lint, typecheck, test (push) Successful in 5m24s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m35s
CI / Import/export fidelity gate (push) Successful in 55s
#197: security response headers and an explicitly restrictive CORS policy
Hand-rolled middleware instead of helmet: the header set is small enough
to own, every value is a deliberate decision, and the api gains no
transitive dependency. HSTS (no includeSubDomains — the api cannot speak
for sibling subdomains), nosniff, Referrer-Policy no-referrer,
X-Frame-Options SAMEORIGIN (not DENY: the plugin sandbox frame embeds
same-origin and its CSP has no frame-ancestors, so this header governs),
and a minimal deny-all Permissions-Policy.

CORS grants no foreign origin anything; only the APP_BASE_URL origin is
ever echoed (where browsers do not consult CORS anyway), with
Vary: Origin on every response. No preflight handling — same-origin
requests never preflight, and cross-origin API access is cookie-less by
design (PAT/Bearer).

Wired via the AppModule MiddlewareConsumer so createTestApp boots the
identical middleware. Fences: security-headers.e2e.test.ts (header set,
foreign origin gets no ACAO) and a frame assertion in
plugins.e2e.db.test.ts (framing stays possible). Rationale table in
security.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 17:13:24 +02:00

75 lines
3.2 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AppModule } from '../app.module';
// Boots the AppModule without a database (like health.e2e.test.ts): the
// middleware under test runs before any route logic, so the always-on
// healthz endpoint is a representative response (issue #197).
describe('security response headers & CORS (e2e, issue #197)', () => {
let app: INestApplication;
const appOrigin = 'http://localhost:5173'; // APP_BASE_URL default origin
beforeAll(async () => {
process.env.NODE_ENV = 'test';
process.env.DATABASE_URL ??= 'postgresql://nobody:nothing@127.0.0.1:59999/absent';
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix('api/v1');
await app.init();
});
afterAll(async () => {
await app.close();
});
it('stamps the full header set on a representative response', async () => {
const res = await request(app.getHttpServer()).get('/api/v1/healthz').expect(200);
expect(res.headers['strict-transport-security']).toBe('max-age=31536000');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['referrer-policy']).toBe('no-referrer');
// SAMEORIGIN, not DENY — the plugin sandbox frame is embedded
// same-origin (plugins.e2e.db.test.ts asserts the frame side).
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(res.headers['permissions-policy']).toBe(
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
);
});
it('stamps the headers on error responses too (unknown route)', async () => {
const res = await request(app.getHttpServer()).get('/api/v1/does-not-exist').expect(404);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it('grants a foreign origin nothing (no ACAO), while varying on Origin', async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/healthz')
.set('Origin', 'https://attacker.example')
.expect(200);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
expect(res.headers['access-control-allow-credentials']).toBeUndefined();
expect(res.headers.vary).toContain('Origin');
});
it("echoes only the app's own origin, with the credentials rule stated", async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/healthz')
.set('Origin', appOrigin)
.expect(200);
expect(res.headers['access-control-allow-origin']).toBe(appOrigin);
expect(res.headers['access-control-allow-credentials']).toBe('true');
});
it('leaves a foreign preflight ungranted (no CORS response headers)', async () => {
const res = await request(app.getHttpServer())
.options('/api/v1/healthz')
.set('Origin', 'https://attacker.example')
.set('Access-Control-Request-Method', 'POST');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
expect(res.headers['access-control-allow-methods']).toBeUndefined();
});
});