mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
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:
committed by
GitHub
parent
6a7a36c09e
commit
76d807fcd9
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user