Skip to content

Commit ea06a17

Browse files
authored
feat: subpath-based agent webhooks (#102)
1 parent a8d19d5 commit ea06a17

File tree

3 files changed

+531
-160
lines changed

3 files changed

+531
-160
lines changed

packages/api/src/routes/agent-request.server.ts

Lines changed: 69 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import type { Bindings } from "../server";
44
import { detectRequestLocation } from "../server-helper";
55
import { generateAgentInvocationToken } from "./agents/me/me.server";
66

7+
export type AgentRequestRouting =
8+
| { mode: "webhook"; subpath?: string }
9+
| { mode: "subdomain" };
10+
711
export 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

Comments
 (0)