Skip to content

Commit 220eae6

Browse files
authored
fix(node-adapter): harden Node adapter request parsing
Handle mixed string and binary request body chunks without corrupting UTF-8 decoder state. Empty string chunks preserve pending decoder state, while non-empty string chunks flush before appending to keep stream order intact. Sanitize invalid Host headers before constructing forwarded Request URLs while preserving valid hosts that URL parsing canonicalizes.
1 parent 3832d4c commit 220eae6

4 files changed

Lines changed: 159 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,8 @@ For now, you will still have the old interfaces available to ease the migration,
227227
### Features
228228

229229
* Update to 0.10.1 of the schema ([#36](https://github.com/agentclientprotocol/typescript-sdk/issues/36)) ([210392b](https://github.com/agentclientprotocol/typescript-sdk/commit/210392bfdcb95d2f515784af914323d2606194f6))
230-
* Unstable: add unstable forkSession support [#37](https://github.com/agentclientprotocol/typescript-sdk/pull/37)
230+
* Unstable: add unstable forkSession support ([#37](https://github.com/agentclientprotocol/typescript-sdk/pull/37)) ([16262ef
231+
](https://github.com/agentclientprotocol/typescript-sdk/commit/16262ef7b52892f935aa7fb39d98657895345ff4))
231232

232233
## [0.8.0](https://github.com/agentclientprotocol/typescript-sdk/compare/v0.7.0...v0.8.0) (2025-12-08)
233234

@@ -252,7 +253,7 @@ This update provides much improved schema interfaces. The migration should be mi
252253
## 0.5.1 (2025-10-24)
253254

254255
- Add ability for agents and clients to provide information about their implementation
255-
- Fix incorrectly serialized `_meta` field on `SetSessionModeResponse
256+
- Fix incorrectly serialized `_meta` field on `SetSessionModeResponse`
256257

257258
## 0.5.0 (2025-10-24)
258259

src/examples/ws-client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const authHeaders = {
4141
// uses the platform cookie jar; Node's `ws` uses constructor headers populated
4242
// from this store.
4343
const cookieStore = new MemoryAcpCookieStore();
44-
let savedSessionId: string | undefined;
44+
let reconnectSessionId: string | undefined;
4545

4646
function connect(): {
4747
readonly stream: acp.Stream;
@@ -77,7 +77,7 @@ try {
7777
cwd: process.cwd(),
7878
mcpServers: [],
7979
});
80-
savedSessionId = session.sessionId;
80+
reconnectSessionId = session.sessionId;
8181

8282
const result = await ctx.request(acp.methods.agent.session.prompt, {
8383
sessionId: session.sessionId,
@@ -94,7 +94,7 @@ try {
9494

9595
console.log(`\nDone: ${result.stopReason}`);
9696
console.log(
97-
`Saved session ${savedSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`,
97+
`Saved session ${reconnectSessionId}; loadSession=${initialized.agentCapabilities?.loadSession === true}`,
9898
);
9999

100100
// Reconnect flow sketch:

src/node-adapter.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ describe("createNodeHttpHandler", () => {
233233
},
234234
bodyChunks: [
235235
bodyBytes.slice(0, splitIndex + 1),
236+
"",
236237
bodyBytes.slice(splitIndex + 1),
237238
],
238239
}),
@@ -245,6 +246,99 @@ describe("createNodeHttpHandler", () => {
245246
expect(seenBodies).toEqual([body]);
246247
});
247248

249+
it("flushes pending UTF-8 bytes before non-empty string chunks", async () => {
250+
const acpServer = new AcpServer({
251+
createAgent: () => createTestAgentApp(),
252+
});
253+
const seenBodies: string[] = [];
254+
const response = new CapturingServerResponse();
255+
256+
acpServer.handleRequest = async (req) => {
257+
seenBodies.push(await req.text());
258+
return new Response("ok");
259+
};
260+
261+
createNodeHttpHandler(acpServer)(
262+
fakeRequest({
263+
method: "POST",
264+
headers: {
265+
"content-type": "text/plain",
266+
},
267+
bodyChunks: [
268+
new Uint8Array([0xf0]),
269+
"x",
270+
new Uint8Array([0x9f, 0x9a, 0x80]),
271+
],
272+
}),
273+
response as unknown as ServerResponse,
274+
);
275+
276+
await response.finished;
277+
278+
expect(response.statusCode).toBe(200);
279+
expect(seenBodies).toEqual(["\uFFFDx\uFFFD\uFFFD\uFFFD"]);
280+
});
281+
282+
it("falls back to localhost for invalid request host headers", async () => {
283+
const acpServer = new AcpServer({
284+
createAgent: () => createTestAgentApp(),
285+
});
286+
const seenUrls: string[] = [];
287+
const response = new CapturingServerResponse();
288+
289+
acpServer.handleRequest = (req) => {
290+
seenUrls.push(req.url);
291+
return Promise.resolve(new Response("ok"));
292+
};
293+
294+
createNodeHttpHandler(acpServer)(
295+
fakeRequest({
296+
headers: {
297+
host: "example.com/@internal",
298+
},
299+
}),
300+
response as unknown as ServerResponse,
301+
);
302+
303+
await response.finished;
304+
305+
expect(response.statusCode).toBe(200);
306+
expect(seenUrls).toEqual(["http://localhost/acp"]);
307+
});
308+
309+
it("preserves valid request host headers that URL parsing canonicalizes", async () => {
310+
const acpServer = new AcpServer({
311+
createAgent: () => createTestAgentApp(),
312+
});
313+
const seenUrls: string[] = [];
314+
315+
acpServer.handleRequest = (req) => {
316+
seenUrls.push(req.url);
317+
return Promise.resolve(new Response("ok"));
318+
};
319+
320+
for (const host of ["EXAMPLE.com", "example.com:80", "[::1]:80"]) {
321+
const response = new CapturingServerResponse();
322+
323+
createNodeHttpHandler(acpServer)(
324+
fakeRequest({
325+
headers: { host },
326+
}),
327+
response as unknown as ServerResponse,
328+
);
329+
330+
await response.finished;
331+
332+
expect(response.statusCode).toBe(200);
333+
}
334+
335+
expect(seenUrls).toEqual([
336+
"http://example.com/acp",
337+
"http://example.com/acp",
338+
"http://[::1]/acp",
339+
]);
340+
});
341+
248342
it("rejects request bodies when Content-Length exceeds the configured limit", async () => {
249343
const acpServer = new AcpServer({
250344
createAgent: () => createTestAgentApp(),
@@ -491,7 +585,7 @@ function fakeRequest(
491585
options: {
492586
readonly method?: string;
493587
readonly headers?: Record<string, string>;
494-
readonly bodyChunks?: readonly Uint8Array[];
588+
readonly bodyChunks?: readonly (Uint8Array | string)[];
495589
} = {},
496590
): IncomingMessage {
497591
const request = Object.assign(Readable.from(options.bodyChunks ?? []), {

src/node-adapter.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,10 @@ async function readRequestBody(
280280
}
281281

282282
if (typeof chunk === "string") {
283-
body += decoder.decode();
283+
body +=
284+
chunk.length === 0
285+
? decoder.decode(new Uint8Array(), { stream: true })
286+
: decoder.decode();
284287
body += chunk;
285288
return;
286289
}
@@ -377,8 +380,61 @@ class RequestBodyTooLargeError extends Error {
377380
}
378381
}
379382

383+
function sanitizedRequestHost(req: IncomingMessage): string {
384+
const hostHeader = req.headers.host;
385+
const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader;
386+
387+
if (host === undefined) {
388+
return "localhost";
389+
}
390+
391+
const normalized = host.trim();
392+
393+
if (normalized.length === 0 || hasInvalidRequestHostCharacter(normalized)) {
394+
return "localhost";
395+
}
396+
397+
try {
398+
const parsed = new URL(`http://${normalized}`);
399+
if (
400+
parsed.username !== "" ||
401+
parsed.password !== "" ||
402+
parsed.pathname !== "/" ||
403+
parsed.search !== "" ||
404+
parsed.hash !== ""
405+
) {
406+
return "localhost";
407+
}
408+
} catch {
409+
return "localhost";
410+
}
411+
412+
return normalized;
413+
}
414+
415+
function hasInvalidRequestHostCharacter(host: string): boolean {
416+
for (const char of host) {
417+
const codePoint = char.codePointAt(0);
418+
if (
419+
codePoint === undefined ||
420+
codePoint <= 0x1f ||
421+
codePoint === 0x7f ||
422+
char.trim() === "" ||
423+
char === "/" ||
424+
char === "?" ||
425+
char === "#" ||
426+
char === "@" ||
427+
char === "\\"
428+
) {
429+
return true;
430+
}
431+
}
432+
433+
return false;
434+
}
435+
380436
function nodeRequestUrl(req: IncomingMessage): string {
381-
const host = req.headers.host ?? "localhost";
437+
const host = sanitizedRequestHost(req);
382438
return `http://${host}${req.url ?? "/"}`;
383439
}
384440

0 commit comments

Comments
 (0)