Skip to content

Commit b6666de

Browse files
authored
fix(api): strip hop by hop headers when proxying agent requests (#206)
1 parent ddbb640 commit b6666de

2 files changed

Lines changed: 97 additions & 4 deletions

File tree

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

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,43 @@ import { detectRequestLocation } from "../server-helper";
66
import { generateAgentInvocationToken } from "./agents/me/me.server";
77
import { handleSlackWebhook, isSlackRequest } from "./agents/slack-webhook";
88

9+
const HOP_BY_HOP_HEADERS = new Set([
10+
"connection",
11+
"keep-alive",
12+
"proxy-authenticate",
13+
"proxy-authorization",
14+
"proxy-connection",
15+
"te",
16+
"trailer",
17+
"transfer-encoding",
18+
"upgrade",
19+
]);
20+
21+
const stripHopByHopHeaders = (headers: Headers): Headers => {
22+
const connectionTokens = new Set<string>();
23+
headers.forEach((value, key) => {
24+
if (key.toLowerCase() !== "connection") {
25+
return;
26+
}
27+
for (const token of value.split(",")) {
28+
const trimmed = token.trim().toLowerCase();
29+
if (trimmed) {
30+
connectionTokens.add(trimmed);
31+
}
32+
}
33+
});
34+
35+
const sanitized = new Headers();
36+
headers.forEach((value, key) => {
37+
const lowerKey = key.toLowerCase();
38+
if (HOP_BY_HOP_HEADERS.has(lowerKey) || connectionTokens.has(lowerKey)) {
39+
return;
40+
}
41+
sanitized.set(key, value);
42+
});
43+
return sanitized;
44+
};
45+
946
export type AgentRequestRouting =
1047
| { mode: "webhook"; subpath?: string }
1148
| { mode: "subdomain" };
@@ -67,6 +104,10 @@ export default async function handleAgentRequest(
67104
}
68105
// Ensure we preserve the search params.
69106
url.search = incomingUrl.search;
107+
// Ensure protocol is http/https for fetch.
108+
if (url.protocol !== "http:" && url.protocol !== "https:") {
109+
url.protocol = "http:";
110+
}
70111

71112
let contentLength: number | undefined;
72113
const contentLengthRaw = c.req.raw.headers.get("content-length");
@@ -176,19 +217,26 @@ export default async function handleAgentRequest(
176217
agent_deployment_target_id: query.agent_deployment.target_id,
177218
})
178219
);
220+
const sanitizedHeaders = stripHopByHopHeaders(headers);
179221

180222
let response: Response | undefined;
181223
let error: string | undefined;
182224
try {
183225
// Use the body we already read if it's a Slack request, otherwise use the stream
226+
const hasBody =
227+
c.req.raw.method !== "GET" &&
228+
c.req.raw.method !== "HEAD" &&
229+
c.req.raw.method !== "OPTIONS";
184230
const bodyToSend =
185231
requestBodyText !== undefined ? requestBodyText : c.req.raw.body;
186-
response = await fetch(url, {
187-
body: bodyToSend,
232+
const request = new Request(url.toString(), {
188233
method: c.req.raw.method,
189-
signal,
190-
headers,
234+
headers: sanitizedHeaders,
235+
body: hasBody ? bodyToSend : undefined,
236+
// @ts-expect-error - Required for Node.js streaming.
237+
duplex: hasBody ? "half" : undefined,
191238
});
239+
response = await fetch(request, { signal, redirect: "manual" });
192240
} catch (err) {
193241
error = err instanceof Error ? err.message : JSON.stringify(err);
194242
}

internal/api/src/routes/agent-request.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,51 @@ describe("webhook requests (/api/webhook/:id)", () => {
408408
);
409409
expect(response.headers.get("x-frame-options")).toBe("DENY");
410410
});
411+
412+
test("proxies chunked webhook bodies to the agent", async () => {
413+
const payload = JSON.stringify({
414+
type: "event_callback",
415+
event: { type: "message", text: "streamed hello" },
416+
});
417+
const splitIndex = Math.ceil(payload.length / 2);
418+
const chunks = [payload.slice(0, splitIndex), payload.slice(splitIndex)];
419+
const encoder = new TextEncoder();
420+
let chunkIndex = 0;
421+
422+
const bodyStream = new ReadableStream<Uint8Array>({
423+
pull(controller) {
424+
if (chunkIndex >= chunks.length) {
425+
controller.close();
426+
return;
427+
}
428+
controller.enqueue(encoder.encode(chunks[chunkIndex]));
429+
chunkIndex += 1;
430+
},
431+
});
432+
433+
let receivedBody: string | undefined;
434+
435+
using agent = await setupAgent({
436+
name: "slack-streaming",
437+
handler: async (req) => {
438+
receivedBody = await req.text();
439+
return new Response("OK");
440+
},
441+
});
442+
443+
const response = await fetch(agent.getWebhookUrl("/"), {
444+
method: "POST",
445+
headers: {
446+
"content-type": "application/json",
447+
},
448+
body: bodyStream,
449+
});
450+
451+
const responseText = await response.text();
452+
expect(response.status).toBe(200);
453+
expect(responseText).toBe("OK");
454+
expect(receivedBody).toBe(payload);
455+
});
411456
});
412457

413458
describe("Slack request Content-Length handling", () => {

0 commit comments

Comments
 (0)