fix(security): block cloud-metadata SSRF pivot in cli-tools catalog fetch (CodeQL #326 critical) (#3544)

assertSafeCatalogUrl() blocks the cloud-metadata/link-local SSRF→IAM pivot + non-http(s) + embedded creds before the user-controlled baseUrl reaches fetch; loopback (the legitimate OmniRoute target) and public OmniRoute Cloud stay allowed. Fetches the re-parsed (taint-severed) URL. TDD: 4 guard cases. CodeQL FP (custom-guard limitation) dismissed per Rule #14. Folded into v3.8.19.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-10 02:20:21 -03:00
committed by GitHub
parent 6a7a36c09e
commit 76d807fcd9
3 changed files with 77 additions and 1 deletions

View File

@@ -18,6 +18,10 @@
- **fix(authz):** restored the missing `BYPASS_PREFIX_NOT_ALLOWED` schema guard (Hard Rules #15/#17) — the zod refine documented as layer-1 in `routeGuard.ts` was absent from the live `settingsSchemas.ts`, so `PATCH /api/settings` accepted spawn-capable prefixes (e.g. `/api/cli-tools/runtime/`) into the manage-scope bypass list (the layer-2 runtime predicate still refused to honour them). Surfaced by re-wired orphan tests AC-8/AC-10c, which now stand as the permanent regression guard. ([#3536](https://github.com/diegosouzapw/OmniRoute/pull/3536) — thanks @diegosouzapw)
- **fix(db):** `closeDbInstance()`/`resetDbInstance()` now fire the `stateReset.ts` module-state resetters (previously only backup-restore did) — `apiKeys.ts` kept a process-level schema memo across a recreated DB, so the stale re-prepare exploded with `no such column: is_active` and clients received **503 instead of 403** for an invalid bearer; the same path hit production when restoring an older backup snapshot. Includes a dedicated regression test; a test that had accommodated the buggy 503 now asserts the deterministic 403. ([#3536](https://github.com/diegosouzapw/OmniRoute/pull/3536) — thanks @diegosouzapw)
### 🔒 Security
- **fix(security):** block the cloud-metadata SSRF pivot in the cli-tools catalog fetch (CodeQL `js/request-forgery`, **critical**) — `fetchOmniRouteCatalog()` built its `/v1/models` URL from a user-controlled `baseUrl` and fetched it. Since the legitimate target is the user's own OmniRoute (loopback), the public-only guard can't apply; `assertSafeCatalogUrl()` now blocks the cloud-metadata/link-local pivot (`169.254.169.254`, `metadata.google.internal`, …) unconditionally, plus non-http(s) protocols and embedded credentials, and the request fetches the re-parsed (taint-severed) URL. Loopback and public OmniRoute Cloud targets stay allowed. ([#3544](https://github.com/diegosouzapw/OmniRoute/pull/3544) — thanks @diegosouzapw)
### 📝 Maintenance
- **docs(quality):** Phase 6A critical-audit plan + Phase 7 community-tooling additions, both stored with an activation gate of **2026-06-16** — 6A: stale-allowlist enforcement, ratchet `--require-tighten`, gate scope expansions, remaining orphan/UI-suite triage; Phase 7 additions: gitleaks (Betterleaks noted), actionlint + zizmor, SPDX license compliance. ([#3530](https://github.com/diegosouzapw/OmniRoute/pull/3530) — thanks @diegosouzapw)

View File

@@ -1,9 +1,36 @@
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import {
parseOutboundUrl,
isCloudMetadataHost,
OutboundUrlGuardError,
} from "@/shared/network/outboundUrlGuard";
const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json");
/**
* SSRF guard for the catalog fetch (CodeQL js/request-forgery #326). The catalog
* source is the user's OWN OmniRoute instance, so loopback/private hosts are the
* legitimate default and must stay allowed — we cannot use the public-only guard
* here. What has NO legitimate use as a catalog source is the cloud-metadata /
* link-local pivot (169.254.169.254, metadata.google.internal, …): that is the
* classic SSRF→IAM-credential escalation and is blocked unconditionally, along
* with non-http(s) protocols and embedded credentials (via parseOutboundUrl).
*/
export function assertSafeCatalogUrl(rawUrl: string): URL {
const url = parseOutboundUrl(rawUrl); // throws on bad protocol / embedded creds
if (isCloudMetadataHost(url.hostname)) {
throw new OutboundUrlGuardError(
"Blocked cloud-metadata catalog URL (SSRF protection)",
{ code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname }
);
}
// Return the re-parsed URL so callers fetch the validated value (a `new URL()`
// round-trip is a recognized request-forgery barrier — clears CodeQL #326).
return url;
}
/**
* OpenAI-compatible model entry — subset of fields the /v1/models endpoint
* returns. Only the fields we need to emit `limit.context` / `limit.output`
@@ -87,10 +114,15 @@ export async function fetchOmniRouteCatalog(
total: 0,
};
// SSRF guard (CodeQL #326): baseUrl is user-controlled — block the cloud-metadata
// pivot before issuing the request. Loopback stays allowed. Fetch the VALIDATED,
// re-parsed URL the guard returns (not the raw string) so the taint is severed.
const safeUrl = assertSafeCatalogUrl(`${baseURL}/models`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${baseURL}/models`, {
const response = await fetch(safeUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});

View File

@@ -20,6 +20,46 @@ describe("config-generator", () => {
});
});
describe("assertSafeCatalogUrl (SSRF guard, CodeQL #326)", () => {
it("allows the loopback OmniRoute target (the legitimate default) and returns a URL", async () => {
const { assertSafeCatalogUrl } = await import(
"../../../src/lib/cli-helper/config-generator/opencode.ts"
);
// The catalog source IS the user's own OmniRoute — localhost must stay allowed.
assert.doesNotThrow(() => assertSafeCatalogUrl("http://localhost:20128/v1/models"));
assert.doesNotThrow(() => assertSafeCatalogUrl("http://127.0.0.1:20128/v1/models"));
// Returns the validated, re-parsed URL (taint-severed value the caller fetches).
const safe = assertSafeCatalogUrl("http://localhost:20128/v1/models");
assert.ok(safe instanceof URL);
assert.equal(safe.href, "http://localhost:20128/v1/models");
});
it("allows a public OmniRoute Cloud target", async () => {
const { assertSafeCatalogUrl } = await import(
"../../../src/lib/cli-helper/config-generator/opencode.ts"
);
assert.doesNotThrow(() => assertSafeCatalogUrl("https://api.omniroute.online/v1/models"));
});
it("blocks the cloud-metadata SSRF→IAM pivot (169.254.169.254)", async () => {
const { assertSafeCatalogUrl } = await import(
"../../../src/lib/cli-helper/config-generator/opencode.ts"
);
assert.throws(() => assertSafeCatalogUrl("http://169.254.169.254/v1/models"));
assert.throws(() =>
assertSafeCatalogUrl("http://metadata.google.internal/v1/models")
);
});
it("blocks non-http(s) protocols and embedded credentials", async () => {
const { assertSafeCatalogUrl } = await import(
"../../../src/lib/cli-helper/config-generator/opencode.ts"
);
assert.throws(() => assertSafeCatalogUrl("file:///etc/passwd"));
assert.throws(() => assertSafeCatalogUrl("http://user:pass@example.com/v1/models"));
});
});
describe("generateConfig", () => {
it("returns error for invalid baseUrl", async () => {
const result = await generator.generateConfig("claude", {