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
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
75 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|