fix: protect dynamic dashboard tests with CSRF (#5405)

Integrated into release/v3.8.42 (round 3). Reworked CSRF (HMAC-signed synchronized token).
This commit is contained in:
Randi
2026-06-30 01:01:36 -04:00
committed by GitHub
parent 2ce25e7da8
commit 285b13e12a
11 changed files with 383 additions and 15 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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({

View File

@@ -31,6 +31,7 @@ import {
type CompatByProtocolMap,
} from "../providerPageHelpers";
import { useNotificationStore } from "@/store/notificationStore";
import { withDashboardCsrfHeader } from "@/shared/utils/dashboardCsrf";
type NotifyStore = ReturnType<typeof useNotificationStore>;
@@ -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,

View File

@@ -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" },
});
}

101
src/server/authz/csrf.ts Normal file
View File

@@ -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);
}

View File

@@ -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);

View File

@@ -0,0 +1 @@
export const DASHBOARD_CSRF_HEADER = "x-omniroute-csrf";

View File

@@ -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<string | null> {
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<Headers> {
const result = new Headers(headers);
const token = await getDashboardCsrfToken();
if (token) result.set(DASHBOARD_CSRF_HEADER, token);
return result;
}

View File

@@ -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
);
});
});

View File

@@ -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";