From 3bfce1d7b43c882b6c4daf470b5733689da8d3bc Mon Sep 17 00:00:00 2001 From: Brian Corrigan <816+svperfecta@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:32:48 -0600 Subject: [PATCH] Treat content-length as transport-owned in both providers The fetch layer recomputes Bedrock's content-length from the serialized body, so a caller value would be covered by the SigV4 signature but never sent, and AWS rejects the mismatch. Cursor streams its Connect body after the headers (initial frame, heartbeats, tool responses), so no caller-supplied length can describe it and an HTTP/2 peer resets the stream once the body diverges. Reserved in both, and both regressions now send a Content-Length to prove it never reaches the wire. --- packages/ai/src/providers/amazon-bedrock.ts | 5 ++++- packages/ai/src/providers/cursor.ts | 4 ++++ packages/ai/test/bedrock-caller-headers.test.ts | 5 ++++- packages/ai/test/cursor-caller-headers.test.ts | 4 ++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index aa72ada0e..4473f46d2 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -55,7 +55,10 @@ import { transformMessages } from "./transform-messages"; const SIGNER_OWNED_HEADERS = new Set(["host", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]); /** Headers the Bedrock request sets itself; a caller copy in any casing duplicates them. */ -const BEDROCK_RESERVED_HEADERS = new Set(["content-type", "accept", "authorization"]); +// `content-length` included: the fetch layer recomputes it from the serialized +// body, so a caller value would be signed but not sent, and AWS rejects the +// mismatch. +const BEDROCK_RESERVED_HEADERS = new Set(["content-type", "accept", "authorization", "content-length"]); export type BedrockThinkingDisplay = "summarized" | "omitted"; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f3fc04fc5..23bcae9af 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -256,6 +256,10 @@ const CURSOR_RESERVED_HEADERS = new Set([ // header is present, so a caller value here silently retargets the request at // a different virtual host. "host", + // The Connect body is streamed after the headers (initial frame, heartbeats, + // tool responses), so no caller-supplied length can describe it and an HTTP/2 + // peer resets the stream once the body diverges. + "content-length", ]); /** diff --git a/packages/ai/test/bedrock-caller-headers.test.ts b/packages/ai/test/bedrock-caller-headers.test.ts index fcb3a098d..f9b251650 100644 --- a/packages/ai/test/bedrock-caller-headers.test.ts +++ b/packages/ai/test/bedrock-caller-headers.test.ts @@ -156,6 +156,9 @@ describe("Bedrock caller headers", () => { "Content-Type": "text/plain", Accept: "text/plain", Host: "evil.example.com", + // Recomputed by the fetch layer from the serialized body, so a caller + // value would be signed but never sent. + "Content-Length": "999", "X-Trace": "kept", }, }); @@ -165,7 +168,7 @@ describe("Bedrock caller headers", () => { const headers = seen.headers ?? {}; const names = Object.keys(headers).map(name => name.toLowerCase()); // Each field appears exactly once, whatever casing the caller used. - for (const field of ["content-type", "accept", "host"]) { + for (const field of ["content-type", "accept", "host", "content-length"]) { expect(names.filter(name => name === field).length).toBeLessThanOrEqual(1); } expect(headers["content-type"]).toBe("application/json"); diff --git a/packages/ai/test/cursor-caller-headers.test.ts b/packages/ai/test/cursor-caller-headers.test.ts index 15d37f251..14b21613a 100644 --- a/packages/ai/test/cursor-caller-headers.test.ts +++ b/packages/ai/test/cursor-caller-headers.test.ts @@ -164,12 +164,16 @@ describe("Cursor caller headers reach the wire", () => { "Content-Type": "text/plain", TE: "gzip", "X-Request-Id": "forged", + // The Connect body is streamed after the headers, so no caller length can + // describe it; an HTTP/2 peer resets the stream once the body diverges. + "Content-Length": "999", "x-trace": "kept", }); expect(sent.authorization).toBe("Bearer test-token"); expect(sent["content-type"]).toBe("application/connect+proto"); expect(sent.te).toBe("trailers"); expect(sent["x-request-id"]).not.toBe("forged"); + expect(sent["content-length"]).toBeUndefined(); expect(sent["x-trace"]).toBe("kept"); });