diff --git a/app/backend/src/common/security/csp-report.controller.spec.ts b/app/backend/src/common/security/csp-report.controller.spec.ts new file mode 100644 index 00000000..b6e985c7 --- /dev/null +++ b/app/backend/src/common/security/csp-report.controller.spec.ts @@ -0,0 +1,202 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CspReportController } from './csp-report.controller'; +import { LoggerService } from '../../logger/logger.service'; +import { Public } from '../decorators/public.decorator'; + +describe('CspReportController', () => { + let controller: CspReportController; + let mockLogger: { + warn: jest.Mock; + error: jest.Mock; + debug: jest.Mock; + log: jest.Mock; + }; + + const buildReq = (body: unknown, overrides: Record = {}) => { + return { + body, + ip: '203.0.113.5', + headers: { + 'user-agent': 'Mozilla/5.0 (CSP-Reporter)', + }, + ...overrides, + } as unknown as Parameters[0]; + }; + + const buildRes = () => { + const res: { statusCode?: number; body?: unknown } = {}; + return { + status: jest.fn((code: number) => { + res.statusCode = code; + return { + send: jest.fn((body?: unknown) => { + res.body = body; + }), + }; + }), + _state: res, + } as unknown as Parameters[1] & { + _state: { statusCode?: number; body?: unknown }; + }; + }; + + beforeEach(async () => { + mockLogger = { + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [CspReportController], + providers: [{ provide: LoggerService, useValue: mockLogger }], + }).compile(); + + controller = module.get(CspReportController); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should be marked @Public() so ApiKeyGuard bypasses it', () => { + // The class‑level decorator attaches metadata; verify via reflection. + + const publicFlag = Reflect.getMetadata('isPublic', CspReportController); + expect(publicFlag).toBe(true); + }); + + it('should return 204 immediately and log a single violation (legacy payload)', () => { + const legacyPayload = { + 'csp-report': { + 'document-uri': 'https://app.example.com/page', + 'violated-directive': "script-src 'self'", + 'effective-directive': 'script-src', + 'blocked-uri': 'https://attacker.example.com/x.js', + 'source-file': 'https://app.example.com/page', + 'line-number': 12, + 'column-number': 4, + disposition: 'enforce', + }, + }; + + const res = buildRes(); + + controller.handleReport(buildReq(legacyPayload), res); + + expect( + (res as unknown as { status: jest.Mock }).status, + ).toHaveBeenCalledWith(204); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + const [, context, meta] = mockLogger.warn.mock.calls[0]; + expect(context).toBe('CspReportController'); + expect(meta.security_event).toBe('csp_violation'); + expect(meta.violated_directive).toBe("script-src 'self'"); + expect(meta.blocked_uri).toBe('https://attacker.example.com/x.js'); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('should accept array payloads from the new Reporting API and log one warning per violation', () => { + const reports = [ + { type: 'csp-violation', url: 'https://app.example.com/a' }, + { type: 'csp-violation', url: 'https://app.example.com/b' }, + ]; + + const res = buildRes(); + controller.handleReport(buildReq(reports), res); + + expect( + (res as unknown as { status: jest.Mock }).status, + ).toHaveBeenCalledWith(204); + expect(mockLogger.warn).toHaveBeenCalledTimes(2); + reports.forEach((report, index) => { + expect(mockLogger.warn.mock.calls[index][2].document_uri).toBe( + report.url, + ); + }); + }); + + it('should accept bare object payloads (modern browser reports)', () => { + const payload = { + type: 'csp-violation', + url: 'https://app.example.com/page', + body: { + 'blocked-uri': 'inline', + }, + }; + + const res = buildRes(); + controller.handleReport(buildReq(payload), res); + + expect( + (res as unknown as { status: jest.Mock }).status, + ).toHaveBeenCalledWith(204); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn.mock.calls[0][2].document_uri).toBe( + 'https://app.example.com/page', + ); + }); + + it('should silently ignore empty / non‑object / null bodies but still return 204', () => { + const res = buildRes(); + controller.handleReport(buildReq(null), res); + expect( + (res as unknown as { status: jest.Mock }).status, + ).toHaveBeenCalledWith(204); + expect(mockLogger.warn).not.toHaveBeenCalled(); + + const res2 = buildRes(); + controller.handleReport(buildReq(undefined), res2); + expect(mockLogger.warn).not.toHaveBeenCalled(); + + const res3 = buildRes(); + controller.handleReport(buildReq('plain string'), res3); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should emit an error-level spike log when threshold is reached inside the window', () => { + // Send 24 distinct violations -- below threshold. + for (let i = 0; i < 24; i += 1) { + controller.handleReport( + buildReq({ 'csp-report': { 'violated-directive': "img-src 'self'" } }), + buildRes(), + ); + } + expect(mockLogger.warn).toHaveBeenCalledTimes(24); + expect(mockLogger.error).not.toHaveBeenCalled(); + + // 25th crosses the threshold and should emit the spike alert. + controller.handleReport( + buildReq({ 'csp-report': { 'violated-directive': "img-src 'self'" } }), + buildRes(), + ); + + expect(mockLogger.warn).toHaveBeenCalledTimes(25); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + const [, , errorMeta] = mockLogger.error.mock.calls[0]; + expect(errorMeta.security_event).toBe('csp_violation_spike'); + expect(errorMeta.count).toBe(25); + expect(errorMeta.threshold).toBe(25); + }); + + it('should not log full script samples to limit attacker-controlled content', () => { + const huge = 'A'.repeat(2000); + controller.handleReport( + buildReq({ + 'csp-report': { + 'script-sample': huge, + }, + }), + buildRes(), + ); + + const [, , meta] = mockLogger.warn.mock.calls[0]; + expect(meta.script_sample.length).toBeLessThanOrEqual(120); + }); + + it('imports the Public decorator from the public decorator module', () => { + // Smoke check that the decorator is wired correctly. + expect(typeof Public).toBe('function'); + }); +}); diff --git a/app/backend/src/common/security/csp-report.controller.ts b/app/backend/src/common/security/csp-report.controller.ts new file mode 100644 index 00000000..24a8b840 --- /dev/null +++ b/app/backend/src/common/security/csp-report.controller.ts @@ -0,0 +1,232 @@ +import { Controller, HttpCode, Post, Req, Res } from '@nestjs/common'; +import { SkipThrottle } from '@nestjs/throttler'; +import type { Request, Response } from 'express'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Public } from '../decorators/public.decorator'; +import { LoggerService } from '../../logger/logger.service'; + +/** + * Threshold and window for "many violations in a short period" detection. + * When this many violations are observed in the window we emit a single + * high-severity log entry so monitoring can fire alerts. + */ +const CSP_SPIKE_THRESHOLD = 25; +const CSP_SPIKE_WINDOW_MS = 60_000; + +/** + * Cookie / header values we always strip from the user-agent before logging, + * because some browsers leak cookie content into the User-Agent on violation + * reports. We just take the whole header; this is a defense-in-depth measure + * alongside the recursive log redactor. + */ +const stripUserAgent = ( + raw: string | string[] | undefined, +): string | undefined => { + if (!raw) return undefined; + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value !== 'string') return undefined; + // Truncate at first semi-colon to avoid leaking extension metadata. + return value.slice(0, 256); +}; + +/** + * Shape of a legacy CSP violation report + * (https://www.w3.org/TR/CSP3/#violation-events). + */ +interface CspViolationReport { + 'document-uri'?: string; + referrer?: string; + 'violated-directive'?: string; + 'effective-directive'?: string; + 'original-policy'?: string; + disposition?: string; + 'blocked-uri'?: string; + 'line-number'?: number; + 'column-number'?: number; + 'source-file'?: string; + 'status-code'?: number; + 'script-sample'?: string; + [key: string]: unknown; +} + +/** + * Endpoint that browsers POST browser-side CSP violations to. + * + * - Always responds with HTTP 204 immediately so the browser does not retry. + * - Logs each violation through the shared LoggerService (warn level). + * - Tracks an in-memory sliding-window counter to surface spikes at error level. + * + * Browsers do not send an API key, so this route is annotated `@Public()` to + * bypass the global `ApiKeyGuard`. + */ +@ApiExcludeController() +@Public() +@SkipThrottle() +// CSP reports can legitimately spike during incidents and come from +// unauthenticated browsers; apply neither the global throttler nor the +// rate-limit middleware that runs on most protected endpoints. +@Controller('csp-report') +export class CspReportController { + private windowStart = Date.now(); + private violationCount = 0; + + constructor(private readonly logger: LoggerService) {} + + @Post() + @HttpCode(204) + handleReport(@Req() req: Request, @Res() res: Response): void { + // Acknowledge the browser first so it does not retry on slow processing. + res.status(204).send(); + + try { + const reports = this.extractReports(req.body); + if (!reports || reports.length === 0) { + return; + } + + for (const report of reports) { + this.logViolation(report, req); + } + + this.detectSpike(reports.length); + } catch (error) { + this.logger.debug( + 'Discarded malformed CSP violation report', + 'CspReportController', + { reason: (error as { message?: string }).message ?? 'unknown' }, + ); + } + } + + /** + * Normalise the various browser-side payload shapes into an array of + * `csp-report` documents. Browsers historically send: + * - `application/csp-report` (legacy): `{ "csp-report": {...} }` + * - `application/reports+json` (newer Reporting API): `[{...}, ...]` + * - plain JSON: object or array of reports + */ + private extractReports(body: unknown): CspViolationReport[] | null { + if (body === null || body === undefined) { + return null; + } + + if (Array.isArray(body)) { + return body + .filter( + (item): item is CspViolationReport => + item !== null && typeof item === 'object' && !Array.isArray(item), + ) + .map(item => this.mergeReportingApiBody(item)); + } + + if (typeof body === 'object') { + const record = body as Record; + const legacy = record['csp-report']; + if ( + legacy !== null && + typeof legacy === 'object' && + !Array.isArray(legacy) + ) { + return [legacy as CspViolationReport]; + } + return [this.mergeReportingApiBody(record as CspViolationReport)]; + } + + return null; + } + + /** + * Modern Reporting API items have a top-level `type` and `url`, and the + * CSP details live inside a nested `body` object. Legacy reports use + * `document-uri` and friends directly on the csp-report object. We blend + * the two shapes so the rest of the pipeline can operate on a single + * normalisation. + */ + private mergeReportingApiBody( + report: CspViolationReport, + ): CspViolationReport { + const raw = report as Record; + const nested = raw.body; + if ( + nested === null || + typeof nested !== 'object' || + Array.isArray(nested) + ) { + return report; + } + const merged: CspViolationReport = { ...raw } as CspViolationReport; + const inner = nested as Record; + for (const [key, value] of Object.entries(inner)) { + if (merged[key] === undefined) { + merged[key] = value as string | number | undefined; + } + } + return merged; + } + + private logViolation(report: CspViolationReport, req: Request): void { + // Legacy reports put the URL under `document-uri`; modern Reporting API + // items put it under `url` (top-level) and stash CSP details in `body`. + // `mergeReportingApiBody` already normalised the modern case. + const reportUrl = + (typeof report.url === 'string' ? report.url : undefined) ?? + report['document-uri']; + const rawSample = report['script-sample']; + const scriptSample = + typeof rawSample === 'string' ? rawSample.slice(0, 120) : undefined; + + const meta = { + security_event: 'csp_violation', + document_uri: reportUrl, + referrer: report.referrer, + violated_directive: report['violated-directive'], + effective_directive: report['effective-directive'], + blocked_uri: report['blocked-uri'], + disposition: report.disposition, + source_file: report['source-file'], + line_number: report['line-number'], + column_number: report['column-number'], + status_code: report['status-code'], + original_policy: report['original-policy'], + // Sampling small preview avoids logging full script bodies which may + // contain attacker-controllable PII. + script_sample: scriptSample, + user_agent: stripUserAgent(req.headers['user-agent']), + remote_addr: req.ip, + }; + + this.logger.warn( + 'CSP violation reported by browser', + 'CspReportController', + meta, + ); + } + + private detectSpike(addedCount: number): void { + const now = Date.now(); + if (now - this.windowStart >= CSP_SPIKE_WINDOW_MS) { + this.windowStart = now; + this.violationCount = 0; + } + + this.violationCount += addedCount; + + if (this.violationCount >= CSP_SPIKE_THRESHOLD) { + this.logger.error( + 'CSP violation spike detected', + undefined, + 'CspReportController', + { + security_event: 'csp_violation_spike', + count: this.violationCount, + threshold: CSP_SPIKE_THRESHOLD, + window_ms: CSP_SPIKE_WINDOW_MS, + }, + ); + + // Reset after alerting so we don't re-emit on every subsequent violation. + this.violationCount = 0; + this.windowStart = now; + } + } +} diff --git a/app/backend/src/common/security/security.module.ts b/app/backend/src/common/security/security.module.ts index f0961b12..171ec3e3 100644 --- a/app/backend/src/common/security/security.module.ts +++ b/app/backend/src/common/security/security.module.ts @@ -3,6 +3,8 @@ import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-option import { ConfigService } from '@nestjs/config'; import type { NextFunction, Request, RequestHandler, Response } from 'express'; import helmet, { HelmetOptions } from 'helmet'; +import { CspReportController } from './csp-report.controller'; +import { LoggerModule } from '../../logger/logger.module'; const DEFAULT_ALLOWED_ORIGINS = [ 'http://localhost:3000', @@ -25,6 +27,9 @@ const RATE_LIMIT_EXEMPT_PATHS = [ /^\/(api\/)?(v\d+\/)?health(\/|$)/i, /^\/(api\/)?(v\d+\/)?metrics(\/|$)/i, /^\/(api\/)?docs(\/|$)/i, + // CSP violation reports may legitimately spike during real incidents; we + // do not want rate limiting to silence browser-supplied security data. + /^\/(api\/)?(v\d+\/)?csp-report(\/|$)/i, ]; const parseBoolean = (value: string | undefined, fallback = false): boolean => { @@ -87,7 +92,13 @@ const buildHelmetOptions = (config: ConfigService): HelmetOptions => { objectSrc: ["'none'"], mediaSrc: ["'self'"], frameSrc: ["'none'"], + // Forward browser-side CSP violations to our collector so that + // monitoring can alert on XSS attempts and similar events. + reportUri: ['/api/v1/csp-report'], }, + // We always run as `enforce` (no report-only mode) in production so + // that violations are actively blocked while still being collected. + reportOnly: false, } : false, crossOriginEmbedderPolicy: false, @@ -275,10 +286,19 @@ export const createRateLimiter = (config: ConfigService): RequestHandler => { * CSRF is currently mitigated by design due to our stateless, token-based authentication * mechanism (`x-api-key` header). Since browsers do not automatically attach custom headers * on cross-origin requests, CSRF attacks are inherently prevented. - * + * * WARNING: - * If cookie-based session management or any browser-managed credentials are introduced + * If cookie-based session management or any browser-managed credentials are introduced * in the future, CSRF protection middleware MUST be implemented. */ -@Module({}) +@Module({ + imports: [LoggerModule], + controllers: [CspReportController], +}) export class SecurityModule {} + +/** + * Exported so tests / scripts can refer to the canonical report URL + * without hard-coding the path. + */ +export const CSP_REPORT_PATH = '/api/v1/csp-report'; diff --git a/app/backend/src/logger/logger.service.spec.ts b/app/backend/src/logger/logger.service.spec.ts new file mode 100644 index 00000000..81fb1035 --- /dev/null +++ b/app/backend/src/logger/logger.service.spec.ts @@ -0,0 +1,149 @@ +import { LoggerService } from './logger.service'; +import { redactLogData } from './log-redaction.util'; + +describe('LoggerService', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + jest.restoreAllMocks(); + }); + + describe('output format selection', () => { + it('constructs successfully in production mode (JSON path)', () => { + process.env.NODE_ENV = 'production'; + expect(() => new LoggerService()).not.toThrow(); + }); + + it('constructs successfully in test mode (avoids jest hanging on pino-pretty worker)', () => { + process.env.NODE_ENV = 'test'; + expect(() => new LoggerService()).not.toThrow(); + }); + + it('constructs successfully in development mode (pino-pretty transport)', () => { + process.env.NODE_ENV = 'development'; + expect(() => new LoggerService()).not.toThrow(); + }); + + it('constructs successfully when NODE_ENV is unset (defaults to development)', () => { + delete process.env.NODE_ENV; + expect(() => new LoggerService()).not.toThrow(); + }); + }); + + describe('redaction integration', () => { + it('passes redacted meta to the underlying pino logger', () => { + process.env.NODE_ENV = 'production'; + process.env.LOG_LEVEL = 'info'; + + const service = new LoggerService(); + const infoSpy = jest.spyOn(service.getLogger(), 'info'); + + service.log('login attempt', 'AuthService', { + username: 'gooduser', + password: 'supersecret', + token: 'jwt-token', + nested: { + authorization: 'Bearer xyz', + apikey: 'k-1234', + social: 'public-info', + }, + }); + + expect(infoSpy).toHaveBeenCalledTimes(1); + const call = infoSpy.mock.calls[0] as [ + Record, + ...unknown[], + ]; + const metaArg = call[0]; + // redacted by logger.service redactor (applied before pino): + expect(metaArg.password).toBe('[REDACTED]'); + expect(metaArg.token).toBe('[REDACTED]'); + expect(metaArg.username).toBe('gooduser'); + const nested = metaArg.nested as Record; + expect(nested.authorization).toBe('[REDACTED]'); + expect(nested.apikey).toBe('[REDACTED]'); + expect(nested.social).toBe('public-info'); + }); + + it('does not throw when meta contains circular structures', () => { + process.env.NODE_ENV = 'production'; + const service = new LoggerService(); + + const obj: Record = { foo: 'bar' }; + obj.self = obj; + + expect(() => service.log('Trying circular', 'Spec', obj)).not.toThrow(); + }); + + it('exercises redactLogData utility directly', () => { + const redacted = redactLogData({ + password: 'p', + token: 't', + authorization: 'a', + api_key: 'k', + sleuthing: 'shh', + deeper: { ssn: '111-22-3333', ok: 'safe' }, + }) as Record>; + expect(redacted.password).toBe('[REDACTED]'); + expect(redacted.token).toBe('[REDACTED]'); + expect(redacted.authorization).toBe('[REDACTED]'); + expect(redacted.api_key).toBe('[REDACTED]'); + expect(redacted.sleuthing).toBe('shh'); + expect(redacted.deeper.ssn).toBe('[REDACTED]'); + expect(redacted.deeper.ok).toBe('safe'); + }); + }); + + describe('correlation ID propagation', () => { + it('adds correlationId when async storage is populated', () => { + process.env.NODE_ENV = 'production'; + process.env.LOG_LEVEL = 'info'; + + const service = new LoggerService(); + const als = service.getAsyncLocalStorage(); + const infoSpy = jest.spyOn(service.getLogger(), 'info'); + + als.run(new Map([['correlationId', 'corr-xyz-789']]), () => { + service.log('inside correlation', 'Spec', { user: 'u' }); + }); + + expect(infoSpy).toHaveBeenCalledTimes(1); + const call = infoSpy.mock.calls[0] as [ + Record, + ...unknown[], + ]; + const metaArg = call[0]; + expect(metaArg.correlationId).toBe('corr-xyz-789'); + expect(metaArg.user).toBe('u'); + }); + }); + + describe('log level configuration via LOG_LEVEL env', () => { + it('suppresses info when LOG_LEVEL=warn', () => { + process.env.NODE_ENV = 'production'; + process.env.LOG_LEVEL = 'warn'; + + const service = new LoggerService(); + const infoSpy = jest.spyOn(service.getLogger(), 'info'); + const warnSpy = jest.spyOn(service.getLogger(), 'warn'); + + service.log('should be suppressed (info)', 'Spec'); + service.warn('should be visible (warn)', 'Spec'); + + expect(infoSpy).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('defaults to info level when LOG_LEVEL is unset', () => { + delete process.env.LOG_LEVEL; + process.env.NODE_ENV = 'production'; + + const service = new LoggerService(); + const infoSpy = jest.spyOn(service.getLogger(), 'info'); + + service.log('visible by default', 'Spec'); + expect(infoSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/backend/src/logger/logger.service.ts b/app/backend/src/logger/logger.service.ts index 86e92791..91ebe9ba 100644 --- a/app/backend/src/logger/logger.service.ts +++ b/app/backend/src/logger/logger.service.ts @@ -2,6 +2,7 @@ import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common'; import pino, { Logger as PinoLogger, Bindings, ChildLoggerOptions } from 'pino'; import { AsyncLocalStorage } from 'async_hooks'; import { CORRELATION_ID_KEY } from '../common/utils/correlation-id.util'; +import { redactLogData } from './log-redaction.util'; // Type definitions type LogLevel = 'info' | 'error' | 'warn' | 'debug' | 'trace'; @@ -19,6 +20,57 @@ interface LogEntry { [key: string]: unknown; } +// Pino path patterns for built-in redaction. Matches the same sensitive +// keys recognised by `log-redaction.util.ts`, applied with limited nesting +// so it remains cost-effective at log-construction time. +const SENSITIVE_REDACT_KEYS = [ + 'password', + 'token', + 'secret', + 'authorization', + 'apikey', + 'api_key', + 'privatekey', + 'private_key', + 'creditcard', + 'ssn', +]; + +const REDACT_MAX_DEPTH = 4; + +const buildRedactPaths = (): string[] => { + const paths: string[] = []; + for (let depth = 1; depth <= REDACT_MAX_DEPTH; depth += 1) { + const prefix = + depth === 1 + ? '' + : Array(depth - 1) + .fill('*') + .join('.') + '.'; + for (const key of SENSITIVE_REDACT_KEYS) { + paths.push(`${prefix}${key}`); + } + } + return paths; +}; + +const PINO_REDACT_PATHS = buildRedactPaths(); + +const isProduction = (): boolean => + (process.env.NODE_ENV ?? 'development').toLowerCase() === 'production'; + +const isTest = (): boolean => + (process.env.NODE_ENV ?? 'development').toLowerCase() === 'test' || + process.env.JEST_WORKER_ID !== undefined; + +/** + * Returns true when pino should output structured JSON without a transport. + * In production, logs must be machine-parseable for ELK / Datadog / CloudWatch. + * In test, pinned formatting keeps the test suite from hanging on worker threads + * when pino-pretty spawns its own worker. + */ +const shouldUseJsonOutput = (): boolean => isProduction() || isTest(); + @Injectable() export class LoggerService implements NestLoggerService { private readonly logger: PinoLogger; @@ -30,6 +82,10 @@ export class LoggerService implements NestLoggerService { this.logger = pino({ level: process.env.LOG_LEVEL || 'info', timestamp: pino.stdTimeFunctions.isoTime, + redact: { + paths: PINO_REDACT_PATHS, + censor: '[REDACTED]', + }, formatters: { level: (label: string): Record => ({ level: label }), log: (object: Record): Record => { @@ -40,6 +96,20 @@ export class LoggerService implements NestLoggerService { return object; }, }, + // Pretty-print only when running locally outside tests/production. + ...(shouldUseJsonOutput() + ? {} + : { + transport: { + target: 'pino-pretty', + options: { + singleLine: true, + translateTime: 'SYS:HH:MM:ss.l', + ignore: 'pid,hostname', + colorize: true, + }, + }, + }), }); } @@ -51,6 +121,23 @@ export class LoggerService implements NestLoggerService { return store?.get(CORRELATION_ID_KEY) as string | undefined; } + /** + * Apply the recursive redaction utility to meta payload before Pino + * serialisation. Defense in depth alongside Pino's `redact` paths so + * deeply nested sensitive data is scrubbed regardless of nesting depth. + */ + private redactMeta(meta: LogMeta): LogMeta { + if (!meta || typeof meta !== 'object') { + return meta; + } + try { + return redactLogData(meta); + } catch { + // Never let redaction failures break log emission. + return meta; + } + } + /** * Format message with correlation ID for methods that bypass Pino's formatters */ @@ -61,22 +148,32 @@ export class LoggerService implements NestLoggerService { ): LogEntry { const correlationId = this.getCorrelationId(); const timestamp = new Date().toISOString(); + const safeMeta = this.redactMeta(meta); // If message is an object, merge it with metadata if (typeof message === 'object' && message !== null) { - return { - ...message, - ...(meta || {}), - correlationId, - context, - timestamp, - }; + try { + return { + ...message, + ...(safeMeta || {}), + correlationId, + context, + timestamp, + }; + } catch { + return { + message: '[unserialisable payload]', + correlationId, + context, + timestamp, + }; + } } // String message with metadata return { message, - ...(meta || {}), + ...(safeMeta || {}), correlationId, context, timestamp, @@ -88,11 +185,27 @@ export class LoggerService implements NestLoggerService { */ log(message: LogMessage, context?: LogContext, meta?: LogMeta): void { const correlationId = this.getCorrelationId(); + const safeMeta = this.redactMeta(meta); if (typeof message === 'object' && message !== null) { - this.logger.info({ context, correlationId, ...message, ...(meta || {}) }); + try { + this.logger.info({ + context, + correlationId, + ...message, + ...(safeMeta || {}), + }); + } catch { + this.logger.info( + { context, correlationId }, + '[unserialisable payload]', + ); + } } else { - this.logger.info({ context, correlationId, ...(meta || {}) }, message); + this.logger.info( + { context, correlationId, ...(safeMeta || {}) }, + message, + ); } } @@ -106,18 +219,26 @@ export class LoggerService implements NestLoggerService { meta?: LogMeta, ): void { const correlationId = this.getCorrelationId(); + const safeMeta = this.redactMeta(meta); if (typeof message === 'object' && message !== null) { - this.logger.error({ - context, - correlationId, - trace, - ...message, - ...(meta || {}), - }); + try { + this.logger.error({ + context, + correlationId, + trace, + ...message, + ...(safeMeta || {}), + }); + } catch { + this.logger.error( + { context, correlationId, trace }, + '[unserialisable payload]', + ); + } } else { this.logger.error( - { context, correlationId, trace, ...(meta || {}) }, + { context, correlationId, trace, ...(safeMeta || {}) }, message, ); } @@ -128,11 +249,27 @@ export class LoggerService implements NestLoggerService { */ warn(message: LogMessage, context?: LogContext, meta?: LogMeta): void { const correlationId = this.getCorrelationId(); + const safeMeta = this.redactMeta(meta); if (typeof message === 'object' && message !== null) { - this.logger.warn({ context, correlationId, ...message, ...(meta || {}) }); + try { + this.logger.warn({ + context, + correlationId, + ...message, + ...(safeMeta || {}), + }); + } catch { + this.logger.warn( + { context, correlationId }, + '[unserialisable payload]', + ); + } } else { - this.logger.warn({ context, correlationId, ...(meta || {}) }, message); + this.logger.warn( + { context, correlationId, ...(safeMeta || {}) }, + message, + ); } } @@ -141,16 +278,27 @@ export class LoggerService implements NestLoggerService { */ debug(message: LogMessage, context?: LogContext, meta?: LogMeta): void { const correlationId = this.getCorrelationId(); + const safeMeta = this.redactMeta(meta); if (typeof message === 'object' && message !== null) { - this.logger.debug({ - context, - correlationId, - ...message, - ...(meta || {}), - }); + try { + this.logger.debug({ + context, + correlationId, + ...message, + ...(safeMeta || {}), + }); + } catch { + this.logger.debug( + { context, correlationId }, + '[unserialisable payload]', + ); + } } else { - this.logger.debug({ context, correlationId, ...(meta || {}) }, message); + this.logger.debug( + { context, correlationId, ...(safeMeta || {}) }, + message, + ); } } @@ -159,16 +307,27 @@ export class LoggerService implements NestLoggerService { */ verbose(message: LogMessage, context?: LogContext, meta?: LogMeta): void { const correlationId = this.getCorrelationId(); + const safeMeta = this.redactMeta(meta); if (typeof message === 'object' && message !== null) { - this.logger.trace({ - context, - correlationId, - ...message, - ...(meta || {}), - }); + try { + this.logger.trace({ + context, + correlationId, + ...message, + ...(safeMeta || {}), + }); + } catch { + this.logger.trace( + { context, correlationId }, + '[unserialisable payload]', + ); + } } else { - this.logger.trace({ context, correlationId, ...(meta || {}) }, message); + this.logger.trace( + { context, correlationId, ...(safeMeta || {}) }, + message, + ); } } @@ -214,7 +373,7 @@ export class LoggerService implements NestLoggerService { ) { // Meta object provided const meta = { - ...(lastArg as Record), + ...this.redactMeta(lastArg as LogMeta), correlationId, }; args[args.length - 1] = meta; diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index 8aa0bfc1..5ce1e859 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe, VersioningType } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SwaggerModule } from '@nestjs/swagger'; +import { json } from 'express'; import { AppModule } from './app.module'; import { buildSwaggerConfig } from './swagger.config'; import { LoggerService } from './logger/logger.service'; @@ -44,6 +45,21 @@ async function bootstrap() { const configService = app.get(ConfigService); + // Register a JSON body parser that also understands the CSP and + // Reporting-API content types. These are not in express's default + // JSON `type` list, so without this the CSP report collector would + // receive an empty parsed body. + app.use( + json({ + type: [ + 'application/json', + 'application/csp-report', + 'application/reports+json', + ], + limit: '256kb', + }), + ); + // Security middleware (order matters) app.use(createHelmetMiddleware(configService)); app.use(createCorsOriginValidator(configService)); diff --git a/app/backend/test/security.e2e-spec.ts b/app/backend/test/security.e2e-spec.ts index c76c8e12..da77f65d 100644 --- a/app/backend/test/security.e2e-spec.ts +++ b/app/backend/test/security.e2e-spec.ts @@ -128,6 +128,27 @@ describe('Security (e2e)', () => { process.env.NODE_ENV = 'development'; process.env.CORS_ORIGINS = 'http://localhost:3000'; }); + + it('should include report-uri directive pointing at /api/v1/csp-report in production', async () => { + process.env.NODE_ENV = 'production'; + process.env.CORS_ORIGINS = 'https://api.chainforge.app'; + process.env.CORS_ALLOW_CREDENTIALS = 'false'; + + const prodApp = await createTestApp({ enableDocs: false }); + const response = await request(prodApp.getHttpServer()).get( + '/api/v1/health', + ); + + const csp = response.headers['content-security-policy']; + expect(csp).toBeDefined(); + expect(csp).toContain('report-uri /api/v1/csp-report'); + + await prodApp.close(); + + // Reset to development + process.env.NODE_ENV = 'development'; + process.env.CORS_ORIGINS = 'http://localhost:3000'; + }); }); describe('CORS Policy', () => { @@ -241,4 +262,79 @@ describe('Security (e2e)', () => { expect(response.text).toContain('Swagger UI'); }); }); + + describe('CSP Report Endpoint', () => { + it('accepts a legacy application/csp-report payload and responds 204', async () => { + const legacyPayload = { + 'csp-report': { + 'document-uri': 'https://app.example.com/page', + 'violated-directive': "script-src 'self'", + 'blocked-uri': 'inline', + disposition: 'enforce', + }, + }; + + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(legacyPayload); + + expect(response.status).toBe(204); + }); + + it('accepts a modern application/reports+json array of reports', async () => { + const modernPayload = [ + { type: 'csp-violation', url: 'https://app.example.com/page' }, + { type: 'csp-violation', url: 'https://app.example.com/other' }, + ]; + + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/reports+json') + .send(modernPayload); + + expect(response.status).toBe(204); + }); + + it('does not require an API key (Public decorator)', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send({ + 'csp-report': { + 'violated-directive': "img-src 'self'", + }, + }); + + expect(response.status).toBe(204); + }); + + it('does not rate-limit CSP reports even when threshold is exceeded', async () => { + process.env.API_RATE_LIMIT = '2'; + process.env.THROTTLE_TTL = '60000'; + + const limitedApp = await createTestApp({ enableDocs: true }); + + const statuses: number[] = []; + for (let i = 0; i < 5; i += 1) { + const res = await request(limitedApp.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send({ + 'csp-report': { 'violated-directive': "script-src 'self'" }, + }); + statuses.push(res.status); + } + + await limitedApp.close(); + + // Reset back to safe defaults for other tests. + process.env.API_RATE_LIMIT = '1000'; + process.env.THROTTLE_TTL = '60000'; + process.env.CORS_ORIGINS = 'http://localhost:3000'; + process.env.CORS_ALLOW_CREDENTIALS = 'false'; + + expect(statuses.every(s => s === 204)).toBe(true); + }); + }); });