fix(quick-261009-p0m): Schutz-Kopfzeilen in der Weboberfläche, X-Powered-By abgeschaltet
- next.config.ts: nosniff, Referrer-Policy, X-Frame-Options SAMEORIGIN, CSP nur frame-ancestors 'self', Permissions-Policy (Kamera, Mikrofon, Standort, Zahlung, USB), COOP same-origin-allow-popups, poweredByHeader aus - API: X-Powered-By (Express) in configureHttp abgeschaltet - Tests: next-config.test.ts, http-setup.spec.ts - Sicherheitsprotokoll (Zeilen der Außenprüfung), Entwicklungsanleitung, CHANGELOG Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,9 @@ function recordingApp() {
|
||||
enableCors: vi.fn(() => {
|
||||
calls.push('cors');
|
||||
}),
|
||||
disable: vi.fn((name: string) => {
|
||||
calls.push(`disable:${name}`);
|
||||
}),
|
||||
} as unknown as HttpApp;
|
||||
return { app, calls };
|
||||
}
|
||||
@@ -38,6 +41,12 @@ describe('configureHttp (Review WR-01)', () => {
|
||||
it('Anfragelog und Cookies kommen vor allem anderen', () => {
|
||||
const { app, calls } = recordingApp();
|
||||
configureHttp(app, 'x');
|
||||
expect(calls.slice(0, 2)).toEqual(['use', 'use']);
|
||||
expect(calls.filter((c) => !c.startsWith('disable:')).slice(0, 2)).toEqual(['use', 'use']);
|
||||
});
|
||||
|
||||
it('schaltet die Kopfzeile X-Powered-By ab', () => {
|
||||
const { app } = recordingApp();
|
||||
configureHttp(app, 'x');
|
||||
expect(app.disable).toHaveBeenCalledWith('x-powered-by');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import {
|
||||
CERT_BUILD_ROUTE,
|
||||
@@ -8,7 +9,10 @@ import {
|
||||
import { requestLogMiddleware } from './common/request-log';
|
||||
|
||||
/** Der Teil der Nest-Anwendung, den die HTTP-Einrichtung braucht (so laesst sich die Reihenfolge testen). */
|
||||
export type HttpApp = Pick<INestApplication, 'use' | 'useGlobalPipes' | 'enableCors'>;
|
||||
export type HttpApp = Pick<
|
||||
NestExpressApplication,
|
||||
'use' | 'useGlobalPipes' | 'enableCors' | 'disable'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Gemeinsame HTTP-Einrichtung der API (aus main.ts, damit die Reihenfolge testbar ist).
|
||||
@@ -21,6 +25,9 @@ export type HttpApp = Pick<INestApplication, 'use' | 'useGlobalPipes' | 'enableC
|
||||
* Er steht trotzdem vor Nests globalem JSON-Leser, der erst beim Start (listen) eingebaut wird.
|
||||
*/
|
||||
export function configureHttp(app: HttpApp, corsOrigin: string): void {
|
||||
// Kein "X-Powered-By: Express" (quick-261009-p0m): verrät dem Besucher nur das Framework
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Eine Zeile je Anfrage im Docker-Log (Pfad, Status, Dauer, Benutzer)
|
||||
app.use(requestLogMiddleware);
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { formatAppVersionLine } from './health/app-version';
|
||||
import { configureHttp } from './http-setup';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
configureHttp(app, configService.get<string>('CORS_ORIGIN', 'http://localhost:3000'));
|
||||
|
||||
@@ -28,11 +28,39 @@ function readChangelog(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Schutz-Kopfzeilen der Anwendung (quick-261009-p0m, aus dem ersten ZAP-Lauf). Sie gelten fuer
|
||||
// alle Pfade und stehen hier, damit sie auch ohne vorgeschalteten Proxy gesendet werden
|
||||
// (doppelt gesichert; setzt der Proxy dieselben Kopfzeilen, gewinnt keine Seite etwas oder
|
||||
// verliert etwas). Absichtlich KEINE vollstaendige Content-Security-Policy -- sie braucht eine
|
||||
// eigene Pruefung der Inline-Skripte von Next.js und bleibt im Sicherheitsprotokoll "offen".
|
||||
// `frame-ancestors 'self'` ist nur die eine Richtlinie gegen das Einrahmen durch fremde Seiten
|
||||
// (Gegenstueck zu X-Frame-Options fuer neuere Browser). Eingebettete FREMDE Seiten (XFrame-Kachel,
|
||||
// eigene Module) sind nicht betroffen: diese Kopfzeilen schuetzen nur Tessera selbst.
|
||||
// Permissions-Policy sperrt Kamera, Mikrofon, Standort, Zahlung und USB; die Zwischenablage
|
||||
// (clipboard-write fuer "Link kopieren"), Vollbild und Benachrichtigungen bleiben unberuehrt.
|
||||
// Cross-Origin-Opener-Policy `same-origin-allow-popups`: jeder Link in ein neues Fenster
|
||||
// (Nextcloud-Anmeldung, Favoriten, ...) traegt noopener, nichts nutzt window.opener/postMessage.
|
||||
const securityHeaders = [
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||
{ key: 'Content-Security-Policy', value: "frame-ancestors 'self'" },
|
||||
{
|
||||
key: 'Permissions-Policy',
|
||||
value: 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
||||
},
|
||||
{ key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' },
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone' as const,
|
||||
poweredByHeader: false,
|
||||
env: {
|
||||
TESSERA_CHANGELOG_MD: readChangelog(),
|
||||
},
|
||||
async headers() {
|
||||
return [{ source: '/:path*', headers: securityHeaders }];
|
||||
},
|
||||
async rewrites() {
|
||||
const apiUrl = process.env.API_INTERNAL_URL || 'http://api:3001';
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import nextConfig from '../next.config';
|
||||
|
||||
/**
|
||||
* Schutz-Kopfzeilen der Weboberflaeche (quick-261009-p0m). Der Test haelt fest, dass sie fuer
|
||||
* alle Pfade gelten und dass die Zwischenablage (clipboard-write fuer "Link kopieren") nicht
|
||||
* gesperrt wird.
|
||||
*/
|
||||
describe('next.config: Schutz-Kopfzeilen', () => {
|
||||
it('sendet keinen X-Powered-By', () => {
|
||||
expect(nextConfig.poweredByHeader).toBe(false);
|
||||
});
|
||||
|
||||
it('setzt die Kopfzeilen fuer alle Pfade', async () => {
|
||||
const rules = await nextConfig.headers?.();
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules?.[0].source).toBe('/:path*');
|
||||
const h = Object.fromEntries((rules?.[0].headers ?? []).map((x) => [x.key, x.value]));
|
||||
expect(h['X-Content-Type-Options']).toBe('nosniff');
|
||||
expect(h['Referrer-Policy']).toBe('strict-origin-when-cross-origin');
|
||||
expect(h['X-Frame-Options']).toBe('SAMEORIGIN');
|
||||
expect(h['Cross-Origin-Opener-Policy']).toBe('same-origin-allow-popups');
|
||||
});
|
||||
|
||||
it('beschraenkt die Content-Security-Policy auf frame-ancestors', async () => {
|
||||
const rules = await nextConfig.headers?.();
|
||||
const csp = rules?.[0].headers.find((x) => x.key === 'Content-Security-Policy');
|
||||
expect(csp?.value).toBe("frame-ancestors 'self'");
|
||||
});
|
||||
|
||||
it('sperrt Kamera, Mikrofon, Standort, Zahlung und USB, aber nicht die Zwischenablage', async () => {
|
||||
const rules = await nextConfig.headers?.();
|
||||
const pp = rules?.[0].headers.find((x) => x.key === 'Permissions-Policy')?.value ?? '';
|
||||
for (const f of ['camera', 'microphone', 'geolocation', 'payment', 'usb']) {
|
||||
expect(pp).toContain(`${f}=()`);
|
||||
}
|
||||
expect(pp).not.toContain('clipboard');
|
||||
expect(pp).not.toContain('fullscreen');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user