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"); });