Skip to content

Commit dc7bd4a

Browse files
committed
fix(scripts): cap Claude usage response reads
1 parent 6c041ef commit dc7bd4a

2 files changed

Lines changed: 136 additions & 7 deletions

File tree

scripts/debug-claude-usage.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type FetchOptions = {
2424
};
2525

2626
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
27+
const FETCH_RESPONSE_MAX_BYTES = 256 * 1024;
2728

2829
const mask = (value: string) => {
2930
return maskIdentifier(
@@ -124,6 +125,93 @@ const withFetchTimeout = async <T>(
124125
}
125126
};
126127

128+
const responseBodyTooLargeError = (label: string, maxBytes: number): Error =>
129+
new Error(`${label} response body exceeded ${maxBytes} bytes`);
130+
131+
const readResponseChunk = async (
132+
reader: ReadableStreamDefaultReader<Uint8Array>,
133+
label: string,
134+
signal: AbortSignal,
135+
markCanceled: () => void,
136+
): Promise<ReadableStreamReadResult<Uint8Array>> => {
137+
if (signal.aborted) {
138+
markCanceled();
139+
await reader.cancel().catch(() => undefined);
140+
throw signal.reason instanceof Error ? signal.reason : new Error(`${label} request aborted`);
141+
}
142+
143+
let removeAbortListener: (() => void) | undefined;
144+
const abortPromise = new Promise<ReadableStreamReadResult<Uint8Array>>((_resolve, reject) => {
145+
const onAbort = () => {
146+
markCanceled();
147+
void reader.cancel().catch(() => undefined);
148+
reject(
149+
signal.reason instanceof Error ? signal.reason : new Error(`${label} request aborted`),
150+
);
151+
};
152+
signal.addEventListener("abort", onAbort, { once: true });
153+
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
154+
});
155+
156+
try {
157+
return await Promise.race([reader.read(), abortPromise]);
158+
} finally {
159+
removeAbortListener?.();
160+
}
161+
};
162+
163+
const readBoundedResponseText = async (
164+
response: Response,
165+
label: string,
166+
signal: AbortSignal,
167+
maxBytes = FETCH_RESPONSE_MAX_BYTES,
168+
): Promise<string> => {
169+
const contentLength = Number(response.headers.get("content-length") ?? "");
170+
if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) {
171+
await response.body?.cancel().catch(() => undefined);
172+
throw responseBodyTooLargeError(label, maxBytes);
173+
}
174+
175+
if (!response.body) {
176+
return "";
177+
}
178+
179+
const reader = response.body.getReader();
180+
const decoder = new TextDecoder();
181+
const chunks: string[] = [];
182+
let totalBytes = 0;
183+
let canceled = false;
184+
185+
try {
186+
for (;;) {
187+
const { done, value } = await readResponseChunk(reader, label, signal, () => {
188+
canceled = true;
189+
});
190+
if (done) {
191+
const tail = decoder.decode();
192+
if (tail) {
193+
chunks.push(tail);
194+
}
195+
break;
196+
}
197+
198+
totalBytes += value.byteLength;
199+
if (totalBytes > maxBytes) {
200+
canceled = true;
201+
await reader.cancel().catch(() => undefined);
202+
throw responseBodyTooLargeError(label, maxBytes);
203+
}
204+
chunks.push(decoder.decode(value, { stream: true }));
205+
}
206+
} finally {
207+
if (!canceled) {
208+
reader.releaseLock();
209+
}
210+
}
211+
212+
return chunks.join("");
213+
};
214+
127215
const fetchText = async (
128216
label: string,
129217
url: string,
@@ -134,7 +222,7 @@ const fetchText = async (
134222
const timeoutMs = options.timeoutMs ?? resolveFetchTimeoutMs();
135223
return await withFetchTimeout(label, timeoutMs, async (signal) => {
136224
const res = await fetchImpl(url, { ...init, signal });
137-
const text = await res.text();
225+
const text = await readBoundedResponseText(res, label, signal);
138226
return { res, text };
139227
});
140228
};
@@ -467,9 +555,11 @@ const main = async () => {
467555
export const testing = {
468556
CLAUDE_COOKIE_HOST_SQL,
469557
CLAUDE_FIREFOX_COOKIE_HOST_SQL,
558+
FETCH_RESPONSE_MAX_BYTES,
470559
browserRootLabel,
471560
fetchAnthropicOAuthUsage,
472561
mask,
562+
readBoundedResponseText,
473563
resolveFetchTimeoutMs,
474564
};
475565

test/scripts/dev-tooling-safety.test.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,12 @@ describe("script-specific dev tooling hardening", () => {
246246
});
247247

248248
it("times out stalled Claude usage response body reads", async () => {
249-
const response = {
250-
ok: true,
251-
status: 200,
252-
headers: new Headers({ "content-type": "application/json" }),
253-
text: () => new Promise(() => {}),
254-
} as Response;
249+
const response = new Response(
250+
new ReadableStream({
251+
start() {},
252+
}),
253+
{ headers: { "content-type": "application/json" } },
254+
);
255255
const request = claudeUsageTesting.fetchAnthropicOAuthUsage("test-token", {
256256
timeoutMs: 5,
257257
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
@@ -266,4 +266,43 @@ describe("script-specific dev tooling hardening", () => {
266266
/OPENCLAW_DEBUG_CLAUDE_USAGE_FETCH_TIMEOUT_MS must be an integer/u,
267267
);
268268
});
269+
270+
it("bounds Claude usage response body reads by content-length", async () => {
271+
const maxBytes = claudeUsageTesting.FETCH_RESPONSE_MAX_BYTES;
272+
const response = new Response("{}", {
273+
headers: { "content-length": String(maxBytes + 1) },
274+
});
275+
const controller = new AbortController();
276+
277+
await expect(
278+
claudeUsageTesting.readBoundedResponseText(
279+
response,
280+
"Claude usage test",
281+
controller.signal,
282+
maxBytes,
283+
),
284+
).rejects.toThrow(`Claude usage test response body exceeded ${maxBytes} bytes`);
285+
});
286+
287+
it("bounds Claude usage response body reads by streamed bytes", async () => {
288+
const maxBytes = claudeUsageTesting.FETCH_RESPONSE_MAX_BYTES;
289+
const response = new Response(
290+
new ReadableStream({
291+
start(controller) {
292+
controller.enqueue(new Uint8Array(maxBytes + 1));
293+
controller.close();
294+
},
295+
}),
296+
);
297+
const controller = new AbortController();
298+
299+
await expect(
300+
claudeUsageTesting.readBoundedResponseText(
301+
response,
302+
"Claude usage test",
303+
controller.signal,
304+
maxBytes,
305+
),
306+
).rejects.toThrow(`Claude usage test response body exceeded ${maxBytes} bytes`);
307+
});
269308
});

0 commit comments

Comments
 (0)