Skip to content

Commit 067b0a2

Browse files
fix(chutes-oauth): bound Chutes OAuth JSON response reads
Routes the three Chutes OAuth response.json() sites in src/agents/chutes-oauth.ts (fetchChutesUserInfo:118, exchangeChutesCodeForTokens:158, refreshChutesTokens:229) through the existing readProviderJsonResponse helper, capping reads at 16 MiB via PROVIDER_JSON_RESPONSE_MAX_BYTES. Closes the chutes-oauth half of the unbounded-response.json() family called out as "Out of scope" in PR #96144 (sibling to #95926, #96036, #96136). A misbehaving Chutes endpoint that streams an unbounded body is now rejected with the canonical `<label>: JSON response exceeds 16777216 bytes` overflow error before the runtime buffers the full body. - src/agents/chutes-oauth.ts: +6/-5 (1 import + 3 sites) - src/agents/chutes-oauth.flow.test.ts: +111/-0 (3 new bounded-reader tests) - scripts/repro/issue-chutes-oauth-bounded-read.mjs: standalone repro driving readProviderJsonResponse with valid + hostile + negative control
1 parent d4c1518 commit 067b0a2

3 files changed

Lines changed: 267 additions & 5 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Live repro for the chutes-oauth bounded-read PR — proves the canonical
4+
* 16 MiB provider-response cap on `readProviderJsonResponse` for the three
5+
* Chutes OAuth response sites:
6+
* - fetchChutesUserInfo (line 118)
7+
* - exchangeChutesCodeForTokens (line 158)
8+
* - refreshChutesTokens (line 229)
9+
*
10+
* Run: pnpm exec tsx scripts/repro/issue-chutes-oauth-bounded-read.mjs
11+
*
12+
* The script drives the production `readProviderJsonResponse` directly
13+
* (no vitest mock) with three streaming bodies:
14+
* 1. Valid: a typical Chutes token exchange response (~78 B) under the
15+
* 16 MiB cap is accepted and parsed.
16+
* 2. Hostile: a 64 MiB streaming body is rejected with the canonical
17+
* "JSON response exceeds 16777216 bytes" error before the runtime
18+
* buffers the full body (exercised against all 3 production labels).
19+
* 3. Negative control: raw `response.json()` on the same 64 MiB body
20+
* buffers the full payload and only fails on JSON parse, proving the
21+
* bounded read is the right shape (and that the
22+
* `readProviderJsonResponse` swap in chutes-oauth.ts is the
23+
* meaningful change, not an inert re-export).
24+
*
25+
* Mirrors the bounded-read pattern merged for #95926 / #96036 / #96136 /
26+
* #96144, applied to the chutes-oauth surface.
27+
*/
28+
import assert from "node:assert/strict";
29+
import { readProviderJsonResponse } from "../../src/agents/provider-http-errors.ts";
30+
31+
const PROVIDER_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; // 16 MiB
32+
33+
function createStreamingJsonResponse({ totalBytes, chunkSize = 1024 * 1024 }) {
34+
let written = 0;
35+
const encoder = new TextEncoder();
36+
const stream = new ReadableStream({
37+
pull(controller) {
38+
if (written >= totalBytes) {
39+
controller.close();
40+
return;
41+
}
42+
const remaining = totalBytes - written;
43+
const slice = Math.min(chunkSize, remaining);
44+
controller.enqueue(encoder.encode("a".repeat(slice)));
45+
written += slice;
46+
},
47+
});
48+
return new Response(stream, {
49+
status: 200,
50+
headers: { "Content-Type": "application/json" },
51+
});
52+
}
53+
54+
console.log("=== Reproduction for chutes-oauth bounded JSON response cap policy ===");
55+
console.log(`PROVIDER_JSON_RESPONSE_MAX_BYTES = ${PROVIDER_JSON_RESPONSE_MAX_BYTES} bytes`);
56+
57+
// 1. Valid Chutes token exchange response: typical ~78 B envelope.
58+
const validBody = {
59+
access_token: "at_test_123",
60+
refresh_token: "rt_test_123",
61+
expires_in: 3600,
62+
};
63+
const validJson = JSON.stringify(validBody);
64+
const validResponse = new Response(validJson, {
65+
status: 200,
66+
headers: { "Content-Type": "application/json" },
67+
});
68+
const validParsed = await readProviderJsonResponse(validResponse, "Chutes token exchange");
69+
assert.equal(validParsed.access_token, "at_test_123");
70+
assert.equal(validParsed.refresh_token, "rt_test_123");
71+
assert.equal(validParsed.expires_in, 3600);
72+
console.log(
73+
`PASS valid Chutes token exchange response: accepted (${validJson.length} bytes, 3 fields parsed)`,
74+
);
75+
76+
// 2. Hostile 64 MiB streaming body for token exchange.
77+
const OVERSIZED_BYTES = 64 * 1024 * 1024;
78+
const oversized = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES });
79+
let hostileError = null;
80+
try {
81+
await readProviderJsonResponse(oversized, "Chutes token exchange hostile");
82+
} catch (err) {
83+
hostileError = err;
84+
}
85+
assert.ok(hostileError, "hostile response must throw");
86+
assert.match(
87+
hostileError.message,
88+
/JSON response exceeds 16777216 bytes/,
89+
`hostile response must surface canonical overflow error; got: ${hostileError.message}`,
90+
);
91+
console.log(
92+
`PASS hostile 64 MiB token exchange response: rejected with "${hostileError.message}"`,
93+
);
94+
95+
// 2b. Same hostile 64 MiB body for token refresh — proves the bound is applied
96+
// to the second production site as well.
97+
const refreshOversized = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES });
98+
let refreshError = null;
99+
try {
100+
await readProviderJsonResponse(refreshOversized, "Chutes token refresh hostile");
101+
} catch (err) {
102+
refreshError = err;
103+
}
104+
assert.ok(refreshError, "hostile refresh response must throw");
105+
assert.match(
106+
refreshError.message,
107+
/JSON response exceeds 16777216 bytes/,
108+
`hostile refresh response must surface canonical overflow error; got: ${refreshError.message}`,
109+
);
110+
console.log(`PASS hostile 64 MiB token refresh response: rejected with "${refreshError.message}"`);
111+
112+
// 2c. Same hostile 64 MiB body for userinfo — proves the bound is applied
113+
// to the third production site as well.
114+
const userinfoOversized = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES });
115+
let userinfoError = null;
116+
try {
117+
await readProviderJsonResponse(userinfoOversized, "Chutes userinfo hostile");
118+
} catch (err) {
119+
userinfoError = err;
120+
}
121+
assert.ok(userinfoError, "hostile userinfo response must throw");
122+
assert.match(
123+
userinfoError.message,
124+
/JSON response exceeds 16777216 bytes/,
125+
`hostile userinfo response must surface canonical overflow error; got: ${userinfoError.message}`,
126+
);
127+
console.log(`PASS hostile 64 MiB userinfo response: rejected with "${userinfoError.message}"`);
128+
129+
// 3. Negative control: raw `response.json()` on the same 64 MiB body
130+
// buffers the full body before failing on JSON parse — proves the
131+
// bounded read is the right shape (vs. an inert helper that
132+
// silently truncates and then re-fails).
133+
const negativeBody = createStreamingJsonResponse({ totalBytes: OVERSIZED_BYTES });
134+
let negativeError = null;
135+
try {
136+
await negativeBody.json();
137+
} catch (err) {
138+
negativeError = err;
139+
}
140+
assert.ok(negativeError, "raw json() must also throw on 64 MiB non-JSON body");
141+
assert.doesNotMatch(
142+
negativeError.message,
143+
/JSON response exceeds 16777216 bytes/,
144+
"raw json() must NOT surface the bounded-reader error (it doesn't go through the helper)",
145+
);
146+
console.log(
147+
`PASS negative control: raw response.json() on 64 MiB body failed with "${negativeError.constructor.name}" (no bounded-reader wrapping)`,
148+
);
149+
150+
console.log("=== All repro assertions passed ===");

