@@ -4,10 +4,14 @@ import type { Bindings } from "../server";
44import { detectRequestLocation } from "../server-helper" ;
55import { generateAgentInvocationToken } from "./agents/me/me.server" ;
66
7+ export type AgentRequestRouting =
8+ | { mode : "webhook" ; subpath ?: string }
9+ | { mode : "subdomain" } ;
10+
711export default async function handleAgentRequest (
812 c : Context < { Bindings : Bindings } > ,
913 id : string ,
10- legacy ?: boolean
14+ routing : AgentRequestRouting
1115) {
1216 const db = await c . env . database ( ) ;
1317 const query = await db . selectAgentDeploymentByRequestID ( id ) ;
@@ -37,8 +41,8 @@ export default async function handleAgentRequest(
3741 const incomingUrl = new URL ( c . req . raw . url ) ;
3842
3943 let url : URL ;
40- if ( legacy ) {
41- url = new URL ( "/webhook" + incomingUrl . search , directAccessURL ) ;
44+ if ( routing . mode === "webhook" ) {
45+ url = new URL ( routing . subpath || "/" , directAccessURL ) ;
4246 } else {
4347 url = new URL ( incomingUrl . pathname , directAccessURL ) ;
4448 }
@@ -49,7 +53,7 @@ export default async function handleAgentRequest(
4953 const contentLengthRaw = c . req . raw . headers . get ( "content-length" ) ;
5054 if ( contentLengthRaw ) {
5155 contentLength = Number ( contentLengthRaw ) ;
52- if ( isNaN ( contentLength ) ) {
56+ if ( Number . isNaN ( contentLength ) ) {
5357 contentLength = undefined ;
5458 }
5559 }
@@ -73,7 +77,7 @@ export default async function handleAgentRequest(
7377 const pathWithQuery = incomingUrl . pathname + incomingUrl . search ;
7478 const truncatedPath =
7579 pathWithQuery . length > 80
76- ? pathWithQuery . slice ( 0 , 80 ) + " ..."
80+ ? ` ${ pathWithQuery . slice ( 0 , 80 ) } ...`
7781 : pathWithQuery ;
7882
7983 // Extract useful headers for logging (not sensitive ones)
@@ -125,18 +129,15 @@ export default async function handleAgentRequest(
125129 } )
126130 ) ;
127131
128- let requestBodyPromise : Promise < ReadBodyResult | undefined > | undefined ;
129- let upstreamBody : ReadableStream | undefined ;
130- if ( c . req . raw . body ) {
131- let downstreamBody : ReadableStream ;
132- [ upstreamBody , downstreamBody ] = c . req . raw . body . tee ( ) ;
133- requestBodyPromise = readBody ( c . req . raw . headers , downstreamBody , 64 * 1024 ) ;
134- }
135-
136132 const headers = new Headers ( ) ;
137133 c . req . raw . headers . forEach ( ( value , key ) => {
138134 headers . set ( key , value ) ;
139135 } ) ;
136+ // Strip cookies from webhook requests to prevent session leakage
137+ // Subdomain requests are on a different origin, so cookies won't be sent anyway
138+ if ( routing . mode === "webhook" ) {
139+ headers . delete ( "cookie" ) ;
140+ }
140141 headers . set (
141142 BlinkInvocationTokenHeader ,
142143 await generateAgentInvocationToken ( c . env . AUTH_SECRET , {
@@ -150,7 +151,7 @@ export default async function handleAgentRequest(
150151 let error : string | undefined ;
151152 try {
152153 response = await fetch ( url , {
153- body : upstreamBody ,
154+ body : c . req . raw . body ,
154155 method : c . req . raw . method ,
155156 signal,
156157 headers,
@@ -162,15 +163,60 @@ export default async function handleAgentRequest(
162163 const agentID = query . agent_deployment . agent_id ;
163164 const deploymentID = query . agent_deployment . id ;
164165
165- let responseBodyPromise : Promise < ReadBodyResult | undefined > | undefined ;
166- if ( response && response . body ) {
167- const [ toClient , toLog ] = response . body . tee ( ) ;
168- responseBodyPromise = readBody ( response . headers , toLog , 64 * 1024 ) ;
169- response = new Response ( toClient , {
170- status : response . status ,
171- statusText : response . statusText ,
172- headers : response . headers ,
173- } ) ;
166+ if ( response ) {
167+ // Strip sensitive headers from webhook responses to prevent:
168+ // - Session hijacking via set-cookie
169+ // - Permissive CORS policies that could expose user data
170+ // - XSS attacks via HTML responses
171+ // - Open redirects via Location header
172+ // Subdomain requests are on a different origin, so these don't apply
173+ if ( routing . mode === "webhook" ) {
174+ const responseHeaders = new Headers ( response . headers ) ;
175+ responseHeaders . delete ( "set-cookie" ) ;
176+ responseHeaders . delete ( "access-control-allow-origin" ) ;
177+ responseHeaders . delete ( "access-control-allow-credentials" ) ;
178+ responseHeaders . delete ( "access-control-allow-methods" ) ;
179+ responseHeaders . delete ( "access-control-allow-headers" ) ;
180+
181+ // Prevent open redirects - strip Location header
182+ responseHeaders . delete ( "location" ) ;
183+
184+ // Security headers to prevent XSS and other attacks
185+ // nosniff prevents browsers from MIME-sniffing responses
186+ responseHeaders . set ( "x-content-type-options" , "nosniff" ) ;
187+ // Restrictive CSP blocks all active content (scripts, styles, etc.)
188+ responseHeaders . set (
189+ "content-security-policy" ,
190+ "default-src 'none'; frame-ancestors 'none'"
191+ ) ;
192+ // Prevent clickjacking
193+ responseHeaders . set ( "x-frame-options" , "DENY" ) ;
194+
195+ // Filter CORS-related values from Vary header
196+ const vary = responseHeaders . get ( "vary" ) ;
197+ if ( vary ) {
198+ const corsVaryValues = [
199+ "origin" ,
200+ "access-control-request-method" ,
201+ "access-control-request-headers" ,
202+ ] ;
203+ const filtered = vary
204+ . split ( "," )
205+ . map ( ( v ) => v . trim ( ) )
206+ . filter ( ( v ) => ! corsVaryValues . includes ( v . toLowerCase ( ) ) ) ;
207+ if ( filtered . length > 0 ) {
208+ responseHeaders . set ( "vary" , filtered . join ( ", " ) ) ;
209+ } else {
210+ responseHeaders . delete ( "vary" ) ;
211+ }
212+ }
213+
214+ response = new Response ( response . body , {
215+ status : response . status ,
216+ statusText : response . statusText ,
217+ headers : responseHeaders ,
218+ } ) ;
219+ }
174220 }
175221
176222 const durationMs = Math . round ( performance . now ( ) - startTime ) ;
@@ -249,104 +295,3 @@ export default async function handleAgentRequest(
249295 ) ;
250296 }
251297}
252-
253- interface RedactHeadersResult {
254- headers : Record < string , string > ;
255- redacted : boolean ;
256- }
257-
258- // redactHeaders replaces sensitive headers with "REDACTED" and
259- // limits the number of headers to 100.
260- function redactHeaders ( incoming : Headers ) : RedactHeadersResult {
261- const headers : Record < string , string > = { } ;
262- let headerCount = 0 ;
263- let redacted = false ;
264- const sensitiveHeaders = [ "authorization" , "cookie" , "set-cookie" ] ;
265- incoming . forEach ( ( value , key ) => {
266- if ( headerCount >= 60 ) {
267- redacted = true ;
268- return ;
269- }
270- if ( key . length > 128 ) {
271- redacted = true ;
272- key = key . slice ( 0 , 128 ) ;
273- }
274- if ( value . length > 2048 ) {
275- redacted = true ;
276- value = value . slice ( 0 , 2048 ) + " ... [truncated]" ;
277- }
278- headerCount ++ ;
279- if ( sensitiveHeaders . includes ( key . toLowerCase ( ) ) ) {
280- headers [ key ] = "REDACTED" ;
281- } else {
282- headers [ key ] = value ;
283- }
284- } ) ;
285- return {
286- headers : headers ,
287- redacted,
288- } ;
289- }
290-
291- interface ReadBodyResult {
292- body : string ;
293- truncated : boolean ;
294- }
295-
296- async function readBody (
297- headers : Headers ,
298- body : ReadableStream ,
299- maxLength : number
300- ) : Promise < ReadBodyResult | undefined > {
301- if ( ! isTextual ( headers . get ( "content-type" ) ) ) {
302- // For non-textual content, cancel the stream immediately.
303- // We don't need to read it, just ensure it's canceled to signal
304- // to Cloudflare that we're not using this teed stream.
305- await body . cancel ( ) ;
306- return undefined ;
307- }
308- const reader = body . getReader ( ) ;
309- try {
310- const decoder = new TextDecoder ( ) ;
311- let result = "" ;
312- let totalRead = 0 ;
313- while ( true ) {
314- const { done, value } = await reader . read ( ) ;
315- if ( done ) {
316- break ;
317- }
318- const chunk = decoder . decode ( value , { stream : true } ) ;
319- result += chunk ;
320- totalRead += chunk . length ;
321- if ( totalRead > maxLength ) {
322- // Cancel the reader - we've read enough
323- await reader . cancel ( ) ;
324- return {
325- body : result ,
326- truncated : true ,
327- } ;
328- }
329- }
330- return {
331- body : result ,
332- truncated : false ,
333- } ;
334- } finally {
335- reader . releaseLock ( ) ;
336- }
337- }
338-
339- const isTextual = ( contentType : string | null ) => {
340- if ( ! contentType ) {
341- return false ;
342- }
343- const v = contentType . toLowerCase ( ) ;
344- return (
345- v . startsWith ( "text/" ) ||
346- v . includes ( "json" ) ||
347- v . includes ( "xml" ) ||
348- v . includes ( "x-www-form-urlencoded" ) ||
349- v . includes ( "graphql" ) ||
350- v . includes ( "cloudevents+json" )
351- ) ;
352- } ;
0 commit comments