Compare commits

..

2 Commits

Author SHA1 Message Date
backryun
08a8a871be fix(docker): eliminate npm-bundled CVEs from the published image
Trivy reported 9 HIGH/MEDIUM CVEs against the npm CLI's own bundled
node_modules inside the published image (brace-expansion, ip-address,
tar, undici under /usr/local/lib/node_modules/npm/node_modules).

The base stage claimed `npm install -g npm@latest` shipped patched
copies. It does not: npm@12.0.2 (latest) bundles brace-expansion 5.0.7,
ip-address 10.2.0, tar 7.5.19 and undici 6.27.0 — all still vulnerable.
No npm release fixes them, so that step was buying zero CVEs.

Overlay the patched versions onto npm's bundled tree instead, pinned and
semver-compatible with the ranges npm's own tree declares (minimatch ->
brace-expansion ^5.0.5, socks -> ip-address ^10.1.1, node-gyp -> tar
^7.5.4 and undici ^6.25.0, so undici stays on 6.x). Removing npm from
the runner stages was not viable — the app shells out to npm at runtime
(installers/utils.ts::runNpm, system/autoUpdate.ts,
system/globalPackagePath.ts, api/system/version) — and the old comment
asserting otherwise is corrected.
2026-08-12 10:43:19 -03:00
backryun
93008f3bdb chore(repo): re-untrack the _tasks self-referential symlink
`caf768e3c4` untracked it; the DeepAI merge (44069c5f54, #9443) re-added
it. It is an absolute symlink pointing at one machine's checkout, and
AGENTS.md keeps `_tasks/` out of the main repo entirely. While tracked,
`check-tracked-artifacts.mjs` fails on pre-commit, so no commit can be
made on this branch at all — this restores the precedent fix purely to
unblock committing, and is unrelated to the Docker change that follows.
2026-08-12 10:43:10 -03:00
9 changed files with 228 additions and 273 deletions

View File

@@ -15,14 +15,46 @@ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
# ship the patched versions. These are npm's own internals — not application
# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
# but the container scanner flags the stale copies under
# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
# the runner stages, so this is hygiene, not an exploitable runtime path.
RUN npm install -g npm@latest \
&& npm cache clean --force
# npm's *bundled* node_modules (brace-expansion, ip-address, tar, undici) are
# npm's own internals — not application dependencies (the app resolves its own,
# already-fixed copies) — but the container scanner reads them off
# /usr/local/lib/node_modules/npm/node_modules and reports 9 HIGH/MEDIUM CVEs.
#
# Refreshing npm does NOT fix them. Measured on npm@12.0.2 (2026-08-12, latest):
# brace-expansion 5.0.7 (needs >= 5.0.9) CVE-2026-69152, CVE-2026-14257
# ip-address 10.2.0 (needs >= 10.3.1) CVE-2026-69192/-69198/-54272
# tar 7.5.19 (needs >= 7.5.21) GHSA-r292-9mhp-454m
# undici 6.27.0 (needs >= 6.28.0) CVE-2026-16729/-16728/-15157
# No published npm release carries patched copies, so `npm install -g npm@latest`
# alone was pure build time for zero CVEs — it is kept only to land on a known,
# current npm tree, and the patched copies are overlaid on top below.
#
# Deleting npm from the runner stages is NOT an option: the application shells
# out to npm at runtime (src/lib/services/installers/utils.ts::runNpm for the
# embedded services, src/lib/system/{autoUpdate,globalPackagePath}.ts,
# src/app/api/system/version). The previous version of this comment claimed the
# opposite; it was wrong.
#
# The overlay is semver-compatible with the ranges npm's own tree declares
# (minimatch → brace-expansion ^5.0.5, socks → ip-address ^10.1.1, node-gyp →
# tar ^7.5.4 and undici ^6.25.0 — hence undici stays on the 6.x line, NOT 8.x).
# --install-strategy=nested makes each replacement self-contained, so it cannot
# perturb the versions the rest of npm's flat tree resolves.
RUN set -eux; \
npm install -g npm@latest; \
npm install --prefix /tmp/npm-cve-patch --no-audit --no-fund --ignore-scripts \
--install-strategy=nested \
brace-expansion@5.0.9 ip-address@10.5.0 tar@7.5.22 undici@6.28.0; \
for pkg in brace-expansion ip-address tar undici; do \
test -d "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
rm -rf "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
cp -R "/tmp/npm-cve-patch/node_modules/$pkg" \
"/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
done; \
rm -rf /tmp/npm-cve-patch; \
node -e "for (const p of ['brace-expansion','ip-address','tar','undici']) console.log(p, require('/usr/local/lib/node_modules/npm/node_modules/'+p+'/package.json').version);"; \
npm --version; \
npm cache clean --force
# ── Builder ────────────────────────────────────────────────────────────────
FROM base AS builder

1
_tasks
View File

@@ -1 +0,0 @@
/home/diegosouzapw/dev/proxys/OmniRoute/_tasks

View File

@@ -1,5 +1,3 @@
import { randomUUID } from "node:crypto";
import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
import { initTinyCmsWasm, generateSecurePayload } from "./tinycmsSigner.ts";
@@ -30,9 +28,9 @@ async function fetchChallenge(uuid: string): Promise<any> {
const res = await fetch(CHALLENGE_URL, {
method: "GET",
headers: {
uuid: uuid,
"uuid": uuid,
"x-origin": "https://gov.freegpt.win",
Accept: "application/json",
"Accept": "application/json",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
},
});
@@ -69,11 +67,10 @@ export class TinyCmsExecutor extends BaseExecutor {
const challengeObj = await fetchChallenge(uuid);
const timestamp = Date.now().toString();
// Security context: this nonce is signed into `x-secure-signature` and
// reused as the session id, so it must be unpredictable. `node:crypto`
// randomUUID() is always available on the supported runtime — never fall
// back to Math.random() (CodeQL js/insecure-randomness).
const nonceJs = randomUUID();
const nonceJs =
typeof crypto !== "undefined" && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const securePayload = generateSecurePayload(
uuid,
@@ -125,7 +122,12 @@ export class TinyCmsExecutor extends BaseExecutor {
transformedBody: bodyObj,
};
} catch (err: any) {
return makeErrorResult(500, `TinyCMS Error: ${err.message}`, body, CHAT_URL);
return makeErrorResult(
500,
`TinyCMS Error: ${err.message}`,
body,
CHAT_URL
);
}
}
}

View File

@@ -237,15 +237,12 @@ function trustedEnvironmentText(parsed: CodexParsedRequest): string {
}
function decodeXmlText(value: string): string {
// `&amp;` MUST be decoded last: decoding it first produces a bare `&` that the
// later passes re-consume, so `&amp;quot;` would collapse to `"` instead of the
// literal `&quot;` (double-unescape — CodeQL js/double-escaping).
return value
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&amp;", "&");
.replaceAll("&#39;", "'");
}
function uniqueAbsolutePaths(values: string[], field: string): string[] {

View File

@@ -1,83 +0,0 @@
/**
* CodeQL alert 811 — js/double-escaping (HIGH) on
* `open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/environment.ts`.
*
* `decodeXmlText()` unescapes the XML entities of the trusted Codex
* `<environment_context>` block. It decoded `&amp;` BEFORE `&quot;` / `&#39;`,
* so the `&` it produced was re-consumed by a later `replaceAll` and the text
* was unescaped twice: `&amp;quot;` collapsed to `"` instead of `&quot;`.
*
* These values become sandbox `cwd` / `workspace_roots` paths, so a
* double-unescape silently rewrites the trusted workspace boundary.
* `&amp;` must be decoded LAST.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { extractChatGptTurnEnvironment } from "../../open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/environment.ts";
function parsedRequestWithCwd(cwdLiteral: string) {
const environmentText = [
"<environment_context>",
` <cwd>${cwdLiteral}</cwd>`,
" <sandbox_mode>read-only</sandbox_mode>",
"</environment_context>",
].join("\n");
const turnMetadata = { internal_chat_message_metadata_passthrough: { turn_id: "turn-1" } };
return {
context: { tools: [] },
_rawBody: {
client_metadata: {
"x-codex-turn-metadata": JSON.stringify({ thread_id: "thread-1", turn_id: "turn-1" }),
},
input: [
{ type: "message", role: "system", content: [{ type: "input_text", text: "sys" }] },
{
type: "message",
role: "user",
content: [{ type: "input_text", text: environmentText }],
...turnMetadata,
},
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "hello" }],
...turnMetadata,
},
],
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
test("decoding the trusted Codex environment does not double-unescape &amp;quot;", () => {
const env = extractChatGptTurnEnvironment(parsedRequestWithCwd("/tmp/ws&amp;quot;dir"));
assert.equal(
env.cwd,
"/tmp/ws&quot;dir",
'`&amp;quot;` must decode to the literal text `&quot;`, not to a double-unescaped `"`'
);
});
test("decoding the trusted Codex environment does not double-unescape &amp;lt; / &amp;#39;", () => {
assert.equal(
extractChatGptTurnEnvironment(parsedRequestWithCwd("/tmp/ws&amp;lt;dir")).cwd,
"/tmp/ws&lt;dir"
);
assert.equal(
extractChatGptTurnEnvironment(parsedRequestWithCwd("/tmp/ws&amp;#39;dir")).cwd,
"/tmp/ws&#39;dir"
);
});
test("single-level XML entities still decode normally", () => {
assert.equal(extractChatGptTurnEnvironment(parsedRequestWithCwd("/tmp/a&amp;b")).cwd, "/tmp/a&b");
assert.equal(
extractChatGptTurnEnvironment(parsedRequestWithCwd("/tmp/a&quot;b")).cwd,
'/tmp/a"b'
);
assert.equal(extractChatGptTurnEnvironment(parsedRequestWithCwd("/tmp/a&#39;b")).cwd, "/tmp/a'b");
assert.equal(extractChatGptTurnEnvironment(parsedRequestWithCwd("/tmp/a&gt;b")).cwd, "/tmp/a>b");
});

View File

@@ -0,0 +1,133 @@
/**
* Trivy image scan reported 9 HIGH/MEDIUM CVEs against the npm CLI's own
* *bundled* node_modules inside the published image:
*
* usr/local/lib/node_modules/npm/node_modules/brace-expansion CVE-2026-69152, CVE-2026-14257
* usr/local/lib/node_modules/npm/node_modules/ip-address CVE-2026-69192, CVE-2026-69198, CVE-2026-54272
* usr/local/lib/node_modules/npm/node_modules/tar GHSA-r292-9mhp-454m
* usr/local/lib/node_modules/npm/node_modules/undici CVE-2026-16729, CVE-2026-16728, CVE-2026-15157
*
* The `base` stage used to claim `npm install -g npm@latest` shipped patched
* copies. That was false: npm@12.0.2 (the latest release at the time) bundles
* brace-expansion 5.0.7, ip-address 10.2.0, tar 7.5.19 and undici 6.27.0 — every
* one still vulnerable. No npm release fixes these, so the Dockerfile now
* overlays the patched versions onto npm's bundled tree.
*
* Removing npm from the runner stages was NOT viable: npm is invoked at runtime
* by src/lib/services/installers/utils.ts::runNpm (embedded services),
* src/lib/system/autoUpdate.ts, src/lib/system/globalPackagePath.ts and
* src/app/api/system/version/route.ts.
*
* This guards the mechanism (the overlay exists, targets all four packages, and
* pins versions at or above the fixed ones). The end-to-end proof is a clean
* Trivy scan on the next published image — this sandbox has no Docker daemon.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf-8");
/** Dockerfile source with line continuations joined, the way the shell sees it. */
const joined = dockerfile.replace(/\\\n\s*/g, " ");
/** Instruction lines only — comments must never satisfy these assertions. */
const instructions = joined
.split("\n")
.filter((l) => !l.trim().startsWith("#"))
.join("\n");
/** package -> lowest version that is not affected by the reported CVEs. */
const FIXED_MINIMUMS: Record<string, [number, number, number]> = {
"brace-expansion": [5, 0, 9],
"ip-address": [10, 3, 1],
tar: [7, 5, 21],
undici: [6, 28, 0],
};
function parseVersion(raw: string): [number, number, number] {
const parts = raw.split(".").map((n) => Number.parseInt(n, 10));
assert.equal(parts.length, 3, `expected an exact x.y.z pin, got "${raw}"`);
assert.ok(
parts.every((n) => Number.isInteger(n)),
`expected an exact x.y.z pin, got "${raw}"`
);
return [parts[0], parts[1], parts[2]];
}
function isAtLeast(actual: [number, number, number], min: [number, number, number]): boolean {
for (let i = 0; i < 3; i++) {
if (actual[i] > min[i]) return true;
if (actual[i] < min[i]) return false;
}
return true;
}
test("base stage pins patched versions of every CVE-flagged npm-bundled package", () => {
for (const [pkg, min] of Object.entries(FIXED_MINIMUMS)) {
const match = new RegExp(`\\b${pkg}@(\\d+\\.\\d+\\.\\d+)\\b`).exec(instructions);
assert.ok(
match,
`Dockerfile must install an explicit patched ${pkg}@x.y.z for npm's bundled tree`
);
const actual = parseVersion(match[1]);
assert.ok(
isAtLeast(actual, min),
`${pkg}@${match[1]} is below the fixed version ${min.join(".")} — the Trivy alert would return`
);
}
});
test("undici stays on the 6.x line node-gyp declares (^6.25.0), never 8.x", () => {
const match = /\bundici@(\d+)\.\d+\.\d+\b/.exec(instructions);
assert.ok(match, "Dockerfile must pin an undici version");
assert.equal(
match[1],
"6",
"npm's bundled node-gyp declares undici ^6.25.0 — an 8.x overlay would break its resolution"
);
});
test("the patched copies actually replace npm's bundled ones", () => {
for (const pkg of Object.keys(FIXED_MINIMUMS)) {
assert.match(
instructions,
new RegExp(`for pkg in [^;]*\\b${pkg}\\b`),
`${pkg} must be part of the overlay loop that rewrites npm's bundled node_modules`
);
}
assert.match(
instructions,
/rm -rf "\/usr\/local\/lib\/node_modules\/npm\/node_modules\/\$pkg"/,
"the overlay must remove the vulnerable bundled copy before replacing it"
);
assert.match(
instructions,
/cp -R "\/tmp\/npm-cve-patch\/node_modules\/\$pkg"\s+"\/usr\/local\/lib\/node_modules\/npm\/node_modules\/\$pkg"/,
"the overlay must copy the patched package into npm's bundled node_modules"
);
assert.match(
instructions,
/test -d "\/usr\/local\/lib\/node_modules\/npm\/node_modules\/\$pkg"/,
"the overlay must fail the build loudly if npm's layout changes and a target path disappears"
);
});
test("the overlay smoke-tests npm after patching it", () => {
assert.match(
instructions,
/npm --version/,
"the patched npm must be exercised in the same layer so a broken overlay fails the build"
);
});
test("the stale 'npm is not invoked at runtime' claim is gone", () => {
assert.doesNotMatch(
dockerfile,
/npm is not invoked at runtime/,
"npm IS invoked at runtime (installers/utils.ts::runNpm, system/autoUpdate.ts, " +
"system/globalPackagePath.ts, api/system/version) — the comment must not claim otherwise"
);
});

View File

@@ -17,7 +17,10 @@ import assert from "node:assert/strict";
import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts";
import { REGISTRY } from "../../open-sse/config/providers/index.ts";
import { getExecutor, TinyCmsExecutor } from "../../open-sse/executors/index.ts";
import { setupDomMocks, type DomMockRestore } from "../../open-sse/executors/tinycmsSigner.ts";
import {
setupDomMocks,
type DomMockRestore,
} from "../../open-sse/executors/tinycmsSigner.ts";
// tinycmsSigner.ts intentionally does NOT install its window/document/canvas
// shims as a module-load side effect (see setupDomMocks() there) — doing so
@@ -38,10 +41,9 @@ after(() => {
// ── Catalog / WEB_COOKIE_PROVIDERS ────────────────────────────────────────────
test("tinycms-web is present in WEB_COOKIE_PROVIDERS", () => {
const p = (WEB_COOKIE_PROVIDERS as Record<string, unknown>)["tinycms-web"] as Record<
string,
unknown
>;
const p = (WEB_COOKIE_PROVIDERS as Record<string, unknown>)[
"tinycms-web"
] as Record<string, unknown>;
assert.ok(p, "WEB_COOKIE_PROVIDERS['tinycms-web'] must exist");
assert.equal(p.id, "tinycms-web");
assert.equal(p.alias, "tcw");
@@ -49,10 +51,9 @@ test("tinycms-web is present in WEB_COOKIE_PROVIDERS", () => {
});
test("tinycms-web WEB_COOKIE_PROVIDERS entry is marked as free-tier", () => {
const p = (WEB_COOKIE_PROVIDERS as Record<string, unknown>)["tinycms-web"] as Record<
string,
unknown
>;
const p = (WEB_COOKIE_PROVIDERS as Record<string, unknown>)[
"tinycms-web"
] as Record<string, unknown>;
assert.equal(p.hasFree, true);
assert.ok(typeof p.freeNote === "string" && (p.freeNote as string).length > 0);
assert.ok(typeof p.authHint === "string" && (p.authHint as string).length > 0);
@@ -78,7 +79,10 @@ test("tinycms-web registry has all expected models", () => {
assert.ok(ids.includes("gpt-5-free"), "gpt-5-free must be registered");
assert.ok(ids.includes("gpt-5.3-free"), "gpt-5.3-free must be registered");
assert.ok(ids.includes("gpt-5.3-thinking-free"), "gpt-5.3-thinking-free must be registered");
assert.ok(
ids.includes("gpt-5.3-thinking-free"),
"gpt-5.3-thinking-free must be registered"
);
assert.ok(ids.includes("deepseek-v4-flash"), "deepseek-v4-flash must be registered");
assert.ok(ids.includes("claude-sonnet-5"), "claude-sonnet-5 must be registered");
assert.ok(ids.includes("gemini-3.5-flash"), "gemini-3.5-flash must be registered");
@@ -136,7 +140,10 @@ test("TinyCmsExecutor returns 401 when UUID is missing", async () => {
assert.equal(result.response.status, 401);
const body = await result.response.json();
const errMsg = body?.error?.message || "";
assert.ok(errMsg.includes("Invalid or missing device UUID"), "error must mention missing UUID");
assert.ok(
errMsg.includes("Invalid or missing device UUID"),
"error must mention missing UUID"
);
// Hard Rule #12: must NOT leak stack traces
assert.ok(!errMsg.includes("at /"), "error must not contain a stack trace path");
});
@@ -154,7 +161,10 @@ test("TinyCmsExecutor returns 401 when UUID does not start with 'R'", async () =
assert.equal(result.response.status, 401);
const body = await result.response.json();
const errMsg = body?.error?.message || "";
assert.ok(errMsg.includes("Invalid or missing device UUID"), "error must mention missing UUID");
assert.ok(
errMsg.includes("Invalid or missing device UUID"),
"error must mention missing UUID"
);
assert.ok(!errMsg.includes("at /"), "error must not contain a stack trace path");
});
@@ -162,7 +172,7 @@ test("TinyCmsExecutor returns the standard executor response envelope on success
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
const url = String(input);
if (new URL(url).hostname === "api64.ipify.org") {
if (url.includes("api64.ipify.org")) {
return new Response(JSON.stringify({ ip: "127.0.0.1" }), {
headers: { "Content-Type": "application/json" },
});
@@ -207,7 +217,10 @@ test("TinyCmsExecutor returns the standard executor response envelope on success
test("initTinyCmsWasm module exports expected functions", async () => {
const signer = await import("../../open-sse/executors/tinycmsSigner.ts");
assert.ok(typeof signer.initTinyCmsWasm === "function", "must export initTinyCmsWasm function");
assert.ok(
typeof signer.initTinyCmsWasm === "function",
"must export initTinyCmsWasm function"
);
assert.ok(
typeof signer.generateSecurePayload === "function",
"must export generateSecurePayload function"
@@ -241,7 +254,10 @@ test("TinyCmsExecutor sanitizes errors (no stack traces in error response)", asy
assert.ok(result.response, "response must be present");
const body = await result.response.json();
const errMsg = body?.error?.message || "";
assert.ok(errMsg.includes("Invalid or missing device UUID"), "error must mention missing UUID");
assert.ok(
errMsg.includes("Invalid or missing device UUID"),
"error must mention missing UUID"
);
assert.ok(!errMsg.includes("at /"), "error must not contain a stack trace path (Hard Rule #12)");
});
@@ -258,6 +274,12 @@ test("tinycms-web credential requirement is kind: token with app-config-uuid", a
assert.equal(req.credentialName, "app-config-uuid");
assert.equal(req.acceptsFullCookieHeader, false);
assert.ok(Array.isArray(req.storageKeys), "must have storageKeys array");
assert.ok((req.storageKeys as string[]).includes("apiKey"), "apiKey must be in storageKeys");
assert.ok((req.storageKeys as string[]).includes("uuid"), "uuid must be in storageKeys");
assert.ok(
(req.storageKeys as string[]).includes("apiKey"),
"apiKey must be in storageKeys"
);
assert.ok(
(req.storageKeys as string[]).includes("uuid"),
"uuid must be in storageKeys"
);
});

View File

@@ -1,142 +0,0 @@
/**
* CodeQL alert 806 — js/insecure-randomness (HIGH) on
* `open-sse/executors/tinycms.ts`.
*
* The TinyCMS executor derives `x-secure-nonce` / `x-session-id` from a nonce
* that is fed into the upstream request signature (`generateSecurePayload`).
* That is a security context, so the nonce must never fall back to
* `Math.random()` — a predictable nonce lets an observer replay or forge a
* signed request.
*
* The regression guard runs the executor with a `globalThis.crypto` that has no
* `randomUUID` (the exact condition that used to select the `Math.random()`
* fallback) and asserts the emitted nonce is still a cryptographically strong
* UUID.
*/
import test, { before, after } from "node:test";
import assert from "node:assert/strict";
import { TinyCmsExecutor } from "../../open-sse/executors/index.ts";
import { setupDomMocks, type DomMockRestore } from "../../open-sse/executors/tinycmsSigner.ts";
let restoreDomMocks: DomMockRestore;
before(() => {
restoreDomMocks = setupDomMocks();
});
after(() => {
restoreDomMocks();
});
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
test("TinyCMS nonce stays cryptographically strong when globalThis.crypto has no randomUUID", async () => {
const originalFetch = globalThis.fetch;
const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")!;
const realCrypto = globalThis.crypto;
// Keep every other WebCrypto capability, drop only `randomUUID`. This is the
// branch that previously fell back to `Math.random()`.
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: {
getRandomValues: (array: ArrayBufferView) => realCrypto.getRandomValues(array as never),
subtle: realCrypto.subtle,
},
});
const seenHeaders: Record<string, string>[] = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = String(input);
if (new URL(url).hostname === "api64.ipify.org") {
return new Response(JSON.stringify({ ip: "127.0.0.1" }), {
headers: { "Content-Type": "application/json" },
});
}
if (new URL(url).pathname === "/api/challenge") {
return new Response(
JSON.stringify({
challenge: "test",
challengeId: "challenge-id",
expiresAt: Date.now() + 60_000,
version: "1",
difficulty: 0,
}),
{ headers: { "Content-Type": "application/json" } }
);
}
seenHeaders.push((init?.headers ?? {}) as Record<string, string>);
return new Response("upstream body", { status: 200 });
}) as typeof fetch;
try {
await new TinyCmsExecutor().execute({
model: "gpt-5-free",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "Rtest-device" },
});
assert.equal(seenHeaders.length, 1, "the executor must reach the chat endpoint exactly once");
const headers = seenHeaders[0]!;
assert.match(
headers["x-secure-nonce"] ?? "",
UUID_RE,
"x-secure-nonce must be a crypto-strong UUID, never a Math.random() fallback"
);
assert.match(
headers["x-session-id"] ?? "",
UUID_RE,
"x-session-id must be a crypto-strong UUID, never a Math.random() fallback"
);
} finally {
globalThis.fetch = originalFetch;
Object.defineProperty(globalThis, "crypto", originalCryptoDescriptor);
}
});
test("consecutive TinyCMS nonces are unique", async () => {
const originalFetch = globalThis.fetch;
const nonces: string[] = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = String(input);
if (new URL(url).hostname === "api64.ipify.org") {
return new Response(JSON.stringify({ ip: "127.0.0.1" }), {
headers: { "Content-Type": "application/json" },
});
}
if (new URL(url).pathname === "/api/challenge") {
return new Response(
JSON.stringify({
challenge: "test",
challengeId: "challenge-id",
expiresAt: Date.now() + 60_000,
version: "1",
difficulty: 0,
}),
{ headers: { "Content-Type": "application/json" } }
);
}
nonces.push(((init?.headers ?? {}) as Record<string, string>)["x-secure-nonce"] ?? "");
return new Response("upstream body", { status: 200 });
}) as typeof fetch;
try {
const executor = new TinyCmsExecutor();
for (let i = 0; i < 3; i += 1) {
await executor.execute({
model: "gpt-5-free",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "Rtest-device" },
});
}
assert.equal(nonces.length, 3);
assert.equal(new Set(nonces).size, 3, "each request must carry a distinct nonce");
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -55,19 +55,14 @@ test("#8014: ZaiWebExecutor must POST to the current chat.z.ai v2 chat-completio
assert.ok(requested.length > 0, "the direct path must actually reach fetch");
assert.ok(
// Exact-URL match (not a substring test): `requested` holds whole URLs.
!requested.some((url) => url === STALE_URL),
!requested.includes(STALE_URL),
`zai-web executor POSTed to the stale endpoint — matches #8014's model-independent 404 "Not Found"`
);
// The executor also probes the homepage for the frontend version and calls
// /api/v1/chats/new first, so pick the completions request by its path.
const completions = requested.filter((u) => new URL(u).pathname.endsWith("/chat/completions"));
assert.equal(
completions.length,
1,
`expected exactly one completions request, got ${requested}`
);
assert.equal(completions.length, 1, `expected exactly one completions request, got ${requested}`);
assert.equal(
new URL(completions[0]).pathname,
"/api/v2/chat/completions",