@@ -80,21 +80,31 @@ function getCorsHeaders(
8080// The envelope DSN is validated against env.SENTRY_DSN to prevent open proxy abuse.
8181const SENTRY_ENVELOPE_MAX_BYTES = 256 * 1024 ; // 256 KB — Sentry rejects >200KB compressed
8282
83- let _dsnCache : { dsn : string ; parsed : { host : string ; projectId : string } | null } | undefined ;
83+ interface ParsedDsn { host : string ; projectId : string ; publicKey : string }
8484
85- /** Parse host and project ID from a Sentry DSN URL. Returns null if invalid. */
86- function parseSentryDsn ( dsn : string ) : { host : string ; projectId : string } | null {
85+ let _dsnCache : { dsn : string ; parsed : ParsedDsn | null } | undefined ;
86+
87+ /** Parse host, project ID, and public key from a Sentry DSN URL. Returns null if invalid. */
88+ function parseSentryDsn ( dsn : string ) : ParsedDsn | null {
8789 if ( ! dsn ) return null ;
8890 try {
8991 const url = new URL ( dsn ) ;
9092 const projectId = url . pathname . split ( "/" ) . filter ( Boolean ) . pop ( ) ?? "" ;
91- if ( ! url . hostname || ! projectId ) return null ;
92- return { host : url . hostname , projectId } ;
93+ if ( ! url . hostname || ! projectId || ! url . username ) return null ;
94+ return { host : url . hostname , projectId, publicKey : url . username } ;
9395 } catch {
9496 return null ;
9597 }
9698}
9799
100+ /** Get cached parsed DSN, re-parsing only when the DSN string changes. */
101+ function getOrCacheDsn ( env : Env ) : ParsedDsn | null {
102+ if ( ! _dsnCache || _dsnCache . dsn !== env . SENTRY_DSN ) {
103+ _dsnCache = { dsn : env . SENTRY_DSN , parsed : parseSentryDsn ( env . SENTRY_DSN ) } ;
104+ }
105+ return _dsnCache . parsed ;
106+ }
107+
98108async function handleSentryTunnel (
99109 request : Request ,
100110 env : Env ,
@@ -103,10 +113,7 @@ async function handleSentryTunnel(
103113 return new Response ( null , { status : 405 , headers : SECURITY_HEADERS } ) ;
104114 }
105115
106- if ( ! _dsnCache || _dsnCache . dsn !== env . SENTRY_DSN ) {
107- _dsnCache = { dsn : env . SENTRY_DSN , parsed : parseSentryDsn ( env . SENTRY_DSN ) } ;
108- }
109- const allowedDsn = _dsnCache . parsed ;
116+ const allowedDsn = getOrCacheDsn ( env ) ;
110117 if ( ! allowedDsn ) {
111118 log ( "warn" , "sentry_tunnel_not_configured" , { } , request ) ;
112119 return new Response ( null , { status : 404 , headers : SECURITY_HEADERS } ) ;
@@ -186,6 +193,106 @@ async function handleSentryTunnel(
186193 }
187194}
188195
196+ // ── CSP report tunnel ────────────────────────────────────────────────────
197+ // Receives browser CSP violation reports, scrubs OAuth params from URLs,
198+ // then forwards to Sentry's security ingest endpoint.
199+ const CSP_REPORT_MAX_BYTES = 64 * 1024 ;
200+ const CSP_OAUTH_PARAMS_RE = / ( [ ? & ] ) ( c o d e | s t a t e | a c c e s s _ t o k e n ) = [ ^ & \s ] * / g;
201+
202+ function scrubReportUrl ( url : unknown ) : string | undefined {
203+ if ( typeof url !== "string" ) return undefined ;
204+ return url . replace ( CSP_OAUTH_PARAMS_RE , "$1$2=[REDACTED]" ) ;
205+ }
206+
207+ function scrubCspReportBody ( body : Record < string , unknown > ) : Record < string , unknown > {
208+ const scrubbed = { ...body } ;
209+ // Legacy report-uri format uses kebab-case keys
210+ for ( const key of [ "document-uri" , "blocked-uri" , "source-file" , "referrer" ] ) {
211+ if ( typeof scrubbed [ key ] === "string" ) scrubbed [ key ] = scrubReportUrl ( scrubbed [ key ] ) ;
212+ }
213+ // report-to format uses camelCase keys
214+ for ( const key of [ "documentURL" , "blockedURL" , "sourceFile" , "referrer" ] ) {
215+ if ( typeof scrubbed [ key ] === "string" ) scrubbed [ key ] = scrubReportUrl ( scrubbed [ key ] ) ;
216+ }
217+ return scrubbed ;
218+ }
219+
220+ async function handleCspReport ( request : Request , env : Env ) : Promise < Response > {
221+ if ( request . method !== "POST" ) {
222+ return new Response ( null , { status : 405 , headers : SECURITY_HEADERS } ) ;
223+ }
224+
225+ const allowedDsn = getOrCacheDsn ( env ) ;
226+ if ( ! allowedDsn ) {
227+ return new Response ( null , { status : 404 , headers : SECURITY_HEADERS } ) ;
228+ }
229+
230+ let bodyText : string ;
231+ try {
232+ bodyText = await request . text ( ) ;
233+ } catch {
234+ return new Response ( null , { status : 400 , headers : SECURITY_HEADERS } ) ;
235+ }
236+
237+ if ( bodyText . length > CSP_REPORT_MAX_BYTES ) {
238+ log ( "warn" , "csp_report_too_large" , { body_length : bodyText . length } , request ) ;
239+ return new Response ( null , { status : 413 , headers : SECURITY_HEADERS } ) ;
240+ }
241+
242+ const contentType = request . headers . get ( "Content-Type" ) ?? "" ;
243+ let scrubbedPayloads : Array < Record < string , unknown > > = [ ] ;
244+
245+ try {
246+ if ( contentType . includes ( "application/reports+json" ) ) {
247+ // report-to format: array of report objects
248+ const reports = JSON . parse ( bodyText ) as Array < { type ?: string ; body ?: Record < string , unknown > } > ;
249+ for ( const report of reports ) {
250+ if ( report . type === "csp-violation" && report . body ) {
251+ scrubbedPayloads . push ( { "csp-report" : scrubCspReportBody ( report . body ) } ) ;
252+ }
253+ }
254+ } else {
255+ // Legacy report-uri format: { "csp-report": { ... } }
256+ const parsed = JSON . parse ( bodyText ) as { "csp-report" ?: Record < string , unknown > } ;
257+ if ( parsed [ "csp-report" ] ) {
258+ scrubbedPayloads . push ( { "csp-report" : scrubCspReportBody ( parsed [ "csp-report" ] ) } ) ;
259+ }
260+ }
261+ } catch {
262+ log ( "warn" , "csp_report_parse_failed" , { } , request ) ;
263+ return new Response ( null , { status : 400 , headers : SECURITY_HEADERS } ) ;
264+ }
265+
266+ if ( scrubbedPayloads . length === 0 ) {
267+ return new Response ( null , { status : 204 , headers : SECURITY_HEADERS } ) ;
268+ }
269+
270+ // Cap fan-out to prevent amplification from crafted report-to batches
271+ if ( scrubbedPayloads . length > 20 ) {
272+ scrubbedPayloads = scrubbedPayloads . slice ( 0 , 20 ) ;
273+ }
274+
275+ // Sentry security endpoint expects individual csp-report JSON objects
276+ const sentryUrl = `https://${ allowedDsn . host } /api/${ allowedDsn . projectId } /security/?sentry_key=${ allowedDsn . publicKey } ` ;
277+
278+ const results = await Promise . all (
279+ scrubbedPayloads . map ( ( payload ) =>
280+ fetch ( sentryUrl , {
281+ method : "POST" ,
282+ headers : { "Content-Type" : "application/csp-report" } ,
283+ body : JSON . stringify ( payload ) ,
284+ } ) . catch ( ( ) => null )
285+ )
286+ ) ;
287+
288+ log ( "info" , "csp_report_forwarded" , {
289+ count : scrubbedPayloads . length ,
290+ sentry_ok : results . some ( ( r ) => r ?. ok ) ,
291+ } , request ) ;
292+
293+ return new Response ( null , { status : 204 , headers : SECURITY_HEADERS } ) ;
294+ }
295+
189296// GitHub OAuth code format validation (SDR-005): alphanumeric, 1-40 chars.
190297// GitHub's code format is undocumented and has changed historically — validate
191298// loosely here; GitHub's server validates the actual code.
@@ -350,6 +457,11 @@ export default {
350457 return handleSentryTunnel ( request , env ) ;
351458 }
352459
460+ // CSP report tunnel — scrubs OAuth params before forwarding to Sentry
461+ if ( url . pathname === "/api/csp-report" ) {
462+ return handleCspReport ( request , env ) ;
463+ }
464+
353465 if ( url . pathname === "/api/oauth/token" ) {
354466 return handleTokenExchange ( request , env , cors ) ;
355467 }
0 commit comments