Skip to content

Commit 6189324

Browse files
ZOOWHsteipete
andauthored
fix(chutes): redact OAuth error response body (#103569)
* fix(chutes): redact OAuth error response body in token exchange and refresh Replace readResponseTextLimited + raw body in error message with extractProviderErrorDetail for bounded, redacted structured error detail. Ref: #102953 * fix(chutes): update test assertions for extractProviderErrorDetail message format * fix(chutes): remove unused readResponseTextLimited and CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES * fix(chutes): normalize OAuth HTTP failures * test(chutes): assert redaction without fixed mask --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 188535b commit 6189324

4 files changed

Lines changed: 71 additions & 42 deletions

File tree

extensions/chutes/oauth.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,9 @@ describe("chutes plugin OAuth", () => {
130130
});
131131

132132
it("bounds token exchange error bodies without requiring response.text()", async () => {
133+
const leakedClientSecret = "oauth-client-secret-1234567890";
133134
const errorResponse = boundedErrorResponse(
134-
`${"chutes token unavailable ".repeat(1024)}tail-marker`,
135+
`${`client_secret=${leakedClientSecret}&reason=unavailable `.repeat(1024)}tail-marker`,
135136
502,
136137
);
137138
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
@@ -165,8 +166,11 @@ describe("chutes plugin OAuth", () => {
165166

166167
expect(error).toBeInstanceOf(Error);
167168
const message = (error as Error).message;
168-
expect(message).toContain("Chutes token exchange failed: chutes token unavailable");
169+
expect(error).toMatchObject({ name: "ProviderHttpError", status: 502 });
170+
expect(message).toContain("Chutes token exchange failed (502): client_secret=");
171+
expect(message).not.toContain(leakedClientSecret);
169172
expect(message).not.toContain("tail-marker");
173+
expect((error as { errorBody?: string }).errorBody).not.toContain(leakedClientSecret);
170174
expect(errorResponse.text).not.toHaveBeenCalled();
171175
expect(errorResponse.cancel).toHaveBeenCalledTimes(1);
172176
expect(errorResponse.releaseLock).toHaveBeenCalledTimes(1);

extensions/chutes/oauth.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,15 @@ import {
99
waitForLocalOAuthCallback,
1010
} from "openclaw/plugin-sdk/provider-auth-runtime";
1111
import {
12+
assertOkOrThrowProviderError,
1213
readProviderJsonResponse,
13-
readResponseTextLimited,
1414
} from "openclaw/plugin-sdk/provider-http";
1515
import { buildOAuthRequestSignal } from "openclaw/plugin-sdk/provider-oauth-runtime";
1616
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
1717

1818
const CHUTES_AUTHORIZE_ENDPOINT = "https://api.chutes.ai/idp/authorize";
1919
const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token";
2020
const CHUTES_USERINFO_ENDPOINT = "https://api.chutes.ai/idp/userinfo";
21-
const CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
2221
const CHUTES_OAUTH_REQUEST_TIMEOUT_MS = 30_000;
2322

2423
type OAuthPrompt = {
@@ -160,13 +159,7 @@ async function exchangeChutesCodeForTokens(params: {
160159
body,
161160
signal: buildOAuthRequestSignal({ timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS }),
162161
});
163-
if (!response.ok) {
164-
const detail = await readResponseTextLimited(
165-
response,
166-
CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES,
167-
).catch(() => "");
168-
throw new Error(`Chutes token exchange failed: ${detail}`);
169-
}
162+
await assertOkOrThrowProviderError(response, "Chutes token exchange failed");
170163

171164
const data = await readProviderJsonResponse<{
172165
access_token?: string;

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

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -312,22 +312,33 @@ describe("chutes-oauth", () => {
312312
expectRefreshedCredential(refreshed, now);
313313
});
314314

315-
it("bounds token exchange error bodies without using response.text()", async () => {
316-
const tracked = cancelTrackedResponse(`${"chutes exchange failure ".repeat(1024)}tail`, {
317-
status: 401,
318-
headers: { "content-type": "text/plain" },
319-
});
320-
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
315+
it("normalizes and redacts structured token exchange errors", async () => {
316+
const leakedClientSecret = "oauth-client-secret-1234567890";
317+
const response = new Response(
318+
JSON.stringify({
319+
error: "invalid_grant",
320+
error_description: `Authorization failed for client_secret=${leakedClientSecret}`,
321+
}),
322+
{
323+
status: 400,
324+
headers: {
325+
"content-type": "application/json",
326+
"x-request-id": "chutes_req_123",
327+
},
328+
},
329+
);
330+
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
321331
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
322332
const url = urlToString(input);
323333
if (url === CHUTES_TOKEN_ENDPOINT) {
324-
return tracked.response;
334+
return response;
325335
}
326336
return new Response("not found", { status: 404 });
327337
});
328338

329-
await expect(
330-
exchangeChutesCodeForTokens({
339+
let error: unknown;
340+
try {
341+
await exchangeChutesCodeForTokens({
331342
app: {
332343
clientId: "cid_test",
333344
redirectUri: "http://127.0.0.1:1456/oauth-callback",
@@ -337,17 +348,35 @@ describe("chutes-oauth", () => {
337348
codeVerifier: "verifier_401",
338349
fetchFn,
339350
now: 1_000_000,
340-
}),
341-
).rejects.toThrow("Chutes token exchange failed: chutes exchange failure");
351+
});
352+
} catch (caught) {
353+
error = caught;
354+
}
355+
356+
expect(error).toMatchObject({
357+
name: "ProviderHttpError",
358+
status: 400,
359+
errorCode: "invalid_grant",
360+
requestId: "chutes_req_123",
361+
});
362+
const message = (error as Error).message;
363+
expect(message).toContain("Chutes token exchange failed (400): Authorization failed");
364+
expect(message).toContain("[code=invalid_grant]");
365+
expect(message).not.toContain(leakedClientSecret);
366+
expect(message).not.toContain("error_description");
367+
expect((error as { errorBody?: string }).errorBody).not.toContain(leakedClientSecret);
342368
expect(textSpy).not.toHaveBeenCalled();
343-
expect(tracked.wasCanceled()).toBe(true);
344369
});
345370

346-
it("bounds token refresh error bodies without using response.text()", async () => {
347-
const tracked = cancelTrackedResponse(`${"chutes refresh failure ".repeat(1024)}tail`, {
348-
status: 401,
349-
headers: { "content-type": "text/plain" },
350-
});
371+
it("bounds and redacts plain-text token refresh errors", async () => {
372+
const leakedRefreshToken = "oauth-refresh-secret-1234567890";
373+
const tracked = cancelTrackedResponse(
374+
`${`refresh_token=${leakedRefreshToken} unavailable `.repeat(1024)}tail-marker`,
375+
{
376+
status: 401,
377+
headers: { "content-type": "text/plain" },
378+
},
379+
);
351380
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
352381
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
353382
const url = urlToString(input);
@@ -357,13 +386,23 @@ describe("chutes-oauth", () => {
357386
return new Response("not found", { status: 404 });
358387
});
359388

360-
await expect(
361-
refreshChutesTokens({
389+
let error: unknown;
390+
try {
391+
await refreshChutesTokens({
362392
credential: createStoredCredential(5_000_000),
363393
fetchFn,
364394
now: 5_000_000,
365-
}),
366-
).rejects.toThrow("Chutes token refresh failed: chutes refresh failure");
395+
});
396+
} catch (caught) {
397+
error = caught;
398+
}
399+
400+
expect(error).toMatchObject({ name: "ProviderHttpError", status: 401 });
401+
const message = (error as Error).message;
402+
expect(message).toContain("Chutes token refresh failed (401): refresh_token=");
403+
expect(message).not.toContain(leakedRefreshToken);
404+
expect(message).not.toContain("tail-marker");
405+
expect((error as { errorBody?: string }).errorBody).not.toContain(leakedRefreshToken);
367406
expect(textSpy).not.toHaveBeenCalled();
368407
expect(tracked.wasCanceled()).toBe(true);
369408
});

src/agents/chutes-oauth.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import { sha256Base64Url } from "../infra/crypto-digest.js";
88
import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js";
99
import type { OAuthCredentials } from "../llm/oauth.js";
1010
import { buildOAuthRequestSignal } from "../llm/utils/oauth/abort.js";
11-
import { readProviderJsonResponse, readResponseTextLimited } from "./provider-http-errors.js";
11+
import { assertOkOrThrowProviderError, readProviderJsonResponse } from "./provider-http-errors.js";
1212

13-
const CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
1413
const CHUTES_OAUTH_REQUEST_TIMEOUT_MS = 30_000;
1514

1615
const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai";
@@ -158,10 +157,7 @@ export async function exchangeChutesCodeForTokens(params: {
158157
body,
159158
signal: buildOAuthRequestSignal({ timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS }),
160159
});
161-
if (!response.ok) {
162-
const text = await readResponseTextLimited(response, CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES);
163-
throw new Error(`Chutes token exchange failed: ${text}`);
164-
}
160+
await assertOkOrThrowProviderError(response, "Chutes token exchange failed");
165161

166162
const data = await readProviderJsonResponse<{
167163
access_token?: string;
@@ -236,10 +232,7 @@ export async function refreshChutesTokens(params: {
236232
body,
237233
signal: buildOAuthRequestSignal({ timeoutMs: CHUTES_OAUTH_REQUEST_TIMEOUT_MS }),
238234
});
239-
if (!response.ok) {
240-
const text = await readResponseTextLimited(response, CHUTES_OAUTH_ERROR_BODY_LIMIT_BYTES);
241-
throw new Error(`Chutes token refresh failed: ${text}`);
242-
}
235+
await assertOkOrThrowProviderError(response, "Chutes token refresh failed");
243236

244237
const data = await readProviderJsonResponse<{
245238
access_token?: string;

0 commit comments

Comments
 (0)