diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index 877b3dbde3..e732508444 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -32,7 +32,10 @@ import { import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { isManagedProviderConnectionId } from "@/lib/providers/catalog"; import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure"; -import { buildModelSyncInternalHeaders } from "@/shared/services/modelSyncScheduler"; +import { + buildModelSyncInternalHeaders, + getModelSyncInternalBaseUrl, +} from "@/shared/services/modelSyncScheduler"; // GET /api/providers - List all connections export async function GET(request: Request) { @@ -174,7 +177,12 @@ export async function POST(request: Request) { // auth header (defense in depth) and an X-Internal-Auto-Sync marker for // log correlation. try { - const internalOrigin = new URL(request.url).origin; + // SECURITY: use the trusted loopback/env-pinned origin, NOT + // `new URL(request.url).origin` — the latter comes from the client- + // controlled Host header, which would let a caller redirect this + // credential-bearing internal self-fetch to an arbitrary host + // (SSRF + internal-auth-header exfiltration; CodeQL js/request-forgery). + const internalOrigin = getModelSyncInternalBaseUrl(); const cookieHeader = request.headers.get("cookie") || ""; const syncHeaders: Record = { "Content-Type": "application/json", diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index fe9478cdb6..553a6783e1 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -24,6 +24,19 @@ const INTERNAL_BASE_URL = process.env.NEXT_PUBLIC_APP_URL || `http://127.0.0.1:${dashboardPort}`; +/** + * Trusted origin for server-internal self-fetches (model sync, auto-discovery). + * + * SECURITY: never derive this from the incoming request (`request.url` / + * `Host` header) — that is client-controlled and lets a caller redirect an + * internal, credential-bearing self-fetch to an arbitrary host (SSRF + + * internal-auth-header exfiltration; CodeQL js/request-forgery). Always use + * this loopback/env-pinned origin instead. + */ +export function getModelSyncInternalBaseUrl(): string { + return INTERNAL_BASE_URL; +} + const globalState = globalThis as typeof globalThis & { __omnirouteModelSyncInternalAuthToken?: string; }; diff --git a/tests/unit/providers-autosync-ssrf-323.test.ts b/tests/unit/providers-autosync-ssrf-323.test.ts new file mode 100644 index 0000000000..1df4c4dcf2 --- /dev/null +++ b/tests/unit/providers-autosync-ssrf-323.test.ts @@ -0,0 +1,35 @@ +/** + * 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" +); + +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)" + ); +});