diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index ca3a4e105f..f27a243e81 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -46,6 +46,7 @@ import { normalizeIntelligentRoutingConfig, } from "@/lib/combos/intelligentRouting"; import { resolveServerErrorMessage } from "@/lib/api/serverErrorMessage"; +import { withDashboardCsrfHeader } from "@/shared/utils/dashboardCsrf"; import { useTranslations } from "next-intl"; const ModelSelectModal = dynamic(() => import("@/shared/components/ModelSelectModal"), { @@ -866,7 +867,7 @@ export default function CombosPage() { try { const res = await fetch("/api/combos/test", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }), body: JSON.stringify({ comboName: combo.name }), }); const data = await res.json(); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx index eb5a42a23a..6dce9fa907 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx @@ -3,6 +3,8 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf"; +import { __resetDashboardCsrfTokenForTests } from "@/shared/utils/dashboardCsrf"; import { useModelVisibilityHandlers, type UseModelVisibilityHandlersReturn, @@ -70,6 +72,7 @@ beforeEach(() => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; + __resetDashboardCsrfTokenForTests(); vi.stubGlobal("fetch", vi.fn()); vi.clearAllMocks(); }); @@ -91,14 +94,23 @@ describe("useModelVisibilityHandlers", () => { it("does not hide a model when a single-model test fails", async () => { const fetchMock = vi.mocked(fetch); - fetchMock.mockResolvedValueOnce({ - ok: false, - json: () => - Promise.resolve({ - status: "error", - error: "model unavailable", - }), - } as Response); + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + token: "csrf-token", + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }), + } as Response) + .mockResolvedValueOnce({ + ok: false, + json: () => + Promise.resolve({ + status: "error", + error: "model unavailable", + }), + } as Response); const hook = renderHook(); @@ -108,11 +120,15 @@ describe("useModelVisibilityHandlers", () => { .onTestModel("claude-opus-4-8", "anthropic-compatible-cc-test/claude-opus-4-8"); }); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith(1, "/api/auth/csrf", expect.any(Object)); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, "/api/models/test", expect.objectContaining({ method: "POST" }) ); + const [, modelTestInit] = fetchMock.mock.calls[1]; + expect((modelTestInit?.headers as Headers).get(DASHBOARD_CSRF_HEADER)).toBe("csrf-token"); expect( fetchMock.mock.calls.some(([url]) => String(url).startsWith("/api/provider-models")) ).toBe(false); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index e3dda0a620..4398a114e4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -19,6 +19,7 @@ import { normalizeModelCatalogSource, } from "@/shared/utils/modelCatalogSearch"; import { useNotificationStore } from "@/store/notificationStore"; +import { withDashboardCsrfHeader } from "@/shared/utils/dashboardCsrf"; import { buildCompatMap, providerText, @@ -158,7 +159,7 @@ export default function PassthroughModelsSection({ >; } = await fetch("/api/models/test-all", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }), // Bug #3610 fix 2: pass autoHideFailed so the server persists the hide body: JSON.stringify( buildPassthroughTestBody({ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts index e9283d1b77..7f8f904e89 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts @@ -31,6 +31,7 @@ import { type CompatByProtocolMap, } from "../providerPageHelpers"; import { useNotificationStore } from "@/store/notificationStore"; +import { withDashboardCsrfHeader } from "@/shared/utils/dashboardCsrf"; type NotifyStore = ReturnType; @@ -290,7 +291,7 @@ export function useModelVisibilityHandlers({ try { const res = await fetch("/api/models/test", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }), body: JSON.stringify({ providerId: selectedConnection?.provider || providerNode?.id || providerId, modelId: fullModel, @@ -356,7 +357,7 @@ export function useModelVisibilityHandlers({ >; } = await fetch("/api/models/test-all", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }), body: JSON.stringify({ providerId: providerId, connectionId: selectedConnection?.id, diff --git a/src/app/api/auth/csrf/route.ts b/src/app/api/auth/csrf/route.ts new file mode 100644 index 0000000000..88fae6c3a2 --- /dev/null +++ b/src/app/api/auth/csrf/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { issueDashboardCsrfToken } from "@/server/authz/csrf"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const issued = issueDashboardCsrfToken(request); + return NextResponse.json(issued ?? { token: null, expiresAt: null }, { + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/src/server/authz/csrf.ts b/src/server/authz/csrf.ts new file mode 100644 index 0000000000..d735c1e17e --- /dev/null +++ b/src/server/authz/csrf.ts @@ -0,0 +1,101 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf"; + +const TOKEN_VERSION = "v1"; +const TOKEN_TTL_SECONDS = 10 * 60; +const TOKEN_CONTEXT = "omniroute-dashboard-csrf-v1"; +const TEST_MUTATION_PATHS = new Set([ + "/api/combos/test", + "/api/models/test", + "/api/models/test-all", +]); + +export interface DashboardCsrfToken { + token: string; + expiresAt: string; +} + +function getJwtSecret(): Buffer | null { + const secret = process.env.JWT_SECRET?.trim(); + return secret ? Buffer.from(secret, "utf8") : null; +} + +function getCookieValue(request: Request, name: string): string | null { + const cookieHeader = request.headers.get("cookie") || request.headers.get("Cookie"); + if (!cookieHeader) return null; + + for (const segment of cookieHeader.split(";")) { + const [rawKey, ...rawValue] = segment.split("="); + if (!rawKey || rawValue.length === 0) continue; + if (rawKey.trim() === name) return rawValue.join("=").trim() || null; + } + + return null; +} + +function sessionHash(authToken: string): string { + return createHash("sha256").update(authToken).digest("base64url"); +} + +function csrfMac(secret: Buffer, expiresAtSeconds: number, authToken: string): Buffer { + return createHmac("sha256", secret) + .update(TOKEN_CONTEXT) + .update("\n") + .update(String(expiresAtSeconds)) + .update("\n") + .update(sessionHash(authToken)) + .digest(); +} + +export function issueDashboardCsrfToken( + request: Request, + nowMs: number = Date.now() +): DashboardCsrfToken | null { + const secret = getJwtSecret(); + const authToken = getCookieValue(request, "auth_token"); + if (!secret || !authToken) return null; + + const expiresAtSeconds = Math.floor(nowMs / 1000) + TOKEN_TTL_SECONDS; + const mac = csrfMac(secret, expiresAtSeconds, authToken).toString("base64url"); + + return { + token: `${TOKEN_VERSION}.${expiresAtSeconds}.${mac}`, + expiresAt: new Date(expiresAtSeconds * 1000).toISOString(), + }; +} + +function requestPathname(request: Request): string | null { + try { + return new URL(request.url).pathname; + } catch { + return null; + } +} + +export function validateDashboardCsrfToken(request: Request, nowMs: number = Date.now()): boolean { + const pathname = requestPathname(request); + if (!pathname || !TEST_MUTATION_PATHS.has(pathname)) return false; + + const secret = getJwtSecret(); + const authToken = getCookieValue(request, "auth_token"); + const rawToken = request.headers.get(DASHBOARD_CSRF_HEADER); + if (!secret || !authToken || !rawToken) return false; + + const [version, rawExpiresAt, rawMac, ...extra] = rawToken.split("."); + if (extra.length > 0 || version !== TOKEN_VERSION || !rawExpiresAt || !rawMac) return false; + + const expiresAtSeconds = Number(rawExpiresAt); + const nowSeconds = Math.floor(nowMs / 1000); + if (!Number.isSafeInteger(expiresAtSeconds) || expiresAtSeconds < nowSeconds) return false; + + let providedMac: Buffer; + try { + providedMac = Buffer.from(rawMac, "base64url"); + } catch { + return false; + } + + const expectedMac = csrfMac(secret, expiresAtSeconds, authToken); + return providedMac.length === expectedMac.length && timingSafeEqual(providedMac, expectedMac); +} diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 72257947ec..d05567fd52 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -7,6 +7,7 @@ import { generateRequestId } from "../../shared/utils/requestId"; import { applyCorsHeaders } from "../cors/origins"; import { validateBrowserMutationOrigin } from "../origin/publicOrigin"; import { classifyRoute } from "./classify"; +import { validateDashboardCsrfToken } from "./csrf"; import { classifyStampedPeerLocality } from "./peerStamp"; import { clientApiPolicy } from "./policies/clientApi"; import { managementPolicy } from "./policies/management"; @@ -329,7 +330,7 @@ export async function runAuthzPipeline( isUnsafeMutationMethod(method) ) { const originVerdict = validateBrowserMutationOrigin(request); - if (!originVerdict.ok) { + if (!originVerdict.ok && !validateDashboardCsrfToken(request)) { const rejection = invalidOriginResponse(requestId); rejection.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); applyCorsHeaders(rejection, request); diff --git a/src/shared/constants/dashboardCsrf.ts b/src/shared/constants/dashboardCsrf.ts new file mode 100644 index 0000000000..f340f4292e --- /dev/null +++ b/src/shared/constants/dashboardCsrf.ts @@ -0,0 +1 @@ +export const DASHBOARD_CSRF_HEADER = "x-omniroute-csrf"; diff --git a/src/shared/utils/dashboardCsrf.ts b/src/shared/utils/dashboardCsrf.ts new file mode 100644 index 0000000000..6b3747bdfe --- /dev/null +++ b/src/shared/utils/dashboardCsrf.ts @@ -0,0 +1,56 @@ +import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf"; + +interface CachedDashboardCsrfToken { + token: string; + expiresAtMs: number; +} + +let cachedToken: CachedDashboardCsrfToken | null = null; + +export function __resetDashboardCsrfTokenForTests(): void { + cachedToken = null; +} + +async function getDashboardCsrfToken(): Promise { + const now = Date.now(); + if (cachedToken && cachedToken.expiresAtMs - now > 30_000) { + return cachedToken.token; + } + + let response: Response; + try { + response = await fetch("/api/auth/csrf", { + cache: "no-store", + credentials: "same-origin", + }); + } catch { + return null; + } + if (!response.ok) return null; + + const body = (await response.json().catch(() => null)) as { + token?: unknown; + expiresAt?: unknown; + } | null; + + if (typeof body?.token !== "string" || typeof body.expiresAt !== "string") { + cachedToken = null; + return null; + } + + const expiresAtMs = Date.parse(body.expiresAt); + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) { + cachedToken = null; + return null; + } + + cachedToken = { token: body.token, expiresAtMs }; + return cachedToken.token; +} + +export async function withDashboardCsrfHeader(headers?: HeadersInit): Promise { + const result = new Headers(headers); + const token = await getDashboardCsrfToken(); + if (token) result.set(DASHBOARD_CSRF_HEADER, token); + return result; +} diff --git a/tests/unit/authz/csrf.test.ts b/tests/unit/authz/csrf.test.ts new file mode 100644 index 0000000000..eff1120e42 --- /dev/null +++ b/tests/unit/authz/csrf.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { describe, it, beforeEach, after } from "node:test"; + +import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf"; +import { issueDashboardCsrfToken, validateDashboardCsrfToken } from "@/server/authz/csrf"; + +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; + +beforeEach(() => { + process.env.JWT_SECRET = "csrf-test-secret"; +}); + +after(() => { + if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; +}); + +function request(path: string, cookie = "auth_token=session-a", token?: string): Request { + return new Request(`http://127.0.0.1:20128${path}`, { + method: "POST", + headers: { + cookie, + ...(token ? { [DASHBOARD_CSRF_HEADER]: token } : {}), + }, + }); +} + +describe("dashboard CSRF tokens", () => { + it("accepts a valid token for dashboard test mutation paths", () => { + const issued = issueDashboardCsrfToken(request("/api/auth/csrf"), 1_000); + + assert.ok(issued); + assert.equal( + validateDashboardCsrfToken(request("/api/models/test", undefined, issued.token), 1_000), + true + ); + assert.equal( + validateDashboardCsrfToken(request("/api/models/test-all", undefined, issued.token), 1_000), + true + ); + assert.equal( + validateDashboardCsrfToken(request("/api/combos/test", undefined, issued.token), 1_000), + true + ); + }); + + it("rejects tokens on non-test management paths", () => { + const issued = issueDashboardCsrfToken(request("/api/auth/csrf"), 1_000); + + assert.ok(issued); + assert.equal( + validateDashboardCsrfToken(request("/api/keys", undefined, issued.token), 1_000), + false + ); + }); + + it("binds tokens to the dashboard auth cookie", () => { + const issued = issueDashboardCsrfToken( + request("/api/auth/csrf", "auth_token=session-a"), + 1_000 + ); + + assert.ok(issued); + assert.equal( + validateDashboardCsrfToken( + request("/api/models/test", "auth_token=session-b", issued.token), + 1_000 + ), + false + ); + }); + + it("rejects expired and tampered tokens", () => { + const issued = issueDashboardCsrfToken(request("/api/auth/csrf"), 1_000); + + assert.ok(issued); + assert.equal( + validateDashboardCsrfToken(request("/api/models/test", undefined, issued.token), 700_000), + false + ); + assert.equal( + validateDashboardCsrfToken(request("/api/models/test", undefined, `${issued.token}x`), 1_000), + false + ); + }); +}); diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index ab740146da..f2c0dbae5e 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -14,6 +14,8 @@ const core = await import("../../../src/lib/db/core.ts"); const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); const settingsDb = await import("../../../src/lib/db/settings.ts"); const pipeline = await import("../../../src/server/authz/pipeline.ts"); +const csrf = await import("../../../src/server/authz/csrf.ts"); +const dashboardCsrfConstants = await import("../../../src/shared/constants/dashboardCsrf.ts"); const ORIGINAL_JWT = process.env.JWT_SECRET; const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD; @@ -323,6 +325,94 @@ test("runAuthzPipeline accepts dashboard mutations from configured public origin assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); }); +test("runAuthzPipeline rejects dashboard test mutations from dynamic public origins without CSRF", async () => { + await forceAuthRequired(); + + const response = await pipeline.runAuthzPipeline( + request("http://127.0.0.1:20128/api/models/test", { + method: "POST", + headers: { + cookie: await dashboardCookie(), + host: "127.0.0.1:20128", + origin: "https://random-tunnel.example.test", + "content-type": "application/json", + "sec-fetch-site": "same-origin", + }, + body: "{}", + }), + { enforce: true } + ); + const body = await response.json(); + + assert.equal(response.status, 403); + assert.equal(body.error.code, "INVALID_ORIGIN"); +}); + +test("runAuthzPipeline accepts dashboard test mutations from dynamic public origins with CSRF", async () => { + await forceAuthRequired(); + + const cookie = await dashboardCookie(); + const issued = csrf.issueDashboardCsrfToken( + request("http://127.0.0.1:20128/api/auth/csrf", { + headers: { cookie }, + }) + ); + assert.ok(issued); + + for (const path of ["/api/combos/test", "/api/models/test", "/api/models/test-all"]) { + const response = await pipeline.runAuthzPipeline( + request(`http://127.0.0.1:20128${path}`, { + method: "POST", + headers: { + cookie, + host: "127.0.0.1:20128", + origin: "https://random-tunnel.example.test", + "content-type": "application/json", + [dashboardCsrfConstants.DASHBOARD_CSRF_HEADER]: issued.token, + "sec-fetch-site": "same-origin", + }, + body: "{}", + }), + { enforce: true } + ); + + assert.equal(response.status, 200, path); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); + } +}); + +test("runAuthzPipeline keeps non-test management mutations pinned to known origins with CSRF", async () => { + await forceAuthRequired(); + + const cookie = await dashboardCookie(); + const issued = csrf.issueDashboardCsrfToken( + request("http://127.0.0.1:20128/api/auth/csrf", { + headers: { cookie }, + }) + ); + assert.ok(issued); + + const response = await pipeline.runAuthzPipeline( + request("http://127.0.0.1:20128/api/keys", { + method: "POST", + headers: { + cookie, + host: "127.0.0.1:20128", + origin: "https://random-tunnel.example.test", + "content-type": "application/json", + [dashboardCsrfConstants.DASHBOARD_CSRF_HEADER]: issued.token, + "sec-fetch-site": "same-origin", + }, + body: "{}", + }), + { enforce: true } + ); + const body = await response.json(); + + assert.equal(response.status, 403); + assert.equal(body.error.code, "INVALID_ORIGIN"); +}); + test("runAuthzPipeline rejects dashboard mutations from invalid browser origin", async () => { await forceAuthRequired(); process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test";