Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -114,6 +115,7 @@ import { SandboxModule } from './sandbox/sandbox.module';
EntityLinkingModule,
DeploymentMetadataModule,
SandboxModule,
CspReportModule,
RedisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand Down
73 changes: 73 additions & 0 deletions app/backend/src/csp/csp-report.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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']);
}
}
12 changes: 12 additions & 0 deletions app/backend/src/csp/csp-report.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
80 changes: 80 additions & 0 deletions app/backend/src/csp/csp-report.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
const existing = await this.redis.get<number>(key);
if (existing !== null) {
return true;
}

await this.redis.set(key, 1, DEDUP_TTL_SECONDS);
return false;
}
}
126 changes: 126 additions & 0 deletions app/backend/src/csp/dto/csp-report.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions app/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');

Expand Down
7 changes: 7 additions & 0 deletions app/backend/src/observability/metrics/metrics.providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}),
];
9 changes: 9 additions & 0 deletions app/backend/src/observability/metrics/metrics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export class MetricsService {
public emailDeliveryCounter: Counter<string>,
@InjectMetric('email_delivery_duration_seconds')
public emailDeliveryDuration: Histogram<string>,
@InjectMetric('csp_violations_total')
public cspViolationsCounter: Counter<string>,
) {}

/**
Expand Down Expand Up @@ -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) });
}
}
Loading
Loading