diff --git a/open-sse/executors/maxai/signing.ts b/open-sse/executors/maxai/signing.ts index 629d967f32..8de8c41eb3 100644 --- a/open-sse/executors/maxai/signing.ts +++ b/open-sse/executors/maxai/signing.ts @@ -23,7 +23,7 @@ * The non-secret STRUCTURAL fields (appVersion, ctxKey, header names) carry safe * defaults so a transient parse miss can't break an otherwise-working signer. */ -import { createHmac, createHash, createCipheriv, randomBytes } from "node:crypto"; +import { createHmac, createHash, createCipheriv, randomBytes, randomInt } from "node:crypto"; import type { MaxaiSigningConstants, MaxaiHeaderNames } from "./constants.ts"; import { MAXAI_DEFAULT_HEADER_NAMES } from "./constants.ts"; @@ -39,8 +39,22 @@ const BLANK_USER_ROUTES = new Set([ const MAGIC = Buffer.from("Salted__", "ascii"); +/** + * The wire `X-Random` slot: a 6-digit decimal string (100000-999999). + * + * Uses `crypto.randomInt`, which rejection-samples internally, instead of + * `randomBytes(4) % 900000` — a plain modulo over a 32-bit draw does not divide + * evenly by 900000, so the low ~4772 values of the range came out marginally + * more often. The emitted shape is unchanged (always exactly 6 digits). + */ +export function maxaiRandomSlot(): string { + return String(randomInt(100000, 1000000)); +} + function hmacSha1Hex(message: string, key: string): string { - return createHmac("sha1", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("hex"); + return createHmac("sha1", Buffer.from(key, "utf8")) + .update(Buffer.from(message, "utf8")) + .digest("hex"); } function sm3Hex(message: string): string { @@ -58,7 +72,9 @@ function evpBytesToKey( let block = Buffer.alloc(0); const pass = Buffer.from(passphrase, "utf8"); while (derived.length < keyLen + ivLen) { - block = createHash("md5").update(Buffer.concat([block, pass, salt])).digest(); + block = createHash("md5") + .update(Buffer.concat([block, pass, salt])) + .digest(); derived = Buffer.concat([derived, block]); } return { key: derived.subarray(0, keyLen), iv: derived.subarray(keyLen, keyLen + ivLen) }; @@ -124,8 +140,7 @@ export function buildMaxaiSignedHeaders( constants: MaxaiSigningConstants ): Record { const reqTime = (input.now ?? (() => Date.now()))(); - const random = - input.random?.() ?? String((randomBytes(4).readUInt32BE(0) % 900000) + 100000); + const random = input.random?.() ?? maxaiRandomSlot(); const h: MaxaiHeaderNames = { ...MAXAI_DEFAULT_HEADER_NAMES, ...constants.headerNames }; const ctxKey = constants.ctxKey; const appVersion = constants.appVersion; diff --git a/tests/unit/custom-provider-prefix-shadowing-11943.test.ts b/tests/unit/custom-provider-prefix-shadowing-11943.test.ts index 7ad3e08fa2..f128c93556 100644 --- a/tests/unit/custom-provider-prefix-shadowing-11943.test.ts +++ b/tests/unit/custom-provider-prefix-shadowing-11943.test.ts @@ -95,10 +95,14 @@ test("handleChat names the shadowed custom node when the built-in prefix has no /prefix "of" is reserved by the built-in provider "openference"/, `runtime error must explain that the prefix resolved to the built-in, got: ${message}` ); - assert.match( - message, - new RegExp(`"${SHADOWED_NODE_NAME.replace(/[()]/g, "\\$&")}" \\(${SHADOWED_NODE_ID}\\)`), - `runtime error must name the shadowed node and its id, got: ${message}` + // Exact substring, not a hand-escaped RegExp: the name carries regex + // metacharacters (parentheses) and the previous `.replace(/[()]/g, …)` escaped + // only those, so any other metachar in a future name would have been + // interpreted instead of matched literally (CodeQL js/incomplete-sanitization). + const expectedNodeMention = `"${SHADOWED_NODE_NAME}" (${SHADOWED_NODE_ID})`; + assert.ok( + message.includes(expectedNodeMention), + `runtime error must name the shadowed node and its id (${expectedNodeMention}), got: ${message}` ); assert.match(message, /Rename that node's prefix/); }); diff --git a/tests/unit/helpers/ucClerkUrl.ts b/tests/unit/helpers/ucClerkUrl.ts new file mode 100644 index 0000000000..5c451983ef --- /dev/null +++ b/tests/unit/helpers/ucClerkUrl.ts @@ -0,0 +1,31 @@ +/** + * Strict recognizer for the UC (uncensored.com) Clerk session-token mint call, + * shared by the uc-image / uc-video mock `fetch` routers. + * + * The mock routers used to dispatch on `url.includes("clerk.uncensored.com")`. + * That is a substring test over a whole URL, so ANY host answers as long as the + * name appears somewhere in it — `https://evil.example/?next=clerk.uncensored.com` + * would have been served the mint response. A test whose router accepts a + * malformed URL cannot fail when the executor builds one, which is exactly the + * regression such a test exists to catch (and CodeQL flags it as + * `js/incomplete-url-substring-sanitization`). + * + * This matches the real shape instead: + * POST https://clerk.uncensored.com/v1/client/sessions/{sid}/tokens?_clerk_js_version=… + * comparing the parsed origin against the production constant and pinning the + * path shape. + */ +import { UC_CLERK_FAPI } from "../../../open-sse/executors/uc/constants.ts"; + +const MINT_PATH = /^\/v1\/client\/sessions\/[^/]+\/tokens$/; + +/** True only for the Clerk mint endpoint on the real Clerk FAPI origin. */ +export function isUcClerkMintUrl(raw: unknown): boolean { + let parsed: URL; + try { + parsed = new URL(String(raw)); + } catch { + return false; + } + return parsed.origin === UC_CLERK_FAPI && MINT_PATH.test(parsed.pathname); +} diff --git a/tests/unit/maxai-image.test.ts b/tests/unit/maxai-image.test.ts index 76eff868f5..888d559b43 100644 --- a/tests/unit/maxai-image.test.ts +++ b/tests/unit/maxai-image.test.ts @@ -9,6 +9,7 @@ import { } from "../../open-sse/handlers/imageGeneration/providers/maxaiImage.ts"; import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; import { __setMaxaiConstantsForTest } from "../../open-sse/executors/maxai/constantsStore.ts"; +import { MAXAI_BASE_URL } from "../../open-sse/executors/maxai/protocol.ts"; import { MOCK_CONSTANTS } from "./helpers/maxaiMockConstants.ts"; // Image generation signs like any request; seed the in-process constants memo @@ -28,7 +29,9 @@ const CRED = { // --- Registry ------------------------------------------------------------ test("maxai is registered in IMAGE_PROVIDERS with the maxai-image format + 6 models", () => { - const entry = (IMAGE_PROVIDERS as Record)["maxai"]; + const entry = ( + IMAGE_PROVIDERS as Record + )["maxai"]; assert.ok(entry, "maxai must exist in IMAGE_PROVIDERS"); assert.equal(entry.format, "maxai-image"); assert.match(String(entry.baseUrl), /api\.maxai\.me\/gpt\/get_image_generate_response/); @@ -93,7 +96,10 @@ test("handleMaxaiImageGeneration returns OpenAI image data on success", async () ok: true, status: 200, async json() { - return { status: "OK", data: [{ png_url: "https://cdn/x.png", webp_url: "https://cdn/x.webp" }] }; + return { + status: "OK", + data: [{ png_url: "https://cdn/x.png", webp_url: "https://cdn/x.webp" }], + }; }, async text() { return ""; @@ -111,8 +117,12 @@ test("handleMaxaiImageGeneration returns OpenAI image data on success", async () assert.equal(result.success, true); assert.deepEqual(result.data?.data, [{ url: "https://cdn/x.png" }]); - // Hit the image endpoint with the signed body. - assert.match(capturedUrl, new RegExp(MAXAI_IMAGE_PATH.replace(/\//g, "\\/"))); + // Hit the image endpoint with the signed body. Exact URL equality instead of a + // hand-escaped RegExp over the path — the old `.replace(/\//g, "\\/")` escaped + // only slashes (which need no escaping in a RegExp anyway) and would have let + // any other metacharacter through (CodeQL js/incomplete-sanitization), while + // also accepting the path appearing anywhere in a wrong URL. + assert.equal(capturedUrl, MAXAI_BASE_URL + MAXAI_IMAGE_PATH); assert.equal(capturedBody.model_name, "flux-1-schnell"); assert.equal(capturedBody.size, "512x512"); // flux passes size through assert.equal(capturedBody.n, 2); diff --git a/tests/unit/maxai.test.ts b/tests/unit/maxai.test.ts index 1372c9b646..934fe1583f 100644 --- a/tests/unit/maxai.test.ts +++ b/tests/unit/maxai.test.ts @@ -12,6 +12,7 @@ import { computeMaxaiProof, maxaiAesEncrypt, buildMaxaiSignedHeaders, + maxaiRandomSlot, } from "../../open-sse/executors/maxai/signing.ts"; import { assembleMaxaiContext, @@ -103,7 +104,13 @@ test("computeMaxaiProof blanks the user id only on /oauth/* routes", () => { // A blank-user route yields a different proof than the same route with a uid, // proving the uid is dropped for /oauth/* (and only there). const t = 1784594159681; - const oauthWithUid = computeMaxaiProof("/oauth/signin_with_email", t, USER_ID, HMAC_KEY, APP_VERSION); + const oauthWithUid = computeMaxaiProof( + "/oauth/signin_with_email", + t, + USER_ID, + HMAC_KEY, + APP_VERSION + ); const oauthNoUid = computeMaxaiProof("/oauth/signin_with_email", t, "", HMAC_KEY, APP_VERSION); assert.equal(oauthWithUid, oauthNoUid); // uid ignored for /oauth/* const chatWithUid = computeMaxaiProof("/gpt/cwc/chat", t, USER_ID, HMAC_KEY, APP_VERSION); @@ -306,7 +313,28 @@ test("buildMaxaiSignedHeaders emits the X-App/X-Browser companions + X-Authoriza assert.equal(h["X-App-Version"], MOCK_APP_VERSION); assert.equal(h["X-App-Env"], "MaxAI-Browser-Extension"); assert.ok(h["X-Authorization"].length > 0); - assert.equal(Buffer.from(h["X-Authorization"], "base64").subarray(0, 8).toString("ascii"), "Salted__"); + assert.equal( + Buffer.from(h["X-Authorization"], "base64").subarray(0, 8).toString("ascii"), + "Salted__" + ); +}); + +test("maxaiRandomSlot emits an unbiased 6-digit X-Random slot", () => { + // The wire slot is always exactly 6 decimal digits, i.e. 100000-999999. + const samples = Array.from({ length: 4000 }, () => maxaiRandomSlot()); + for (const s of samples) { + assert.match(s, /^\d{6}$/, `X-Random must be 6 digits, got: ${s}`); + const n = Number(s); + assert.ok(n >= 100000 && n <= 999999, `X-Random out of range: ${s}`); + } + // Regression guard for the modulo bias the previous + // `randomBytes(4).readUInt32BE(0) % 900000` draw introduced: the value must + // still spread across the whole range, not collapse onto its low end. + assert.ok(new Set(samples).size > samples.length * 0.9, "X-Random must not repeat heavily"); + assert.ok( + samples.some((s) => Number(s) < 550000) && samples.some((s) => Number(s) >= 550000), + "X-Random must cover both halves of the 100000-999999 range" + ); }); // ── Context assembly ───────────────────────────────────────────────────────── @@ -364,7 +392,12 @@ test("contentToText flattens multipart content, dropping non-text parts", () => }); test("buildMaxaiChatBody pins field order + constants", () => { - const body = buildMaxaiChatBody({ conversationId: "conv-1", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION }); + const body = buildMaxaiChatBody({ + conversationId: "conv-1", + text: "hi", + modelName: "gpt-5.6", + appVersion: APP_VERSION, + }); const keys = Object.keys(body); assert.equal(keys[0], "chat_mode"); assert.equal(keys[3], "message_content"); @@ -379,7 +412,12 @@ test("buildMaxaiChatBody pins field order + constants", () => { // ── Vision input (image_url parts) ─────────────────────────────────────────── test("buildMaxaiChatBody text-only path is unchanged (no imageUrls)", () => { - const body = buildMaxaiChatBody({ conversationId: "c", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION }); + const body = buildMaxaiChatBody({ + conversationId: "c", + text: "hi", + modelName: "gpt-5.6", + appVersion: APP_VERSION, + }); // Byte-identical to the pre-vision shape: a single text part. assert.deepEqual(body.message_content, [{ type: "text", text: "hi" }]); assert.deepEqual(body.doc_list, []); @@ -563,8 +601,7 @@ test("maxaiRefreshAccessToken sends the exact web-app request + parses data.acce test("maxaiRefreshAccessToken returns a structured error on non-200 (no throw)", async () => { const nowSec = Math.floor(Date.now() / 1000); - const fakeFetch = (async () => - new Response("nope", { status: 418 })) as unknown as typeof fetch; + const fakeFetch = (async () => new Response("nope", { status: 418 })) as unknown as typeof fetch; const result = await maxaiRefreshAccessToken({ refreshToken: fakeJwt(nowSec + 1000, USER_ID), deviceId: "dev", @@ -687,7 +724,9 @@ test("verifyMaxaiEmailCode maps code 10119 to an expired-code message", async () test("verifyMaxaiEmailCode defaults to an invalid-code message otherwise", async () => { const fakeFetch = (async () => - new Response(JSON.stringify({ data: { status: "FAIL" } }), { status: 200 })) as unknown as typeof fetch; + new Response(JSON.stringify({ data: { status: "FAIL" } }), { + status: 200, + })) as unknown as typeof fetch; const r = await verifyMaxaiEmailCode({ email: "x@y.z", code: "999999", @@ -1009,10 +1048,9 @@ test("discoverMaxaiModels drops deprecated, non-chat, and non-curated models", a test("discoverMaxaiModels falls back to the catalog window when max_tokens is absent", async () => { const fakeFetch = (async () => - new Response( - modelsConfigBody([{ model_name: "claude-5-sonnet", type: "chat" }]), - { status: 200 } - )) as unknown as typeof fetch; + new Response(modelsConfigBody([{ model_name: "claude-5-sonnet", type: "chat" }]), { + status: 200, + })) as unknown as typeof fetch; const { models } = await discoverMaxaiModels({ providerSpecificData: DISCOVERY_CRED.providerSpecificData, accessToken: DISCOVERY_CRED.accessToken, diff --git a/tests/unit/uc-image.test.ts b/tests/unit/uc-image.test.ts index cb947cef45..8aaedaf630 100644 --- a/tests/unit/uc-image.test.ts +++ b/tests/unit/uc-image.test.ts @@ -9,6 +9,7 @@ import { UC_DIRECT_IMAGE_URL, } from "../../open-sse/handlers/imageGeneration/providers/ucImage.ts"; import { IMAGE_PROVIDERS, parseImageModel } from "../../open-sse/config/imageRegistry.ts"; +import { isUcClerkMintUrl } from "./helpers/ucClerkUrl.ts"; // A valid PERSONA credential (durable Clerk cookie + sid + uid in psd). No API // key, so the handler takes the persona web path (mint -> POST -> poll). @@ -144,7 +145,7 @@ function personaFetch(opts: { let pollsSeen = 0; return (async (url: string, init: RequestInit = {}) => { // 1) Clerk mint - if (url.includes("clerk.uncensored.com")) { + if (isUcClerkMintUrl(url)) { return { ok: true, status: 200, @@ -265,7 +266,7 @@ test("handleUcImageGeneration (persona) times out with 504 when the result never test("handleUcImageGeneration (persona) surfaces a Clerk mint failure", async () => { const fetchImpl = (async (url: string) => { - if (url.includes("clerk.uncensored.com")) { + if (isUcClerkMintUrl(url)) { return { ok: false, status: 401, diff --git a/tests/unit/uc-video.test.ts b/tests/unit/uc-video.test.ts index af204f2ffc..dfd7c3f2c0 100644 --- a/tests/unit/uc-video.test.ts +++ b/tests/unit/uc-video.test.ts @@ -13,6 +13,7 @@ import { UC_DIRECT_VIDEO_URL, } from "../../open-sse/handlers/videoGeneration/providers/ucVideo.ts"; import { VIDEO_PROVIDERS } from "../../open-sse/config/videoRegistry.ts"; +import { isUcClerkMintUrl } from "./helpers/ucClerkUrl.ts"; // A valid PERSONA credential (durable Clerk cookie + sid + uid in psd). No API // key, so the handler takes the persona web path (mint -> generate -> poll). @@ -148,7 +149,7 @@ function personaFetch(opts: { let pollsSeen = 0; return (async (url: string, init: RequestInit = {}) => { // Clerk mint - if (url.includes("clerk.uncensored.com")) { + if (isUcClerkMintUrl(url)) { return { ok: true, status: 200, @@ -339,7 +340,7 @@ test("handleUcVideoGeneration (persona) times out with 504 when never ready", as test("handleUcVideoGeneration (persona) surfaces a Clerk mint failure", async () => { const fetchImpl = (async (url: string) => { - if (url.includes("clerk.uncensored.com")) { + if (isUcClerkMintUrl(url)) { return { ok: false, status: 401,