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:
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user