fix(chutes): bound OAuth token error response reads (#97808)

* fix(chutes): bound OAuth token error response reads

* ci: re-trigger checks (fs-safe unhandled-rejection flake on prior run)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Pick-cat <266665499+Pick-cat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pick-cat
2026-06-30 01:48:05 +08:00
committed by GitHub
parent 84cd3aa7f5
commit 9949f6bd85
2 changed files with 83 additions and 3 deletions
+78
View File
@@ -27,6 +27,28 @@ function createStoredCredential(
} as unknown as Parameters<typeof refreshChutesTokens>[0]["credential"];
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function expectRefreshedCredential(
refreshed: Awaited<ReturnType<typeof refreshChutesTokens>>,
now: number,
@@ -212,6 +234,62 @@ describe("chutes-oauth", () => {
expectRefreshedCredential(refreshed, now);
});
it("bounds token exchange error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"chutes exchange failure ".repeat(1024)}tail`, {
status: 401,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return tracked.response;
}
return new Response("not found", { status: 404 });
});
await expect(
exchangeChutesCodeForTokens({
app: {
clientId: "cid_test",
redirectUri: "http://127.0.0.1:1456/oauth-callback",
scopes: ["openid"],
},
code: "code_401",
codeVerifier: "verifier_401",
fetchFn,
now: 1_000_000,
}),
).rejects.toThrow("Chutes token exchange failed: chutes exchange failure");
expect(textSpy).not.toHaveBeenCalled();
expect(tracked.wasCanceled()).toBe(true);
});
it("bounds token refresh error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"chutes refresh failure ".repeat(1024)}tail`, {
status: 401,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
if (url === CHUTES_TOKEN_ENDPOINT) {
return tracked.response;
}
return new Response("not found", { status: 404 });
});
await expect(
refreshChutesTokens({
credential: createStoredCredential(5_000_000),
fetchFn,
now: 5_000_000,
}),
).rejects.toThrow("Chutes token refresh failed: chutes refresh failure");
expect(textSpy).not.toHaveBeenCalled();
expect(tracked.wasCanceled()).toBe(true);
});
it("rejects unsafe refresh token lifetimes", async () => {
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
const url = urlToString(input);
+5 -3
View File
@@ -6,7 +6,9 @@ import { createHash, randomBytes } from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js";
import type { OAuthCredentials } from "../llm/oauth.js";
import { readProviderJsonResponse } from "./provider-http-errors.js";
import { readProviderJsonResponse, readResponseTextLimited } from "./provider-http-errors.js";
const CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`;
@@ -152,7 +154,7 @@ export async function exchangeChutesCodeForTokens(params: {
body,
});
if (!response.ok) {
const text = await response.text();
const text = await readResponseTextLimited(response, CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES);
throw new Error(`Chutes token exchange failed: ${text}`);
}
@@ -223,7 +225,7 @@ export async function refreshChutesTokens(params: {
body,
});
if (!response.ok) {
const text = await response.text();
const text = await readResponseTextLimited(response, CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES);
throw new Error(`Chutes token refresh failed: ${text}`);
}