mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
src/app/api/auth/csrf/route.ts
Normal file
14
src/app/api/auth/csrf/route.ts
Normal 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
101
src/server/authz/csrf.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
1
src/shared/constants/dashboardCsrf.ts
Normal file
1
src/shared/constants/dashboardCsrf.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DASHBOARD_CSRF_HEADER = "x-omniroute-csrf";
|
||||
56
src/shared/utils/dashboardCsrf.ts
Normal file
56
src/shared/utils/dashboardCsrf.ts
Normal 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;
|
||||
}
|
||||
86
tests/unit/authz/csrf.test.ts
Normal file
86
tests/unit/authz/csrf.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user