From b1d06a28334ccec12a57dc99705a395cb48f74dd Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:39:53 +0900 Subject: [PATCH 1/2] fix(oauth): bound Meta Muse device response bodies --- src/oauth/meta-muse-device.ts | 48 +++++++++++++++++++++--- structure/providers-and-adapters.md | 2 +- tests/providers/meta-muse-device.test.ts | 28 ++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/oauth/meta-muse-device.ts b/src/oauth/meta-muse-device.ts index 70ec7257821..8ed273f52c4 100644 --- a/src/oauth/meta-muse-device.ts +++ b/src/oauth/meta-muse-device.ts @@ -23,6 +23,7 @@ */ import type { OAuthController, OAuthCredentials } from "./types"; import { sanitizeApiKeyValue } from "../providers/api-keys"; +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; /** Meta's own Muse Code client. Public in its device-approval URL; not a secret. */ const CLIENT_ID = "1031625952748946"; @@ -173,12 +174,45 @@ function requestSignal(signal: AbortSignal | undefined): AbortSignal { return signal ? AbortSignal.any([signal, timeout]) : timeout; } +async function readMuseJson( + response: Response, + signal: AbortSignal, + kind: "device-authorization" | "device-token" | "mint-invalid", +): Promise | undefined> { + const declaredLength = response.headers.get("content-length"); + if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > BOUNDED_BODY_MAX_BYTES) { + void response.body?.cancel().catch(() => undefined); + throw new MuseDeviceLoginError( + kind, + `Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`, + { status: response.status }, + ); + } + const { bytes, oversized } = await readBoundedResponseBytes(response, { + maxBytes: BOUNDED_BODY_MAX_BYTES, + signal, + }); + if (oversized) { + throw new MuseDeviceLoginError( + kind, + `Muse Code response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`, + { status: response.status }, + ); + } + try { + return record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + } catch { + return undefined; + } +} + /** Step 1: ask Meta for a user code. */ export async function requestMuseDeviceAuthorization( deps: MuseDeviceDeps = {}, signal?: AbortSignal, ): Promise { const now = deps.now ?? Date.now; + const request = requestSignal(signal); const response = await (deps.fetchImpl ?? fetch)(DEVICE_AUTHORIZATION_URL, { method: "POST", headers: { @@ -188,7 +222,7 @@ export async function requestMuseDeviceAuthorization( }, body: new URLSearchParams({ client_id: CLIENT_ID }).toString(), redirect: "error", - signal: requestSignal(signal), + signal: request, }); if (!response.ok) { throw new MuseDeviceLoginError( @@ -197,7 +231,7 @@ export async function requestMuseDeviceAuthorization( { status: response.status }, ); } - const payload = record(await response.json().catch(() => undefined)); + const payload = await readMuseJson(response, request, "device-authorization"); const deviceCode = text(payload?.device_code); const userCode = text(payload?.user_code); if (!deviceCode || !userCode) { @@ -241,6 +275,7 @@ export async function pollMuseDeviceToken( // shape checked the deadline at the top, so a sleep ending exactly at the deadline // skipped the final poll and discarded an approval the user had already completed // inside that window. + const request = requestSignal(signal); const response = await (deps.fetchImpl ?? fetch)(DEVICE_TOKEN_URL, { method: "POST", headers: { @@ -254,9 +289,9 @@ export async function pollMuseDeviceToken( grant_type: DEVICE_GRANT_TYPE, }).toString(), redirect: "error", - signal: requestSignal(signal), + signal: request, }); - const payload = record(await response.json().catch(() => undefined)); + const payload = await readMuseJson(response, request, "device-token"); if (response.ok) { // [W3] No deadline re-check here. If Meta answered 200 with a token, Meta accepted // the device code; its clock is authoritative and ours is not. Discarding an issued @@ -322,6 +357,7 @@ export async function mintMuseApiKey( signal?: AbortSignal, ): Promise { const now = deps.now ?? Date.now; + const request = requestSignal(signal); const response = await (deps.fetchImpl ?? fetch)(MUSE_KEY_URL, { method: "POST", headers: { @@ -332,7 +368,7 @@ export async function mintMuseApiKey( }, body: JSON.stringify(options.onboard ? { onboard: true } : {}), redirect: "error", - signal: requestSignal(signal), + signal: request, }); if (response.status === 429) { const wait = retryAfterMs(response.headers.get("retry-after"), now()); @@ -352,7 +388,7 @@ export async function mintMuseApiKey( { status: response.status }, ); } - const payload = record(await response.json().catch(() => undefined)); + const payload = await readMuseJson(response, request, "mint-invalid"); if (!payload) { throw new MuseDeviceLoginError("mint-invalid", "Muse Code key exchange returned an unreadable response", { status: response.status, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 7dda9c214c6..27dde0768c8 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -11,7 +11,7 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest | `src/providers/derive.ts` | Enrichment from provider presets into user config. | | `src/providers/resolved-model-policy.ts`, `src/providers/resolved-model-policy-merge.ts` | Static provider/model policy resolution for the final upstream wire model, plus its pure clone/merge/URL/family helpers. The resolver detaches and freezes registry defaults, operator overrides, exact explicit input-modality declarations, hard wire pins, aliases, and explicit false/empty values with field-level provenance. Provider derivation, routing, catalog hints, gather admission, and adapter selection consume its detached frozen result. Callers supply transport match, the exact capability row, and a credential-free effective auth decision; credential bytes, usability evidence, account/quota/health state, and observed limits remain outside the result. | -| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | +| `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. Meta Muse device authorization, polling, and key-mint JSON responses share the 64 KiB bounded-body ceiling and the request's deadline; oversized declared or streamed bodies are rejected before JSON parsing. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | diff --git a/tests/providers/meta-muse-device.test.ts b/tests/providers/meta-muse-device.test.ts index 6341efe050b..6223e0d4626 100644 --- a/tests/providers/meta-muse-device.test.ts +++ b/tests/providers/meta-muse-device.test.ts @@ -27,6 +27,7 @@ const KEY = `LLM|${"1".repeat(16)}|${"c".repeat(27)}`; const ACCOUNT_TOKEN = "meta-account-" + "z".repeat(48); /** If this string ever reaches an error message, a response body leaked into one. */ const BODY_CANARY = "canary-body-must-never-appear-in-an-error"; +const OVERSIZED_JSON = JSON.stringify({ value: "x".repeat(65_536) }); interface Reply { status?: number; body?: unknown; text?: string; headers?: Record } interface Scenario { @@ -132,6 +133,13 @@ describe("muse device authorization", () => { expect(error.message).toContain("500"); expect(error.message).not.toContain(BODY_CANARY); }); + + test("rejects an oversized streamed authorization response", async () => { + const h = harness({ auth: { text: OVERSIZED_JSON } }); + const error = await caught(() => requestMuseDeviceAuthorization(h.deps)); + expect(error.kind).toBe("device-authorization"); + expect(error.message).toContain("65536-byte limit"); + }); }); describe("muse device poll", () => { @@ -217,6 +225,14 @@ describe("muse device poll", () => { expect(error.kind).toBe("device-token"); }); + test("rejects an oversized token response instead of polling again", async () => { + const h = harness({ tokens: [{ text: OVERSIZED_JSON }] }); + const auth = await requestMuseDeviceAuthorization(h.deps); + const error = await caught(() => pollMuseDeviceToken(auth, h.deps)); + expect(error.kind).toBe("device-token"); + expect(h.calls.token).toBe(1); + }); + // W4 and W3 together: the last seconds of a grant must still be polled, and a token // the server issued in that window must not be thrown away by a local clock. test("polls once more inside the final seconds and accepts a late token", async () => { @@ -284,6 +300,18 @@ describe("muse key mint", () => { expect(error.message).not.toContain(BODY_CANARY); }); + test("rejects an oversized declared mint response before consuming it", async () => { + let cancelled = false; + const fetchImpl = (async () => new Response(new ReadableStream({ + pull() {}, + cancel() { cancelled = true; }, + }), { headers: { "content-length": "65537" } })) as typeof fetch; + const error = await caught(() => mintMuseApiKey(ACCOUNT_TOKEN, {}, { fetchImpl })); + expect(error.kind).toBe("mint-invalid"); + expect(error.message).toContain("65536-byte limit"); + expect(cancelled).toBeTrue(); + }); + test("lowercases the email and keeps the usage object", async () => { const h = harness({ mint: { body: { ...MINT_OK, subs_usage: { weekly: { used_percent: 4 } } } } }); const payload = await mintMuseApiKey(ACCOUNT_TOKEN, {}, h.deps); From ecfd3f0805eca6e5bad3cd00785265c015a77048 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:00:46 +0900 Subject: [PATCH 2/2] fix(oauth): cancel unparsed error bodies and pin the token bound in the poll test --- src/oauth/meta-muse-device.ts | 7 ++++++- tests/providers/meta-muse-device.test.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/oauth/meta-muse-device.ts b/src/oauth/meta-muse-device.ts index 8ed273f52c4..985f8157eb5 100644 --- a/src/oauth/meta-muse-device.ts +++ b/src/oauth/meta-muse-device.ts @@ -225,6 +225,9 @@ export async function requestMuseDeviceAuthorization( signal: request, }); if (!response.ok) { + // The error body is never parsed, but it still has to be released: an unread + // response body keeps the underlying connection occupied. + void response.body?.cancel().catch(() => undefined); throw new MuseDeviceLoginError( "device-authorization", `Muse Code device authorization request failed: HTTP ${response.status}`, @@ -381,7 +384,9 @@ export async function mintMuseApiKey( ); } if (!response.ok) { - // Status only. The body of this endpoint can carry the key itself. + // Status only. The body of this endpoint can carry the key itself, so it is + // never parsed — but it is still cancelled so the connection is released. + void response.body?.cancel().catch(() => undefined); throw new MuseDeviceLoginError( "mint-http", `Muse Code key exchange failed: HTTP ${response.status}`, diff --git a/tests/providers/meta-muse-device.test.ts b/tests/providers/meta-muse-device.test.ts index 6223e0d4626..7113371b781 100644 --- a/tests/providers/meta-muse-device.test.ts +++ b/tests/providers/meta-muse-device.test.ts @@ -230,6 +230,9 @@ describe("muse device poll", () => { const auth = await requestMuseDeviceAuthorization(h.deps); const error = await caught(() => pollMuseDeviceToken(auth, h.deps)); expect(error.kind).toBe("device-token"); + // A 200 without access_token also ends as device-token, so kind alone cannot tell + // the limit fired; the message names the bound. + expect(error.message).toContain("65536-byte limit"); expect(h.calls.token).toBe(1); });