Files
OmniRoute/tests/unit/providers-autosync-ssrf-323.test.ts
MumuTW b4776a2d84 test(quality): fail loudly when a source-scanning guard is negative-only (#8619)
* test(quality): fail loudly when a source-scanning guard is negative-only

A negative guard — assert.doesNotMatch(src, /x/) or src.includes(x) === false —
passes against an empty string. Once the code it guards is extracted into another
file the parent no longer contains the string, so the assertion keeps passing while
protecting nothing. The regression coverage is deleted with no test turning red,
which is exactly the failure mode the god-file decomposition campaign (#8617) is
about to trigger 90-odd times.

Adds tests/unit/source-scanner-guards.test.ts: a hard gate (no baseline, no
allowlist) requiring every test variable bound to project source to carry at least
one positive anchor. Classification runs on logical statements with strings, regexes
and comments blanked out, so a guard wrapped across lines cannot slip past — that
folding is what exposed 3 of the 7 violations.

Fixes all 7 violations across 6 files with one stable top-level export anchor each.
Two were security scope guards held only by multi-line negative assertions: the SSRF
guards on /api/sync/initialize (#323) and the proxy-bypass guards on chatHelpers.ts
and chatCore.ts (#3226) — the latter anchored on handleChatCore precisely because
that file is a decomposition target.

Adds tests/_helpers/readSrc.ts, a repo-root-relative reader that throws on a missing
or empty file instead of returning "".

Refs #8617

* docs(changelog): number the fragment for #8619

* chore(skills): sync cli-backup-sync SKILL.md with catalog

Same tip fix as #8657 so Merge integrity is green without waiting for
that PR to land. Regenerated via generate-agent-skills --apply.
2026-07-27 23:57:30 -03:00

89 lines
3.4 KiB
TypeScript

/**
* Regression guard — CodeQL js/request-forgery alert #323 (v3.8.13).
*
* POST /api/providers fires a non-blocking self-fetch to the connection's
* /sync-models route, forwarding the management cookie + internal sync auth
* headers. #3267 built that self-fetch origin from `new URL(request.url).origin`
* — i.e. the client-controlled Host header — so a caller could redirect the
* credential-bearing internal request to an arbitrary host (SSRF + internal
* auth-header exfiltration).
*
* The origin must come from the trusted loopback/env-pinned base URL
* (`getModelSyncInternalBaseUrl()`), never from the incoming request.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const routeSrc = readFileSync(
join(import.meta.dirname, "../../src/app/api/providers/route.ts"),
"utf8"
);
const syncInitializeRouteSrc = readFileSync(
join(import.meta.dirname, "../../src/app/api/sync/initialize/route.ts"),
"utf8"
);
const syncModelsRouteSrc = readFileSync(
join(import.meta.dirname, "../../src/app/api/providers/[id]/sync-models/route.ts"),
"utf8"
);
const codexProfileSyncSrc = readFileSync(
join(import.meta.dirname, "../../src/lib/cli-helper/codexProfileAutoSync.ts"),
"utf8"
);
const claudeProfileSyncSrc = readFileSync(
join(import.meta.dirname, "../../src/lib/cli-helper/claudeProfileAutoSync.ts"),
"utf8"
);
test("POST /api/providers auto-sync uses the trusted internal origin (not request.url) — #323", () => {
assert.ok(
routeSrc.includes("getModelSyncInternalBaseUrl()"),
"auto-sync self-fetch must derive its origin from getModelSyncInternalBaseUrl()"
);
assert.doesNotMatch(
routeSrc,
/const\s+internalOrigin\s*=\s*new URL\(request\.url\)\.origin/,
"auto-sync origin must NOT be derived from the client-controlled request.url/Host (SSRF, CodeQL js/request-forgery #323)"
);
assert.match(
routeSrc,
/fetchModelSyncInternal\(syncUrl,\s*\{[^}]*redirect:\s*["']error["']/s,
"credential-bearing auto-sync self-fetch must reject redirects"
);
});
test("POST /api/sync/initialize never forwards the client Origin to model sync", () => {
// Anchor: the route handler itself, so the SSRF guards below cannot pass against a
// file that was moved, renamed, or split apart.
assert.match(syncInitializeRouteSrc, /export async function POST\(/);
assert.doesNotMatch(
syncInitializeRouteSrc,
/request\.headers\.get\(["']origin["']\)/,
"client-controlled Origin must not become the credential-bearing model-sync base URL"
);
assert.doesNotMatch(
syncInitializeRouteSrc,
/startModelSyncScheduler\(origin\)/,
"model-sync scheduler must resolve its own trusted loopback origin"
);
});
test("credential-forwarding CLI profile self-fetches reject redirects", () => {
for (const source of [codexProfileSyncSrc, claudeProfileSyncSrc]) {
assert.match(source, /redirect:\s*["']error["']/);
}
});
test("nested model-sync self-fetches use the shared dashboard resolver and reject redirects", () => {
assert.ok(
syncModelsRouteSrc.match(/getModelSyncInternalBaseUrl\(\)/g)?.length >= 2,
"readiness and nested model discovery must share the trusted dashboard resolver"
);
assert.match(syncModelsRouteSrc, /fetchModelSyncInternal\(/);
assert.doesNotMatch(syncModelsRouteSrc, /const\s+(?:incomingUrl|loopbackPort)\s*=/);
assert.match(syncModelsRouteSrc, /redirect:\s*["']error["']/);
});