11import { NextRequest , NextResponse } from 'next/server' ;
2+ import { z } from 'zod' ;
23
34const 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
552const EXTENSION_BLOCKLIST = [
653 'chrome-extension://' ,
@@ -75,6 +122,17 @@ function normalizeCspReport(body: unknown): {
75122
76123export 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 ( ( ) => {
0 commit comments