diff --git a/CHANGELOG.md b/CHANGELOG.md index 52fe21b1e9..322ae8e907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index d817e009fb..265246e6d5 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -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, }); diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index 216912bb6b..36d1a2b785 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -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", {