src/agents/chutes-oauth.flow.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,114 @@ describe("chutes-oauth", () => {
233233
).rejects.toThrow("Chutes token refresh returned invalid expires_in");
234234
});
235235
});
236+
237+
/**
238+
* Bounded-reader regression tests for `chutes-oauth` response.json() sites.
239+
*
240+
* Mirrors the bounded-read pattern merged for #95926 / #96036 / #96136 / #96144:
241+
* - 3 production sites (fetchChutesUserInfo:118, exchangeChutesCodeForTokens:158,
242+
* refreshChutesTokens:229) now route through `readProviderJsonResponse` from
243+
* `./provider-http-errors.js`, which caps reads at 16 MiB via
244+
* `PROVIDER_JSON_RESPONSE_MAX_BYTES` (provider-http-errors.ts:16).
245+
* - A misbehaving Chutes endpoint that streams an unbounded body must be
246+
* rejected with the canonical "JSON response exceeds 16777216 bytes" error
247+
* before the runtime buffers the full body.
248+
* - The token-refresh and userinfo responses must remain unchanged in shape
249+
* (the bounded reader preserves the same JSON.parse contract).
250+
*/
251+
describe("chutes-oauth bounded reader", () => {
252+
function createStreamingJsonResponse({ totalBytes, chunkSize = 1024 * 1024 }) {
253+
let written = 0;
254+
const encoder = new TextEncoder();
255+
const stream = new ReadableStream({
256+
pull(controller) {
257+
if (written >= totalBytes) {
258+
controller.close();
259+
return;
260+
}
261+
const remaining = totalBytes - written;
262+
const slice = Math.min(chunkSize, remaining);
263+
controller.enqueue(encoder.encode("a".repeat(slice)));
264+
written += slice;
265+
},
266+
});
267+
return new Response(stream, {
268+
status: 200,
269+
headers: { "Content-Type": "application/json" },
270+
});
271+
}
272+
273+
it("rejects hostile 64 MiB token exchange response with canonical overflow error", async () => {
274+
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
275+
if (urlToString(input) === CHUTES_TOKEN_ENDPOINT) {
276+
return createStreamingJsonResponse({ totalBytes: 64 * 1024 * 1024 });
277+
}
278+
return new Response("not found", { status: 404 });
279+
});
280+
281+
await expect(
282+
exchangeChutesCodeForTokens({
283+
app: {
284+
clientId: "cid_test",
285+
redirectUri: "http://127.0.0.1:1456/oauth-callback",
286+
scopes: ["openid"],
287+
},
288+
code: "code_oversize",
289+
codeVerifier: "verifier_oversize",
290+
fetchFn,
291+
now: 1_000_000,
292+
}),
293+
).rejects.toThrow("Chutes token exchange: JSON response exceeds 16777216 bytes");
294+
});
295+
296+
it("rejects hostile 64 MiB token refresh response with canonical overflow error", async () => {
297+
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
298+
if (urlToString(input) === CHUTES_TOKEN_ENDPOINT) {
299+
return createStreamingJsonResponse({ totalBytes: 64 * 1024 * 1024 });
300+
}
301+
return new Response("not found", { status: 404 });
302+
});
303+
304+
await expect(
305+
refreshChutesTokens({
306+
credential: createStoredCredential(5_000_000),
307+
fetchFn,
308+
now: 5_000_000,
309+
}),
310+
).rejects.toThrow("Chutes token refresh: JSON response exceeds 16777216 bytes");
311+
});
312+
313+
it("rejects hostile 64 MiB userinfo response with canonical overflow error", async () => {
314+
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
315+
const url = urlToString(input);
316+
if (url === CHUTES_TOKEN_ENDPOINT) {
317+
return new Response(
318+
JSON.stringify({
319+
access_token: "at_oversize",
320+
refresh_token: "rt_oversize",
321+
expires_in: 3600,
322+
}),
323+
{ status: 200, headers: { "Content-Type": "application/json" } },
324+
);
325+
}
326+
if (url === CHUTES_USERINFO_ENDPOINT) {
327+
return createStreamingJsonResponse({ totalBytes: 64 * 1024 * 1024 });
328+
}
329+
return new Response("not found", { status: 404 });
330+
});
331+
332+
await expect(
333+
exchangeChutesCodeForTokens({
334+
app: {
335+
clientId: "cid_test",
336+
redirectUri: "http://127.0.0.1:1456/oauth-callback",
337+
scopes: ["openid"],
338+
},
339+
code: "code_userinfo_oversize",
340+
codeVerifier: "verifier_userinfo_oversize",
341+
fetchFn,
342+
now: 1_000_000,
343+
}),
344+
).rejects.toThrow("Chutes userinfo: JSON response exceeds 16777216 bytes");
345+
});
346+
});

