diff --git a/src/oauth/meta-muse-device.ts b/src/oauth/meta-muse-device.ts index 70ec725782..8ed273f52c 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/runtime.md b/structure/runtime.md index 98b795a188..9c05b77d89 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -169,7 +169,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | --- | --- | | `src/providers/registry.ts` | Canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata. | | `src/providers/derive.ts` | Enrichment from provider presets into user config. | -| `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 6341efe050..6223e0d462 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);