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.
This commit is contained in:
Brian Corrigan
2026-08-09 15:32:48 -06:00
parent 9dc3594187
commit 3bfce1d7b4
4 changed files with 16 additions and 2 deletions
+4 -1
View File
@@ -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";
+4
View File
@@ -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",
]);
/**
@@ -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");
@@ -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");
});