fix: enforce the challenge body cap per byte and never log cut-off JSON (#2297)

Copilot review round 2:

- Slice each chunk to the remaining budget, so one oversized chunk can no
  longer carry the recorded body past CHALLENGE_BODY_MAX_BYTES.
- Drop the long-lived-stream exclusion from the challenge branch. It guards
  an unbounded read and this one is bounded, so a 401 to an SSE GET now
  records its body too.
- Keep truncation out of the string that gets redacted. A cut-off body that
  looks like JSON cannot be redacted (redactBody only masks JSON that
  parses), so it is replaced by WITHHELD_TRUNCATED_JSON_BODY. Cut-off form
  and plain-text bodies are redacted, then marked [truncated].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SG6q6YEDJyJB5H2Hr8nBDS
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 01:34:38 -04:00
commit 1fd1b15152

View file

@ -3,6 +3,7 @@ import {
CHALLENGE_BODY_MAX_BYTES,
CHALLENGE_BODY_READ_MS,
TRUNCATED_BODY_SUFFIX,
WITHHELD_TRUNCATED_JSON_BODY,
createFetchTracker,
redactSensitiveHeaders,
redactBody,
@ -659,6 +660,164 @@ describe("createFetchTracker — 401/403 challenge bodies (#2297)", () => {
});
});
describe("createFetchTracker — challenge body bounds and redaction (#2297)", () => {
function trackOne(response: Response) {
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker((async () => response) as typeof fetch, {
trackRequest: (entry) => tracked.push(entry),
});
return { fetcher, tracked };
}
/** A body that delivers `prefix` and then never ends. */
function stalledBody(prefix: string): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(prefix));
},
});
}
async function withDeadlineElapsed<T>(run: () => Promise<T>): Promise<T> {
vi.useFakeTimers();
try {
const pending = run();
await vi.advanceTimersByTimeAsync(CHALLENGE_BODY_READ_MS);
return await pending;
} finally {
vi.useRealTimers();
}
}
it("caps a single chunk larger than the budget at the byte limit", async () => {
const oversized = new Uint8Array(1024 * 1024).fill(97);
const { fetcher, tracked } = trackOne(
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(oversized);
controller.close();
},
}),
{ status: 401 },
),
);
await fetcher("https://mcp.example/mcp", { method: "POST" });
const recorded = tracked[0]?.responseBody ?? "";
expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true);
expect(recorded.length - TRUNCATED_BODY_SUFFIX.length).toBe(
CHALLENGE_BODY_MAX_BYTES,
);
});
it("records the body of a 401 to a long-lived-stream GET", async () => {
const { fetcher, tracked } = trackOne(
new Response("unauthorized", {
status: 401,
headers: { "content-type": "text/event-stream" },
}),
);
await fetcher("https://mcp.example/mcp", { method: "GET" });
expect(tracked[0]?.responseBody).toBe("unauthorized");
});
it("withholds a cut-off JSON body rather than logging an unredacted prefix", async () => {
const { fetcher, tracked } = trackOne(
new Response(stalledBody('{"access_token":"live-access","token_type'), {
status: 401,
headers: { "content-type": "application/json" },
}),
);
await withDeadlineElapsed(() =>
fetcher("https://mcp.example/token", { method: "POST" }),
);
expect(tracked[0]?.responseBody).toBe(WITHHELD_TRUNCATED_JSON_BODY);
expect(serializedWithoutId(tracked[0]!)).not.toContain("live-access");
});
it("withholds a cut-off body that sniffs as JSON with no content-type", async () => {
const { fetcher, tracked } = trackOne(
new Response(stalledBody(' [{"refresh_token":"live-refresh"'), {
status: 403,
}),
);
await withDeadlineElapsed(() =>
fetcher("https://mcp.example/token", { method: "POST" }),
);
expect(tracked[0]?.responseBody).toBe(WITHHELD_TRUNCATED_JSON_BODY);
});
it("still redacts a complete JSON challenge body", async () => {
const { fetcher, tracked } = trackOne(
new Response(JSON.stringify({ error: "x", access_token: "live" }), {
status: 401,
headers: { "content-type": "application/json" },
}),
);
await fetcher("https://mcp.example/token", { method: "POST" });
expect(tracked[0]?.responseBody).toBe(
JSON.stringify({ error: "x", access_token: REDACTED_VALUE }),
);
});
it("redacts a cut-off form body field by field", async () => {
const { fetcher, tracked } = trackOne(
new Response(stalledBody("error=invalid&access_token=live&scope=re"), {
status: 401,
headers: { "content-type": "application/x-www-form-urlencoded" },
}),
);
await withDeadlineElapsed(() =>
fetcher("https://mcp.example/token", { method: "POST" }),
);
const recorded = tracked[0]?.responseBody ?? "";
expect(recorded).not.toContain("live");
expect(recorded.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(true);
});
});
describe("createFetchTracker — challenge body cancel rejection (#2297)", () => {
it("swallows the source's cancel() rejection after stopping early", async () => {
const chunk = new Uint8Array(CHALLENGE_BODY_MAX_BYTES).fill(97);
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(chunk);
},
cancel() {
throw new Error("cancel failed");
},
});
const tracked: FetchRequestEntryBase[] = [];
const fetcher = createFetchTracker(
(async () => new Response(stream, { status: 401 })) as typeof fetch,
{ trackRequest: (entry) => tracked.push(entry) },
);
const res = await fetcher("https://mcp.example/mcp", { method: "POST" });
// Cancelling the caller's branch cancels the source, whose rejection then
// reaches the tracker's own cancel of its clone — which must swallow it.
await res.body?.cancel().catch(() => undefined);
await flush();
expect(tracked[0]?.responseStatus).toBe(401);
expect(tracked[0]?.responseBody?.endsWith(TRUNCATED_BODY_SUFFIX)).toBe(
true,
);
});
});
describe("redactUrlQuery", () => {
it("redacts sensitive params and keeps the path + other params", () => {
expect(