Skip to content

Commit ac89241

Browse files
committed
Security: harden Content-Security-Policy and verify report pipeline
1 parent 98237f0 commit ac89241

3 files changed

Lines changed: 80 additions & 3 deletions

File tree

app/api/csp-report/route.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,53 @@
11
import { NextRequest, NextResponse } from 'next/server';
2+
import { z } from 'zod';
23

34
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
5+
const CSP_INTERNAL_TOKEN = process.env.CSP_INTERNAL_TOKEN;
6+
7+
const MAX_CONTENT_LENGTH_BYTES = 16 * 1024;
8+
const MAX_REPORTS_PER_REQUEST = 20;
9+
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
10+
const MAX_REPORTS_PER_IP_PER_WINDOW = 60;
11+
12+
const ipWindow = new Map<string, { count: number; windowStart: number }>();
13+
14+
const CspReportSchema = z.object({
15+
'document-uri': z.string().url().max(2048),
16+
'violated-directive': z.string().min(1).max(256),
17+
'blocked-uri': z.string().max(2048).optional(),
18+
'source-file': z.string().max(2048).optional(),
19+
'line-number': z.number().int().nonnegative().max(10_000_000).optional(),
20+
'column-number': z.number().int().nonnegative().max(10_000_000).optional(),
21+
'disposition': z.enum(['enforce', 'report']).optional(),
22+
'status-code': z.number().int().min(100).max(599).optional(),
23+
'script-sample': z.string().max(2000).optional(),
24+
}).strict();
25+
26+
function getClientIp(req: NextRequest): string {
27+
return (
28+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
29+
req.headers.get('x-real-ip') ||
30+
'unknown'
31+
);
32+
}
33+
34+
function isRateLimited(ip: string): boolean {
35+
const now = Date.now();
36+
const current = ipWindow.get(ip);
37+
38+
if (!current || now - current.windowStart > RATE_LIMIT_WINDOW_MS) {
39+
ipWindow.set(ip, { count: 1, windowStart: now });
40+
return false;
41+
}
42+
43+
if (current.count >= MAX_REPORTS_PER_IP_PER_WINDOW) {
44+
return true;
45+
}
46+
47+
current.count += 1;
48+
ipWindow.set(ip, current);
49+
return false;
50+
}
451

552
const EXTENSION_BLOCKLIST = [
653
'chrome-extension://',
@@ -75,6 +122,17 @@ function normalizeCspReport(body: unknown): {
75122

76123
export async function POST(req: NextRequest) {
77124
try {
125+
const contentLengthHeader = req.headers.get('content-length');
126+
const contentLength = contentLengthHeader ? Number(contentLengthHeader) : 0;
127+
if (Number.isFinite(contentLength) && contentLength > MAX_CONTENT_LENGTH_BYTES) {
128+
return new NextResponse(null, { status: 204 });
129+
}
130+
131+
const clientIp = getClientIp(req);
132+
if (isRateLimited(clientIp)) {
133+
return new NextResponse(null, { status: 204 });
134+
}
135+
78136
let rawBody: unknown;
79137

80138
try {
@@ -85,9 +143,16 @@ export async function POST(req: NextRequest) {
85143

86144
// Handle application/reports+json (array of reports) or single report
87145
const reports: unknown[] = Array.isArray(rawBody) ? rawBody : [rawBody];
146+
const boundedReports = reports.slice(0, MAX_REPORTS_PER_REQUEST);
147+
148+
for (const item of boundedReports) {
149+
const rawReport = normalizeCspReport(item);
150+
if (!rawReport) continue;
151+
152+
const parsed = CspReportSchema.safeParse(rawReport);
153+
if (!parsed.success) continue;
88154

89-
for (const item of reports) {
90-
const report = normalizeCspReport(item);
155+
const report = parsed.data;
91156
if (!report) continue;
92157

93158
if (!report['document-uri'] || !report['violated-directive']) continue;
@@ -109,6 +174,7 @@ export async function POST(req: NextRequest) {
109174
headers: {
110175
'Content-Type': 'application/json',
111176
'x-internal-request': 'true',
177+
...(CSP_INTERNAL_TOKEN ? { 'x-csp-internal-token': CSP_INTERNAL_TOKEN } : {}),
112178
},
113179
body: JSON.stringify({ report, context }),
114180
}).catch(() => {

backend/src/routes/csp-violations.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { requireAdmin } from '../middleware/admin';
2222
import logger from '../config/logger';
2323

2424
const router: Router = Router();
25+
const CSP_INTERNAL_TOKEN = process.env.CSP_INTERNAL_TOKEN;
2526

2627
/**
2728
* Validation schema for CSP violation report
@@ -64,6 +65,16 @@ router.post('/', async (req: Request, res: Response) => {
6465
});
6566
}
6667

68+
if (CSP_INTERNAL_TOKEN) {
69+
const providedToken = req.headers['x-csp-internal-token'];
70+
if (providedToken !== CSP_INTERNAL_TOKEN) {
71+
return res.status(403).json({
72+
success: false,
73+
error: 'Invalid internal token',
74+
});
75+
}
76+
}
77+
6778
// Validate request body
6879
const result = CspViolationSchema.safeParse(req.body);
6980
if (!result.success) {

client/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function generateCSP(
3232
const cspHeader = [
3333
`default-src 'self'`,
3434
`script-src 'self' 'nonce-${nonce}' ${isDev ? "'unsafe-eval'" : "'strict-dynamic'"}`,
35-
`style-src 'self' 'unsafe-inline'`, // 'unsafe-inline' is needed for Tailwind/CSS-in-JS, nonce would disable it
35+
`style-src 'self' 'nonce-${nonce}'${isDev ? " 'unsafe-inline'" : ''}`,
3636
`img-src 'self' blob: data: https://res.cloudinary.com https://*.supabase.co https://ui-avatars.com`,
3737
`font-src 'self' data:`,
3838
`connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com https://*.stellar.org`,

0 commit comments

Comments
 (0)