#197: security response headers and an explicitly restrictive CORS policy #252
@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { APP_FILTER } from '@nestjs/core';
|
import { APP_FILTER } from '@nestjs/core';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ import { AuthModule } from './auth/auth.module';
|
|||||||
import { BackupModule } from './backup/backup.module';
|
import { BackupModule } from './backup/backup.module';
|
||||||
import { ApiExceptionFilter } from './common/api-exception.filter';
|
import { ApiExceptionFilter } from './common/api-exception.filter';
|
||||||
import { maskTokenParam } from './common/mask-token-param';
|
import { maskTokenParam } from './common/mask-token-param';
|
||||||
|
import { SecurityHeadersMiddleware } from './common/security-headers.middleware';
|
||||||
import { CommentsModule } from './comments/comments.module';
|
import { CommentsModule } from './comments/comments.module';
|
||||||
import { CompactionModule } from './compaction/compaction.module';
|
import { CompactionModule } from './compaction/compaction.module';
|
||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig } from './config/app-config.service';
|
||||||
@ -104,4 +105,10 @@ import { VersionsModule } from './versions/versions.module';
|
|||||||
],
|
],
|
||||||
providers: [{ provide: APP_FILTER, useClass: ApiExceptionFilter }],
|
providers: [{ provide: APP_FILTER, useClass: ApiExceptionFilter }],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
// Module-level (not main.ts) so createTestApp boots the identical
|
||||||
|
// security-header/CORS middleware — see security-headers.middleware.ts.
|
||||||
|
consumer.apply(SecurityHeadersMiddleware).forRoutes('{*path}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
74
apps/api/src/common/security-headers.e2e.test.ts
Normal file
74
apps/api/src/common/security-headers.e2e.test.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
54
apps/api/src/common/security-headers.middleware.ts
Normal file
54
apps/api/src/common/security-headers.middleware.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security response headers and the CORS stance for every api response
|
||||||
|
* (issue #197). Hand-rolled instead of `helmet`: the header set is small
|
||||||
|
* enough to own, every value below is a deliberate decision, and the api
|
||||||
|
* gains no transitive dependency. Wired via the AppModule's
|
||||||
|
* MiddlewareConsumer so the test harness (createTestApp) exercises the
|
||||||
|
* exact production middleware — rationale per header in
|
||||||
|
* docs/architecture/security.md §Security response headers & CORS.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SecurityHeadersMiddleware implements NestMiddleware {
|
||||||
|
/** The one origin the SPA is served from; the only origin CORS ever echoes. */
|
||||||
|
private readonly allowedOrigin: string;
|
||||||
|
|
||||||
|
constructor(config: AppConfig) {
|
||||||
|
this.allowedOrigin = new URL(config.env.APP_BASE_URL).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
use(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
// No includeSubDomains: the api cannot speak for sibling subdomains it
|
||||||
|
// does not control (e.g. a support desk on the same apex). Browsers
|
||||||
|
// ignore HSTS over plain http, so sending it unconditionally is safe.
|
||||||
|
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
// Page paths are permission-scoped knowledge — leak them to no one.
|
||||||
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||||
|
// SAMEORIGIN, deliberately not DENY: the plugin sandbox (ADR 0008)
|
||||||
|
// embeds /api/v1/plugins/<id>/<version>/frame same-origin, and the
|
||||||
|
// frame's own CSP carries no frame-ancestors — this header governs.
|
||||||
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
// Deny the powerful features outright; nothing in the app uses them.
|
||||||
|
res.setHeader(
|
||||||
|
'Permissions-Policy',
|
||||||
|
'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS: no foreign origin is granted anything — only the app's own
|
||||||
|
// origin is ever echoed (where browsers do not consult CORS anyway, as
|
||||||
|
// same-origin; the echo states the decision rather than enabling a
|
||||||
|
// caller). Same-origin requests never preflight, so no OPTIONS
|
||||||
|
// handling is needed. Vary on every response keeps caches honest.
|
||||||
|
res.vary('Origin');
|
||||||
|
if (req.headers.origin === this.allowedOrigin) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', this.allowedOrigin);
|
||||||
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -145,6 +145,13 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => {
|
|||||||
expect(csp).toMatch(/connect-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
expect(csp).toMatch(/connect-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
||||||
expect(csp).toMatch(/frame-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
expect(csp).toMatch(/frame-src https?:\/\/[^ ]+\/api\/v1\/plugins\/framer\/1\.0\.0\//);
|
||||||
|
|
||||||
|
// Framing stays possible under the global security headers (issue #197):
|
||||||
|
// the host app embeds this document same-origin, X-Frame-Options is
|
||||||
|
// SAMEORIGIN (never DENY), and the frame's CSP carries no
|
||||||
|
// frame-ancestors that could override it.
|
||||||
|
expect(frame.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||||
|
expect(csp).not.toContain('frame-ancestors');
|
||||||
|
|
||||||
// Only the installed current version has a frame; anything else 404s.
|
// Only the installed current version has a frame; anything else 404s.
|
||||||
await api().get('/api/v1/plugins/framer/9.9.9/frame').expect(404);
|
await api().get('/api/v1/plugins/framer/9.9.9/frame').expect(404);
|
||||||
await api().get('/api/v1/plugins/ghost/1.0.0/frame').expect(404);
|
await api().get('/api/v1/plugins/ghost/1.0.0/frame').expect(404);
|
||||||
|
|||||||
@ -73,6 +73,39 @@ or sloppy plugin authors, compromised dependencies.
|
|||||||
vectors. (The plaintext cache row itself remains until purge; the index
|
vectors. (The plaintext cache row itself remains until purge; the index
|
||||||
is the concern here because it is queryable.)
|
is the concern here because it is queryable.)
|
||||||
|
|
||||||
|
## Security response headers & CORS (issue #197)
|
||||||
|
|
||||||
|
Every api response carries this header set, stamped by a hand-rolled
|
||||||
|
15-line middleware (`apps/api/src/common/security-headers.middleware.ts`)
|
||||||
|
rather than `helmet` — the set is small enough to own, each value is a
|
||||||
|
deliberate decision, and the api gains no transitive dependency. It is
|
||||||
|
wired through the AppModule's `MiddlewareConsumer`, so the e2e harness
|
||||||
|
boots the exact production middleware.
|
||||||
|
|
||||||
|
| Header | Value | Why |
|
||||||
|
| --------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `Strict-Transport-Security` | `max-age=31536000` | One year, **no** `includeSubDomains` — the api cannot speak for sibling subdomains it does not control. Browsers ignore HSTS over plain http, so it is sent unconditionally. |
|
||||||
|
| `X-Content-Type-Options` | `nosniff` | No MIME sniffing, anywhere (the uploads path relies on this too, see above). |
|
||||||
|
| `Referrer-Policy` | `no-referrer` | Page paths are permission-scoped knowledge; leak them to no destination. |
|
||||||
|
| `X-Frame-Options` | `SAMEORIGIN` | Deliberately **not** `DENY`: the plugin sandbox (ADR 0008) embeds `/api/v1/plugins/<id>/<version>/frame` same-origin, and the frame's CSP has no `frame-ancestors` — this header governs its framing. |
|
||||||
|
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=(), usb=()` | Powerful browser features denied outright; nothing in the app uses them. |
|
||||||
|
|
||||||
|
**CORS** is a stated decision, not an implicit default: no foreign origin
|
||||||
|
is granted anything. The middleware echoes `Access-Control-Allow-Origin`
|
||||||
|
(plus `Allow-Credentials: true`) only for the `APP_BASE_URL` origin
|
||||||
|
itself — where browsers never consult CORS anyway, since the SPA calls
|
||||||
|
the api same-origin (the dev server proxies `/api`). The echo documents
|
||||||
|
the stance rather than enabling a caller; consequently there is no
|
||||||
|
preflight handling (same-origin requests never preflight), and every
|
||||||
|
response carries `Vary: Origin` for cache correctness. Cross-origin API
|
||||||
|
access is cookie-less by design anyway (PAT/Bearer, see Public API), and
|
||||||
|
non-browser clients are unaffected by CORS.
|
||||||
|
|
||||||
|
Regression fence: `apps/api/src/common/security-headers.e2e.test.ts`
|
||||||
|
(header set, foreign origin gets no ACAO) and the frame assertion in
|
||||||
|
`plugins.e2e.db.test.ts`. TLS termination itself is the reverse proxy's
|
||||||
|
job (out of scope, below).
|
||||||
|
|
||||||
## Plugin sandboxing (ADR 0008, operational)
|
## Plugin sandboxing (ADR 0008, operational)
|
||||||
|
|
||||||
- Code plugins: opaque-origin iframes, no network (`connect-src 'none'`),
|
- Code plugins: opaque-origin iframes, no network (`connect-src 'none'`),
|
||||||
|
|||||||
@ -104,7 +104,7 @@ chain`_
|
|||||||
oder entfernen · 2 AT · #194
|
oder entfernen · 2 AT · #194
|
||||||
- [x] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195
|
- [x] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195
|
||||||
- [x] **Retention-Job für `audit_log`** · 1 AT · #196
|
- [x] **Retention-Job für `audit_log`** · 1 AT · #196
|
||||||
- [ ] **Security-Header** (helmet), CORS explizit restriktiv · 1 AT · #197
|
- [x] **Security-Header** (helmet), CORS explizit restriktiv · 1 AT · #197
|
||||||
- [ ] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 1–2 AT · #202
|
- [ ] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 1–2 AT · #202
|
||||||
- [ ] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198
|
- [ ] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198
|
||||||
- [ ] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap
|
- [ ] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user