src/agents/chutes-oauth.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createHash, randomBytes } from "node:crypto";
66
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
77
import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js";
88
import type { OAuthCredentials } from "../llm/oauth.js";
9+
import { readProviderJsonResponse } from "./provider-http-errors.js";
910

1011
const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
1112
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
@@ -115,7 +116,7 @@ async function fetchChutesUserInfo(params: {
115116
await cancelUnreadResponseBody(response);
116117
return null;
117118
}
118-
const data = (await response.json()) as unknown;
119+
const data = await readProviderJsonResponse<unknown>(response, "Chutes userinfo");
119120
if (!data || typeof data !== "object") {
120121
return null;
121122
}
@@ -155,11 +156,11 @@ export async function exchangeChutesCodeForTokens(params: {
155156
throw new Error(`Chutes token exchange failed: ${text}`);
156157
}
157158

158-
const data = (await response.json()) as {
159+
const data = await readProviderJsonResponse<{
159160
access_token?: string;
160161
refresh_token?: string;
161162
expires_in?: number;
162-
};
163+
}>(response, "Chutes token exchange");
163164

164165
const access = data.access_token?.trim();
165166
const refresh = data.refresh_token?.trim();
@@ -226,11 +227,11 @@ export async function refreshChutesTokens(params: {
226227
throw new Error(`Chutes token refresh failed: ${text}`);
227228
}
228229

229-
const data = (await response.json()) as {
230+
const data = await readProviderJsonResponse<{
230231
access_token?: string;
231232
refresh_token?: string;
232233
expires_in?: number;
233-
};
234+
}>(response, "Chutes token refresh");
234235
const access = data.access_token?.trim();
235236
const newRefresh = data.refresh_token?.trim();
236237
const expires = resolveChutesExpiresAt(data.expires_in, now);

0 commit comments

Comments
 (0)