From f8a2aea78f018a66b089325e3893fb8463135b9f Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:57:29 +0100 Subject: [PATCH] feat: add CSP violation report endpoint with PayloadTooLarge protection Implement POST /api/v1/csp-report endpoint to receive browser-formatted CSP violation reports per issue #219. Key features: - Accepts standard CSP report JSON structure (application/csp-report) - Deduplicates reports by (sourceFile, lineNumber, blockedUri) using Redis - Increments Prometheus counter csp_violations_total{directive} - Returns 204 No Content on success - Enforces 8 KiB request body limit (413 if exceeded) - Public endpoint (no authentication required) - OpenAPI documentation marks endpoint as internal Files added: - src/csp/csp-report.controller.ts - src/csp/csp-report.service.ts - src/csp/csp-report.module.ts - src/csp/dto/csp-report.dto.ts - test/csp-report.e2e-spec.ts Closes #219 --- app/backend/src/app.module.ts | 2 + app/backend/src/csp/csp-report.controller.ts | 73 +++++++ app/backend/src/csp/csp-report.module.ts | 12 ++ app/backend/src/csp/csp-report.service.ts | 80 ++++++++ app/backend/src/csp/dto/csp-report.dto.ts | 126 ++++++++++++ app/backend/src/main.ts | 11 + .../metrics/metrics.providers.ts | 7 + .../observability/metrics/metrics.service.ts | 9 + app/backend/test/csp-report.e2e-spec.ts | 189 ++++++++++++++++++ 9 files changed, 509 insertions(+) create mode 100644 app/backend/src/csp/csp-report.controller.ts create mode 100644 app/backend/src/csp/csp-report.module.ts create mode 100644 app/backend/src/csp/csp-report.service.ts create mode 100644 app/backend/src/csp/dto/csp-report.dto.ts create mode 100644 app/backend/test/csp-report.e2e-spec.ts diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 3935b65e..89606d51 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -45,6 +45,7 @@ import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guar import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor'; import { SandboxModule } from './sandbox/sandbox.module'; +import { CspReportModule } from './csp/csp-report.module'; @Module({ imports: [ @@ -114,6 +115,7 @@ import { SandboxModule } from './sandbox/sandbox.module'; EntityLinkingModule, DeploymentMetadataModule, SandboxModule, + CspReportModule, RedisModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/app/backend/src/csp/csp-report.controller.ts b/app/backend/src/csp/csp-report.controller.ts new file mode 100644 index 00000000..d12a6430 --- /dev/null +++ b/app/backend/src/csp/csp-report.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Post, + Body, + HttpCode, + HttpStatus, + Version, + UsePipes, + ValidationPipe, + PayloadTooLargeException, + Req, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiNoContentResponse, + ApiPayloadTooLargeResponse, + ApiBadRequestResponse, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { Public } from '../common/decorators/public.decorator'; +import { CspReportService } from './csp-report.service'; +import { CspReportDto } from './dto/csp-report.dto'; + +const MAX_BODY_SIZE_BYTES = 8 * 1024; // 8 KiB + +@ApiTags('Security (Internal)') +@Controller('csp-report') +export class CspReportController { + constructor(private readonly cspReportService: CspReportService) {} + + @Post() + @Version('1') + @Public() + @HttpCode(HttpStatus.NO_CONTENT) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: false, // Allow extra browser-specific fields + transform: true, + }), + ) + @ApiOperation({ + summary: 'Receive CSP violation reports', + description: + 'Internal endpoint for browsers to report Content Security Policy violations. ' + + 'Reports are deduplicated by (sourceFile, lineNumber, blockedUri) and counted ' + + 'in Prometheus metrics. Request body is limited to 8 KiB.', + }) + @ApiNoContentResponse({ + description: 'Report received and processed (or deduplicated)', + }) + @ApiPayloadTooLargeResponse({ + description: 'Request body exceeds 8 KiB limit', + }) + @ApiBadRequestResponse({ + description: 'Invalid CSP report format', + }) + async receiveReport( + @Req() req: Request, + @Body() report: CspReportDto, + ): Promise { + // Check content-length header for early rejection + const contentLength = parseInt(req.headers['content-length'] ?? '0', 10); + if (contentLength > MAX_BODY_SIZE_BYTES) { + throw new PayloadTooLargeException( + `Request body exceeds ${MAX_BODY_SIZE_BYTES} bytes limit`, + ); + } + + await this.cspReportService.processReport(report['csp-report']); + } +} diff --git a/app/backend/src/csp/csp-report.module.ts b/app/backend/src/csp/csp-report.module.ts new file mode 100644 index 00000000..d034982e --- /dev/null +++ b/app/backend/src/csp/csp-report.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CspReportController } from './csp-report.controller'; +import { CspReportService } from './csp-report.service'; +import { RedisService } from '../../cache/redis.service'; +import { MetricsModule } from '../observability/metrics/metrics.module'; + +@Module({ + imports: [MetricsModule], + controllers: [CspReportController], + providers: [CspReportService, RedisService], +}) +export class CspReportModule {} diff --git a/app/backend/src/csp/csp-report.service.ts b/app/backend/src/csp/csp-report.service.ts new file mode 100644 index 00000000..815dec82 --- /dev/null +++ b/app/backend/src/csp/csp-report.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { RedisService } from '../../cache/redis.service'; +import { MetricsService } from '../observability/metrics/metrics.service'; +import { CspViolationDetails } from './dto/csp-report.dto'; + +const DEDUP_TTL_SECONDS = 3600; // 1 hour deduplication window + +@Injectable() +export class CspReportService { + private readonly logger = new Logger(CspReportService.name); + + constructor( + private readonly redis: RedisService, + private readonly metrics: MetricsService, + ) {} + + /** + * Process a CSP violation report. + * + * Deduplicates reports by (sourceFile, lineNumber, blockedUri) to prevent + * double-counting when the same violation is reported multiple times. + * + * @returns true if the report was new and processed, false if it was a duplicate + */ + async processReport(report: CspViolationDetails): Promise { + const dedupKey = this.buildDedupKey(report); + const isDuplicate = await this.checkAndMarkSeen(dedupKey); + + if (isDuplicate) { + this.logger.debug(`Duplicate CSP report ignored: ${dedupKey}`); + return false; + } + + const directive = report['effective-directive'] ?? report['violated-directive'] ?? 'unknown'; + this.metrics.incrementCspViolation(directive); + + this.logger.log({ + message: 'CSP violation reported', + directive, + blockedUri: report['blocked-uri'], + sourceFile: report['source-file'], + lineNumber: report['line-number'], + documentUri: report['document-uri'], + }); + + return true; + } + + /** + * Build a deduplication key from the report fields that uniquely identify + * a violation: sourceFile, lineNumber, and blockedUri. + */ + private buildDedupKey(report: CspViolationDetails): string { + const components = [ + report['source-file'] ?? '', + String(report['line-number'] ?? ''), + report['blocked-uri'] ?? '', + ].join('|'); + + const hash = createHash('sha256').update(components).digest('hex').slice(0, 16); + return `csp:dedup:${hash}`; + } + + /** + * Check if a report with this key has been seen recently. + * If not, mark it as seen with a TTL. + * + * @returns true if the key was already seen (duplicate), false if new + */ + private async checkAndMarkSeen(key: string): Promise { + const existing = await this.redis.get(key); + if (existing !== null) { + return true; + } + + await this.redis.set(key, 1, DEDUP_TTL_SECONDS); + return false; + } +} diff --git a/app/backend/src/csp/dto/csp-report.dto.ts b/app/backend/src/csp/dto/csp-report.dto.ts new file mode 100644 index 00000000..73113e7e --- /dev/null +++ b/app/backend/src/csp/dto/csp-report.dto.ts @@ -0,0 +1,126 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsNumber, + ValidateNested, + IsNotEmptyObject, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Inner CSP violation report as sent by browsers. + * @see https://www.w3.org/TR/CSP3/#violation-reports + */ +export class CspViolationDetails { + @ApiPropertyOptional({ + description: 'The URI of the document where the violation occurred', + example: 'https://example.com/page', + }) + @IsOptional() + @IsString() + 'document-uri'?: string; + + @ApiPropertyOptional({ + description: 'The referrer of the document where the violation occurred', + example: 'https://example.com/', + }) + @IsOptional() + @IsString() + referrer?: string; + + @ApiPropertyOptional({ + description: 'The directive that was violated', + example: 'script-src', + }) + @IsOptional() + @IsString() + 'violated-directive'?: string; + + @ApiPropertyOptional({ + description: 'The effective directive that was violated', + example: 'script-src', + }) + @IsOptional() + @IsString() + 'effective-directive'?: string; + + @ApiPropertyOptional({ + description: 'The original policy as specified in the CSP header', + example: "default-src 'self'; script-src 'self'", + }) + @IsOptional() + @IsString() + 'original-policy'?: string; + + @ApiPropertyOptional({ + description: 'The disposition of the policy (enforce or report)', + example: 'enforce', + }) + @IsOptional() + @IsString() + disposition?: string; + + @ApiPropertyOptional({ + description: 'The URI of the resource that was blocked', + example: 'https://evil.com/script.js', + }) + @IsOptional() + @IsString() + 'blocked-uri'?: string; + + @ApiPropertyOptional({ + description: 'The line number in the source file where the violation occurred', + example: 42, + }) + @IsOptional() + @IsNumber() + 'line-number'?: number; + + @ApiPropertyOptional({ + description: 'The column number in the source file where the violation occurred', + example: 10, + }) + @IsOptional() + @IsNumber() + 'column-number'?: number; + + @ApiPropertyOptional({ + description: 'The URI of the source file where the violation occurred', + example: 'https://example.com/app.js', + }) + @IsOptional() + @IsString() + 'source-file'?: string; + + @ApiPropertyOptional({ + description: 'The HTTP status code of the document', + example: 200, + }) + @IsOptional() + @IsNumber() + 'status-code'?: number; + + @ApiPropertyOptional({ + description: 'Sample of the inline script/style that caused the violation', + example: 'alert(1)', + }) + @IsOptional() + @IsString() + 'script-sample'?: string; +} + +/** + * Top-level CSP report payload as sent by browsers. + * Browsers wrap the violation details in a "csp-report" object. + */ +export class CspReportDto { + @ApiProperty({ + description: 'The CSP violation report details', + type: CspViolationDetails, + }) + @ValidateNested() + @Type(() => CspViolationDetails) + @IsNotEmptyObject() + 'csp-report'!: CspViolationDetails; +} diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index 9fa25572..22ac2d8e 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -11,6 +11,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import compression from 'compression'; +import express from 'express'; import { RequestIdInterceptor } from './common/interceptors/request-id.interceptor'; import { buildCorsOptions, @@ -52,6 +53,16 @@ async function bootstrap() { app.use(createRateLimiter(configService)); app.use(compression()); + // Body parser for CSP reports with 8KiB limit + // Browsers send CSP reports as application/csp-report or application/json + app.use( + '/api/v1/csp-report', + express.json({ + type: ['application/csp-report', 'application/json'], + limit: '8kb', + }), + ); + // Global prefix app.setGlobalPrefix('api'); diff --git a/app/backend/src/observability/metrics/metrics.providers.ts b/app/backend/src/observability/metrics/metrics.providers.ts index a7ecb9c3..eb262ac6 100644 --- a/app/backend/src/observability/metrics/metrics.providers.ts +++ b/app/backend/src/observability/metrics/metrics.providers.ts @@ -141,4 +141,11 @@ export const metricsProviders = [ help: 'Total number of analytics cache invalidations', labelNames: ['reason'], }), + + // CSP Violation Metrics + makeCounterProvider({ + name: 'csp_violations_total', + help: 'Total number of CSP violations reported', + labelNames: ['directive'], + }), ]; diff --git a/app/backend/src/observability/metrics/metrics.service.ts b/app/backend/src/observability/metrics/metrics.service.ts index 37f7ca5d..2fb92120 100644 --- a/app/backend/src/observability/metrics/metrics.service.ts +++ b/app/backend/src/observability/metrics/metrics.service.ts @@ -47,6 +47,8 @@ export class MetricsService { public emailDeliveryCounter: Counter, @InjectMetric('email_delivery_duration_seconds') public emailDeliveryDuration: Histogram, + @InjectMetric('csp_violations_total') + public cspViolationsCounter: Counter, ) {} /** @@ -243,4 +245,11 @@ export class MetricsService { this.errorRateCounter.inc({ error_type: 'email_delivery_failure' }); } } + + /** + * Increment the CSP violation counter. + */ + incrementCspViolation(directive: string): void { + this.cspViolationsCounter.inc({ directive: directive.slice(0, 50) }); + } } diff --git a/app/backend/test/csp-report.e2e-spec.ts b/app/backend/test/csp-report.e2e-spec.ts new file mode 100644 index 00000000..bfad54da --- /dev/null +++ b/app/backend/test/csp-report.e2e-spec.ts @@ -0,0 +1,189 @@ +import { INestApplication, VersioningType } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import express from 'express'; +import { AppModule } from '../src/app.module'; +import { + buildCorsOptions, + createCorsOriginValidator, + createHelmetMiddleware, + createRateLimiter, +} from '../src/common/security/security.module'; + +const createTestApp = async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleFixture.createNestApplication(); + + app.setGlobalPrefix('api'); + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '1', + prefix: 'v', + }); + + const configService = app.get(ConfigService); + app.use(createHelmetMiddleware(configService)); + app.use(createCorsOriginValidator(configService)); + app.enableCors(buildCorsOptions(configService)); + app.use(createRateLimiter(configService)); + + // Body parser for CSP reports with 8KiB limit + app.use( + '/api/v1/csp-report', + express.json({ + type: ['application/csp-report', 'application/json'], + limit: '8kb', + }), + ); + + await app.init(); + return app; +}; + +const validCspReport = { + 'csp-report': { + 'document-uri': 'https://example.com/page', + 'violated-directive': 'script-src', + 'effective-directive': 'script-src', + 'original-policy': "default-src 'self'; script-src 'self'", + disposition: 'enforce', + 'blocked-uri': 'https://evil.com/script.js', + 'source-file': 'https://example.com/app.js', + 'line-number': 42, + 'status-code': 200, + }, +}; + +describe('CSP Report Endpoint (e2e)', () => { + let app: INestApplication; + + const originalEnv = { + API_RATE_LIMIT: process.env.API_RATE_LIMIT, + THROTTLE_TTL: process.env.THROTTLE_TTL, + CORS_ORIGINS: process.env.CORS_ORIGINS, + }; + + beforeAll(async () => { + process.env.API_RATE_LIMIT = '1000'; + process.env.THROTTLE_TTL = '60000'; + process.env.CORS_ORIGINS = 'http://localhost:3000'; + + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + + process.env.API_RATE_LIMIT = originalEnv.API_RATE_LIMIT; + process.env.THROTTLE_TTL = originalEnv.THROTTLE_TTL; + process.env.CORS_ORIGINS = originalEnv.CORS_ORIGINS; + }); + + describe('POST /api/v1/csp-report', () => { + it('should accept a valid CSP report and return 204', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(validCspReport); + + expect(response.status).toBe(204); + expect(response.body).toEqual({}); + }); + + it('should accept CSP report with application/json content type', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/json') + .send(validCspReport); + + expect(response.status).toBe(204); + }); + + it('should handle duplicate reports (idempotency)', async () => { + const uniqueReport = { + 'csp-report': { + ...validCspReport['csp-report'], + 'source-file': `https://example.com/unique-${Date.now()}.js`, + }, + }; + + // First submission + const first = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(uniqueReport); + + expect(first.status).toBe(204); + + // Second submission (duplicate) + const second = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(uniqueReport); + + expect(second.status).toBe(204); + }); + + it('should accept minimal CSP report', async () => { + const minimalReport = { + 'csp-report': { + 'violated-directive': 'default-src', + }, + }; + + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(minimalReport); + + expect(response.status).toBe(204); + }); + + it('should reject report without csp-report wrapper', async () => { + const invalidReport = { + 'violated-directive': 'script-src', + 'blocked-uri': 'https://evil.com/script.js', + }; + + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(invalidReport); + + expect(response.status).toBe(400); + }); + + it('should reject reports exceeding 8KiB', async () => { + const largeReport = { + 'csp-report': { + ...validCspReport['csp-report'], + 'script-sample': 'x'.repeat(10 * 1024), // 10KiB of data + }, + }; + + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(largeReport); + + expect(response.status).toBe(413); + }); + + it('should be accessible without authentication (public endpoint)', async () => { + // No Authorization header, no API key + const response = await request(app.getHttpServer()) + .post('/api/v1/csp-report') + .set('Content-Type', 'application/csp-report') + .send(validCspReport); + + // Should not return 401 or 403 + expect(response.status).not.toBe(401); + expect(response.status).not.toBe(403); + expect(response.status).toBe(204); + }); + }); +});