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

@ -150,6 +150,14 @@ function redactJsonValue(value: unknown): unknown {
* (and other binary/opaque bodies) are passed through verbatim — OAuth never uses
* multipart, so the risk is low; revisit if a multipart secret path appears.
*/
export function redactBody(
body: string,
contentType: string | null | undefined,
): string;
export function redactBody(
body: string | undefined,
contentType: string | null | undefined,
): string | undefined;
export function redactBody(
body: string | undefined,
contentType: string | null | undefined,
@ -229,6 +237,36 @@ export const CHALLENGE_BODY_MAX_BYTES = 64 * 1024;
export const CHALLENGE_BODY_READ_MS = 2_000;
/** Appended to a 401/403 body the tracker stopped reading early. */
export const TRUNCATED_BODY_SUFFIX = "\n[truncated]";
/**
* Recorded in place of a 401/403 body that was cut off and looks like JSON.
* {@link redactBody} can only mask fields in JSON that parses, so a cut-off
* prefix such as `{"access_token":"…",` would otherwise reach the log verbatim.
*/
export const WITHHELD_TRUNCATED_JSON_BODY =
"[truncated JSON body withheld: an incomplete body cannot be redacted]";
interface ChallengeBodyRead {
text: string;
truncated: boolean;
}
/**
* The recorded form of a 401/403 body: redacted, marked when cut off, and never
* a cut-off JSON prefix. Form-encoded bodies still redact when cut, since each
* surviving `key=value` pair is parsed on its own; plain text has nothing to
* redact either way.
*/
function recordableChallengeBody(
read: ChallengeBodyRead,
contentType: string | null,
): string {
if (!read.truncated) return redactBody(read.text, contentType);
const looksLikeJson =
(contentType ?? "").toLowerCase().includes("json") ||
/^\s*[{[]/.test(read.text);
if (looksLikeJson) return WITHHELD_TRUNCATED_JSON_BODY;
return redactBody(read.text, contentType) + TRUNCATED_BODY_SUFFIX;
}
/**
* Read a 401/403 response body from a clone, bounded by
@ -245,7 +283,7 @@ export const TRUNCATED_BODY_SUFFIX = "\n[truncated]";
*/
async function readChallengeBody(
response: Response,
): Promise<string | undefined> {
): Promise<ChallengeBodyRead | undefined> {
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
const body = response.clone().body;
@ -267,14 +305,17 @@ async function readChallengeBody(
while (bytes < CHALLENGE_BODY_MAX_BYTES) {
const next = await Promise.race([reader.read(), deadline]);
if (next === "timeout") break;
if (next.done) return text + decoder.decode();
bytes += next.value.byteLength;
text += decoder.decode(next.value, { stream: true });
if (next.done) return { text: text + decoder.decode(), truncated: false };
// A stream may hand back one chunk far larger than the budget, so the cap
// is applied to the bytes, not to the chunk count.
const chunk = next.value.subarray(0, CHALLENGE_BODY_MAX_BYTES - bytes);
bytes += chunk.byteLength;
text += decoder.decode(chunk, { stream: true });
}
// Stopped early. Not awaited: cancel() adopts the source's promise, which a
// stalled body may never settle.
reader.cancel().catch(() => {});
return text + decoder.decode() + TRUNCATED_BODY_SUFFIX;
return { text: text + decoder.decode(), truncated: true };
} catch {
return undefined;
} finally {
@ -435,17 +476,18 @@ export function createFetchTracker(
duration,
};
// Not gated on isLongLivedStream: that exclusion guards an unbounded read,
// and this one is bounded — so a challenge to an SSE GET is recorded too.
if (
(responseStatus === 401 || responseStatus === 403) &&
!isLongLivedStream &&
response.body &&
!response.bodyUsed
) {
const body = await readChallengeBody(response);
const read = await readChallengeBody(response);
entry.responseBody =
body === undefined
read === undefined
? undefined
: (redactBody(body, response.headers.get("content-type")) ?? body);
: recordableChallengeBody(read, response.headers.get("content-type"));
callbacks.trackRequest?.(entry);
return response;
}