fix: unify dashboard csrf origin fallback (#5856)

Integrated into release/v3.8.43
This commit is contained in:
Randi
2026-07-01 21:00:43 -04:00
committed by GitHub
parent 7d7626151b
commit 103a0ee2a2
18 changed files with 429 additions and 185 deletions

View File

@@ -261,8 +261,8 @@ ALLOW_API_KEY_REVEAL=false
# CORS configuration — controls which cross-origin browser clients can call the API.
# Used by: src/server/cors/origins.ts — sets Access-Control-Allow-Origin.
# Same-origin dashboard requests behind a reverse proxy do not need CORS; set
# NEXT_PUBLIC_BASE_URL instead. No wildcard is sent unless CORS_ALLOW_ALL=true.
# Same-origin dashboard requests behind a reverse proxy do not need CORS; they
# use session-bound CSRF protection. No wildcard is sent unless CORS_ALLOW_ALL=true.
# CORS_ALLOWED_ORIGINS=https://your-frontend.example.com
# CORS_ORIGIN=https://your-frontend.example.com # legacy single-origin alias
# CORS_ALLOW_ALL=false
@@ -369,23 +369,24 @@ CLOUD_URL=
# Default: 12000 (12 seconds)
# CLOUD_SYNC_TIMEOUT_MS=12000
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
# Used by: OAuth redirect_uri computation, Dashboard UI links, generated public
# URLs, and same-origin browser mutation checks.
# Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com).
# Public-facing base URL — required for stable reverse proxy / OAuth callback setups.
# Used by: OAuth redirect_uri computation, Dashboard UI links, and generated public URLs.
# Set to your stable public URL when OAuth callbacks or generated browser links need a
# canonical host behind nginx/Caddy (e.g., https://omniroute.example.com).
#
# Dashboard display behavior: when this variable is unset, the dashboard
# auto-detects the base URL shown in curl examples and CLI tool snippets
# from window.location.origin (the host the user is browsing). Setting it
# explicitly is only required when running behind a reverse proxy with a
# different public hostname, or when OAuth callbacks must point to a
# canonical URL.
# different public hostname, or when OAuth callbacks / generated browser links must point
# to a canonical URL. Authenticated dashboard writes use same-origin requests plus
# session-bound CSRF protection and do not require a static public base URL.
#
# Default: http://localhost:20128
NEXT_PUBLIC_BASE_URL=http://localhost:20128
# Browser-facing OmniRoute origin for generated assets in API responses.
# Highest-priority public origin override; also used by public-origin validation.
# Highest-priority public origin override; also used by non-dashboard public-origin validation.
# Used by: chatgpt-web image generation cache URLs (/v1/chatgpt-web/image/<id>).
# Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL
# but the user's browser must fetch images from a LAN, tunnel, or public origin.

View File

@@ -190,7 +190,7 @@ services:
- omniroute-data:/app/data
environment:
- PORT=20128
# Browser-facing origin for OAuth callbacks, dashboard links, and same-origin checks.
# Browser-facing origin for OAuth callbacks, dashboard links, and generated public URLs.
- NEXT_PUBLIC_BASE_URL=https://your-domain.com
# Internal server-to-server URL for scheduled jobs / self-fetches.
- BASE_URL=http://omniroute:20128
@@ -210,9 +210,11 @@ volumes:
```
Caddy sets the standard forwarding headers for the upstream container. OmniRoute uses
`NEXT_PUBLIC_BASE_URL` as the canonical public origin; only enable `OMNIROUTE_TRUST_PROXY` for
advanced deployments where you intentionally want OmniRoute to derive the public origin from
trusted forwarded headers instead of explicit configuration.
`NEXT_PUBLIC_BASE_URL` as the canonical public origin for OAuth callbacks and generated public
links; authenticated dashboard writes use same-origin requests plus session-bound CSRF
protection. Only enable `OMNIROUTE_TRUST_PROXY` for advanced deployments where you intentionally
want OmniRoute to derive the public origin from trusted forwarded headers instead of explicit
configuration.
## Cloudflare Quick Tunnel

View File

@@ -218,6 +218,11 @@ build callback URLs against the **public** hostname, not `localhost`. Otherwise
the OAuth provider redirects the user back to a URL its servers cannot reach,
and the handshake fails.
Dashboard edits and settings saves do not require pinning the tunnel hostname in
`NEXT_PUBLIC_BASE_URL`. The authenticated dashboard sends same-origin unsafe
requests with a session-bound CSRF token, so ephemeral Cloudflare Quick Tunnel
hosts can still be used for normal UI management after logging in.
Set:
```bash

View File

@@ -119,7 +119,7 @@ REQUIRE_API_KEY=false
# === URLs (change to your domain) ===
# Internal server-to-server base URL for scheduled jobs / self-fetches.
BASE_URL=http://127.0.0.1:20128
# Browser-facing URL used for OAuth callbacks, dashboard links, and same-origin checks.
# Browser-facing URL used for OAuth callbacks, dashboard links, and generated public URLs.
NEXT_PUBLIC_BASE_URL=https://llms.seudominio.com
# Optional explicit public origin override for generated public asset URLs.
# OMNIROUTE_PUBLIC_BASE_URL=https://llms.seudominio.com
@@ -243,11 +243,13 @@ Keep reverse-proxy stream timeouts aligned with your OmniRoute timeout env vars.
`FETCH_TIMEOUT_MS` / `STREAM_IDLE_TIMEOUT_MS`, raise `proxy_read_timeout` / `proxy_send_timeout`
above the same threshold.
OmniRoute uses `NEXT_PUBLIC_BASE_URL` as the canonical browser-facing origin for OAuth,
public links, and dashboard mutation origin checks. The `X-Forwarded-*` headers above are
still useful routing metadata, but they are not a replacement for setting the explicit public
URL. Only enable `OMNIROUTE_TRUST_PROXY` if OmniRoute is not directly reachable by clients and
your proxy strips/rebuilds incoming forwarded headers.
OmniRoute uses `NEXT_PUBLIC_BASE_URL` as the canonical browser-facing origin for OAuth
callbacks and generated public links. Authenticated dashboard writes use same-origin requests
plus session-bound CSRF protection, so they do not require a static public base URL. The
`X-Forwarded-*` headers above are still useful routing metadata, but they are not a replacement
for setting the explicit public URL when OAuth or generated browser links need one. Only enable
`OMNIROUTE_TRUST_PROXY` if OmniRoute is not directly reachable by clients and your proxy
strips/rebuilds incoming forwarded headers.
### 3.3 Enable and Test

View File

@@ -180,7 +180,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap. Bodies larger than this are rejected with `413` before being cloned/parsed, regardless of heap state. |
| `OMNIROUTE_CHAT_HEAP_SHED_RATIO` | `0.75` | `src/shared/middleware/chatBodyAdmission.ts` | Shed a large chat body with `503` + `Retry-After` once `heapUsed / heap_size_limit` reaches this ratio (`0 < r < 1`). Turns a process-wide V8 OOM under concurrent large compacts into a single graceful client retry; a healthy heap admits every body untouched. |
| `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. |
| `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; same-origin dashboard requests behind a reverse proxy use `NEXT_PUBLIC_BASE_URL` / public-origin validation instead. |
| `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. |
| `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. |
| `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. |
| `OUTBOUND_SSRF_GUARD_ENABLED` | `true` | `src/shared/network/outboundUrlGuard.ts` | Block provider calls targeting private/loopback/link-local IP ranges. Disable only in isolated test envs. |
@@ -258,10 +258,10 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_CLOUD_SYNC_SECRET` | _(empty)_ | `src/lib/cloudSync.ts` | Shared secret used to verify the HMAC-SHA256 signature of Cloud Sync responses. |
| `OMNIROUTE_CLOUD_SYNC_SECRETS` | `false` | `src/lib/cloudSync.ts` | Set to `true` to allow the Cloud Sync endpoint to overwrite local credentials. Default is `false`. |
| `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP` | `false` | `src/app/api/providers/zed/import/route.ts` | Set to `true` to fall back to the v3.8.5 one-step "import everything" behavior without user confirmation. |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | Public-facing URL for OAuth redirect_uri, Dashboard links, generated public URLs, and same-origin browser mutation checks. **Must match your public URL behind reverse proxy.** |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:20128` | OAuth, Dashboard, sync | Public-facing URL for OAuth redirect_uri, Dashboard links, and generated public URLs. Set this to the stable public URL when OAuth callbacks or generated browser links must use a canonical reverse-proxy host. |
| `NEXT_PUBLIC_CLOUD_URL` | _(empty)_ | Client-side | Client-side mirror of `CLOUD_URL`. |
| `NEXT_PUBLIC_APP_URL` | _(unset)_ | `src/shared/services/cloudSyncScheduler.ts` | Legacy fallback for `NEXT_PUBLIC_BASE_URL`. |
| `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | Public-origin resolver, image URLs | Highest-priority browser-facing OmniRoute origin used for public URL generation and origin validation (for example `/v1/chatgpt-web/image/<id>`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. |
| `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | Public-origin resolver, image URLs | Highest-priority browser-facing OmniRoute origin used for public URL generation and non-dashboard browser-origin validation (for example `/v1/chatgpt-web/image/<id>`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. |
| `OMNIROUTE_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. |
| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000` (3 min) | `open-sse/executors/chatgpt-web.ts` | Max wait time for an async chatgpt-web image to land via the celsius WebSocket. Increase during upstream queue-deep windows. |
| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | Total in-memory byte budget (MB) for the chatgpt-web image cache serving `/v1/chatgpt-web/image/<id>`. Lower on memory-constrained hosts; raise if image generation is heavy and clients race the 30-minute TTL. |
@@ -286,11 +286,11 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) usage limits endpoint. Override for relays / test fixtures. |
> [!IMPORTANT]
> When deploying behind a reverse proxy (nginx, Caddy), `NEXT_PUBLIC_BASE_URL` **must** be set to your public URL (e.g., `https://omniroute.example.com`). Without this, OAuth callbacks can fail because the redirect_uri won't match, generated public links can point at the internal container origin, and same-origin dashboard mutations can be rejected by browser-origin checks.
> When deploying behind a reverse proxy (nginx, Caddy), set `NEXT_PUBLIC_BASE_URL` to your stable public URL (e.g., `https://omniroute.example.com`) when OAuth callbacks or generated public links must use that hostname. Without this, OAuth callbacks can fail because the redirect_uri won't match and generated public links can point at the internal container origin.
>
> Keep `BASE_URL` as an internal loopback/container URL for server-to-server jobs. Do not use a browser `Origin` or public hostname for credential-bearing internal self-fetches.
>
> OmniRoute centralizes public-origin validation: explicit public URL env vars are trusted first; raw `Forwarded` / `X-Forwarded-*` headers are ignored unless `OMNIROUTE_TRUST_PROXY` is enabled and the immediate proxy peer is token-stamped as trusted. Do not use CORS settings to fix same-origin dashboard requests; CORS is only for cross-origin browser clients.
> Authenticated dashboard writes do not require a static public base URL: the dashboard sends same-origin unsafe requests with a session-bound CSRF token. OmniRoute still centralizes public-origin validation for non-dashboard browser integrations: explicit public URL env vars are trusted first; raw `Forwarded` / `X-Forwarded-*` headers are ignored unless `OMNIROUTE_TRUST_PROXY` is enabled and the immediate proxy peer is token-stamped as trusted. Do not use CORS settings to fix same-origin dashboard requests; CORS is only for cross-origin browser clients.
---

View File

@@ -47,7 +47,6 @@ 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"), {
@@ -868,7 +867,7 @@ export default function CombosPage() {
try {
const res = await fetch("/api/combos/test", {
method: "POST",
headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboName: combo.name }),
});
const data = await res.json();

View File

@@ -3,8 +3,6 @@ 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,
@@ -72,7 +70,6 @@ beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
__resetDashboardCsrfTokenForTests();
vi.stubGlobal("fetch", vi.fn());
vi.clearAllMocks();
});
@@ -94,23 +91,14 @@ describe("useModelVisibilityHandlers", () => {
it("does not hide a model when a single-model test fails", async () => {
const fetchMock = vi.mocked(fetch);
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);
fetchMock.mockResolvedValueOnce({
ok: false,
json: () =>
Promise.resolve({
status: "error",
error: "model unavailable",
}),
} as Response);
const hook = renderHook();
@@ -120,15 +108,12 @@ describe("useModelVisibilityHandlers", () => {
.onTestModel("claude-opus-4-8", "anthropic-compatible-cc-test/claude-opus-4-8");
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(1, "/api/auth/csrf", expect.any(Object));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
1,
"/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,7 +19,6 @@ import {
normalizeModelCatalogSource,
} from "@/shared/utils/modelCatalogSearch";
import { useNotificationStore } from "@/store/notificationStore";
import { withDashboardCsrfHeader } from "@/shared/utils/dashboardCsrf";
import {
buildCompatMap,
providerText,
@@ -159,7 +158,7 @@ export default function PassthroughModelsSection({
>;
} = await fetch("/api/models/test-all", {
method: "POST",
headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }),
headers: { "Content-Type": "application/json" },
// Bug #3610 fix 2: pass autoHideFailed so the server persists the hide
body: JSON.stringify(
buildPassthroughTestBody({

View File

@@ -31,7 +31,6 @@ import {
type CompatByProtocolMap,
} from "../providerPageHelpers";
import { useNotificationStore } from "@/store/notificationStore";
import { withDashboardCsrfHeader } from "@/shared/utils/dashboardCsrf";
type NotifyStore = ReturnType<typeof useNotificationStore>;
@@ -291,7 +290,7 @@ export function useModelVisibilityHandlers({
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: selectedConnection?.provider || providerNode?.id || providerId,
modelId: fullModel,
@@ -357,7 +356,7 @@ export function useModelVisibilityHandlers({
>;
} = await fetch("/api/models/test-all", {
method: "POST",
headers: await withDashboardCsrfHeader({ "Content-Type": "application/json" }),
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: providerId,
connectionId: selectedConnection?.id,

View File

@@ -9,10 +9,10 @@ interface UseSystemProxyExitGuardOpts {
/**
* On unmount / page hide / beforeunload, if system proxy is applied,
* silently fires a revert request via navigator.sendBeacon (best-effort,
* survives unload) AND attaches a beforeunload listener that prompts the
* user with a native confirm dialog (browser default — text is ignored
* by most browsers but the prompt itself appears).
* silently fires a keepalive fetch revert request (best-effort, survives
* unload) AND attaches a beforeunload listener that prompts the user with a
* native confirm dialog (browser default — text is ignored by most browsers
* but the prompt itself appears).
*/
export function useSystemProxyExitGuard(opts: UseSystemProxyExitGuardOpts): void {
// 1. Track latest 'applied' in a ref so the listener always sees fresh value
@@ -22,19 +22,21 @@ export function useSystemProxyExitGuard(opts: UseSystemProxyExitGuardOpts): void
}, [opts.applied]);
useEffect(() => {
const endpoint =
opts.endpoint ?? "/api/tools/traffic-inspector/capture-modes/system-proxy";
const endpoint = opts.endpoint ?? "/api/tools/traffic-inspector/capture-modes/system-proxy";
const body = JSON.stringify({ action: "revert" });
const blob = new Blob([body], { type: "application/json" });
const revertSystemProxy = () => {
void fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
};
const beforeUnload = (e: BeforeUnloadEvent) => {
if (!appliedRef.current) return;
// Best-effort revert via sendBeacon (survives navigation)
try {
navigator.sendBeacon(endpoint, blob);
} catch {
/* ignore */
}
revertSystemProxy();
// Show confirmation prompt
e.preventDefault();
e.returnValue = "System-wide proxy still active — leave page anyway?";
@@ -46,11 +48,7 @@ export function useSystemProxyExitGuard(opts: UseSystemProxyExitGuardOpts): void
window.removeEventListener("beforeunload", beforeUnload);
// On component unmount (SPA navigation), fire revert too
if (appliedRef.current) {
try {
navigator.sendBeacon(endpoint, blob);
} catch {
/* ignore */
}
revertSystemProxy();
}
};
}, [opts.endpoint]);

View File

@@ -5,11 +5,6 @@ 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;
@@ -65,18 +60,7 @@ export function issueDashboardCsrfToken(
};
}
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);

View File

@@ -176,8 +176,8 @@ function invalidOriginResponse(requestId: string): NextResponse {
error: {
code: "INVALID_ORIGIN",
message:
"Invalid request origin. If you reach the dashboard over a custom host or an HTTPS proxy, " +
"set OMNIROUTE_PUBLIC_BASE_URL to that exact URL and restart. Direct loopback/LAN-IP access works without it.",
"Invalid request origin. Same-origin dashboard writes must include a valid dashboard CSRF token. " +
"Refresh the dashboard and retry, or set OMNIROUTE_PUBLIC_BASE_URL for non-dashboard browser integrations.",
correlation_id: requestId,
},
},
@@ -242,8 +242,7 @@ export async function runAuthzPipeline(
// stay exactly fail-closed.
const corsRelaxOrigin =
classification.routeClass === "CLIENT_API" ||
(classification.routeClass === "PUBLIC" &&
classification.reason === "public_readonly_prefix");
(classification.routeClass === "PUBLIC" && classification.reason === "public_readonly_prefix");
if (guardedPathname.startsWith("/api/") && isDraining()) {
const response = drainingResponse(requestId);
@@ -330,7 +329,9 @@ export async function runAuthzPipeline(
isUnsafeMutationMethod(method)
) {
const originVerdict = validateBrowserMutationOrigin(request);
if (!originVerdict.ok && !validateDashboardCsrfToken(request)) {
const csrfOriginFallback =
originVerdict.reason === "invalid-origin" && validateDashboardCsrfToken(request);
if (!originVerdict.ok && !csrfOriginFallback) {
const rejection = invalidOriginResponse(requestId);
rejection.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass);
applyCorsHeaders(rejection, request);

View File

@@ -1,6 +1,6 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useInsertionEffect, useState } from "react";
import Sidebar from "../Sidebar";
import Header from "../Header";
import NotificationToast from "../NotificationToast";
@@ -9,6 +9,10 @@ import MaintenanceBanner from "../MaintenanceBanner";
import CommandPalette from "../CommandPalette";
import NavigationProgress from "../NavigationProgress";
import { useIsElectron } from "@/shared/hooks/useElectron";
import {
installDashboardCsrfFetch,
prefetchDashboardCsrfToken,
} from "@/shared/utils/dashboardCsrf";
const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
@@ -41,6 +45,12 @@ export default function DashboardLayout({ children }) {
};
}, [isMacElectron]);
useInsertionEffect(() => {
const uninstallDashboardCsrfFetch = installDashboardCsrfFetch();
void prefetchDashboardCsrfToken();
return uninstallDashboardCsrfFetch;
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
@@ -107,9 +117,7 @@ export default function DashboardLayout({ children }) {
1280px cap that left big empty margins on wide screens. */}
<div className="max-w-[3840px] mx-auto w-full h-full min-h-0 flex flex-col">
<Breadcrumbs />
<div className="flex-1 min-h-0">
{children}
</div>
<div className="flex-1 min-h-0">{children}</div>
</div>
</div>
</main>

View File

@@ -1,4 +1,5 @@
import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf";
import { PUBLIC_API_ROUTE_PREFIXES } from "@/shared/constants/publicApiRoutes";
interface CachedDashboardCsrfToken {
token: string;
@@ -6,46 +7,76 @@ interface CachedDashboardCsrfToken {
}
let cachedToken: CachedDashboardCsrfToken | null = null;
let pendingToken: Promise<string | null> | null = null;
let originalFetch: typeof fetch | null = null;
let installCount = 0;
const CLIENT_API_ALIAS_PREFIXES = ["/chat/completions", "/responses", "/models", "/codex"];
const TOP_LEVEL_MANAGEMENT_PATH_PREFIXES = ["/a2a"];
export function __resetDashboardCsrfTokenForTests(): void {
cachedToken = null;
pendingToken = null;
if (originalFetch) {
globalThis.fetch = originalFetch;
originalFetch = null;
}
installCount = 0;
}
async function getDashboardCsrfToken(): Promise<string | null> {
const now = Date.now();
function currentDashboardCsrfToken(now: number = Date.now()): string | null {
if (cachedToken && cachedToken.expiresAtMs - now > 30_000) {
return cachedToken.token;
}
return null;
}
let response: Response;
async function fetchDashboardCsrfToken(now: number): Promise<string | null> {
try {
response = await fetch("/api/auth/csrf", {
const response = await fetch("/api/auth/csrf", {
cache: "no-store",
credentials: "same-origin",
});
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;
} catch {
return null;
}
if (!response.ok) return null;
}
const body = (await response.json().catch(() => null)) as {
token?: unknown;
expiresAt?: unknown;
} | null;
async function getDashboardCsrfToken(): Promise<string | null> {
const cached = currentDashboardCsrfToken();
if (cached) return cached;
if (typeof body?.token !== "string" || typeof body.expiresAt !== "string") {
cachedToken = null;
return null;
if (!pendingToken) {
pendingToken = fetchDashboardCsrfToken(Date.now()).finally(() => {
pendingToken = null;
});
}
const expiresAtMs = Date.parse(body.expiresAt);
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now) {
cachedToken = null;
return null;
}
return pendingToken;
}
cachedToken = { token: body.token, expiresAtMs };
return cachedToken.token;
export function prefetchDashboardCsrfToken(): Promise<string | null> {
return getDashboardCsrfToken();
}
export async function withDashboardCsrfHeader(headers?: HeadersInit): Promise<Headers> {
@@ -54,3 +85,112 @@ export async function withDashboardCsrfHeader(headers?: HeadersInit): Promise<He
if (token) result.set(DASHBOARD_CSRF_HEADER, token);
return result;
}
function requestFromInput(input: RequestInfo | URL): Request | null {
return typeof Request !== "undefined" && input instanceof Request ? input : null;
}
function inputUrl(input: RequestInfo | URL): string | null {
const request = requestFromInput(input);
if (request) return request.url;
if (input instanceof URL) return input.href;
return typeof input === "string" ? input : null;
}
function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
if (init?.method) return init.method;
return requestFromInput(input)?.method ?? "GET";
}
function isClientApiPath(pathname: string): boolean {
if (pathname === "/api/v1" || pathname.startsWith("/api/v1/")) return true;
if (pathname === "/api/v1beta" || pathname.startsWith("/api/v1beta/")) return true;
if (pathname === "/v1" || pathname.startsWith("/v1/")) return true;
if (pathname === "/v1beta" || pathname.startsWith("/v1beta/")) return true;
if (pathname === "/v1/v1" || pathname.startsWith("/v1/v1/")) return true;
return CLIENT_API_ALIAS_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix + "/")
);
}
function isPublicApiPath(pathname: string): boolean {
return PUBLIC_API_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
function shouldAttachDashboardCsrf(url: URL): boolean {
if (
TOP_LEVEL_MANAGEMENT_PATH_PREFIXES.some(
(prefix) => url.pathname === prefix || url.pathname.startsWith(prefix + "/")
)
) {
return true;
}
return (
url.pathname.startsWith("/api/") &&
url.pathname !== "/api/auth/csrf" &&
!isPublicApiPath(url.pathname) &&
!isClientApiPath(url.pathname)
);
}
function sameOriginDashboardMutation(input: RequestInfo | URL, init?: RequestInit): boolean {
if (typeof window === "undefined") return false;
const method = requestMethod(input, init).toUpperCase();
if (!["POST", "PUT", "PATCH", "DELETE"].includes(method)) return false;
const rawUrl = inputUrl(input);
if (!rawUrl) return false;
let url: URL;
try {
url = new URL(rawUrl, window.location.href);
} catch {
return false;
}
return url.origin === window.location.origin && shouldAttachDashboardCsrf(url);
}
function mergedHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {
return new Headers(init?.headers ?? requestFromInput(input)?.headers);
}
export function installDashboardCsrfFetch(): () => void {
if (typeof globalThis.fetch !== "function") return () => {};
if (installCount === 0) {
originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
if (!originalFetch || !sameOriginDashboardMutation(input, init)) {
return originalFetch ? originalFetch(input, init) : fetch(input, init);
}
const headers = mergedHeaders(input, init);
if (headers.has(DASHBOARD_CSRF_HEADER)) {
return originalFetch(input, init);
}
const token = currentDashboardCsrfToken() ?? (await getDashboardCsrfToken());
if (!token) return originalFetch(input, init);
headers.set(DASHBOARD_CSRF_HEADER, token);
return originalFetch(input, { ...init, headers });
}) as typeof fetch;
}
installCount++;
let active = true;
return () => {
if (!active) return;
active = false;
installCount = Math.max(0, installCount - 1);
if (installCount === 0 && originalFetch) {
globalThis.fetch = originalFetch;
originalFetch = null;
}
};
}

View File

@@ -26,7 +26,7 @@ function request(path: string, cookie = "auth_token=session-a", token?: string):
}
describe("dashboard CSRF tokens", () => {
it("accepts a valid token for dashboard test mutation paths", () => {
it("accepts a valid token for dashboard management mutation paths", () => {
const issued = issueDashboardCsrfToken(request("/api/auth/csrf"), 1_000);
assert.ok(issued);
@@ -42,15 +42,13 @@ describe("dashboard CSRF tokens", () => {
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
true
);
assert.equal(
validateDashboardCsrfToken(request("/api/settings", undefined, issued.token), 1_000),
true
);
});

View File

@@ -325,12 +325,12 @@ 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 () => {
test("runAuthzPipeline rejects dashboard 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",
request("http://127.0.0.1:20128/api/settings", {
method: "PATCH",
headers: {
cookie: await dashboardCookie(),
host: "127.0.0.1:20128",
@@ -348,7 +348,7 @@ test("runAuthzPipeline rejects dashboard test mutations from dynamic public orig
assert.equal(body.error.code, "INVALID_ORIGIN");
});
test("runAuthzPipeline accepts dashboard test mutations from dynamic public origins with CSRF", async () => {
test("runAuthzPipeline accepts dashboard mutations from dynamic public origins with CSRF", async () => {
await forceAuthRequired();
const cookie = await dashboardCookie();
@@ -359,10 +359,16 @@ test("runAuthzPipeline accepts dashboard test mutations from dynamic public orig
);
assert.ok(issued);
for (const path of ["/api/combos/test", "/api/models/test", "/api/models/test-all"]) {
for (const [method, path] of [
["POST", "/api/models/test"],
["POST", "/api/keys"],
["PATCH", "/api/settings"],
["PUT", "/api/combos/combo-1"],
["DELETE", "/api/webhooks/webhook-1"],
] as const) {
const response = await pipeline.runAuthzPipeline(
request(`http://127.0.0.1:20128${path}`, {
method: "POST",
method,
headers: {
cookie,
host: "127.0.0.1:20128",
@@ -381,7 +387,7 @@ test("runAuthzPipeline accepts dashboard test mutations from dynamic public orig
}
});
test("runAuthzPipeline keeps non-test management mutations pinned to known origins with CSRF", async () => {
test("runAuthzPipeline does not let CSRF bypass cross-site fetch metadata", async () => {
await forceAuthRequired();
const cookie = await dashboardCookie();
@@ -393,15 +399,15 @@ test("runAuthzPipeline keeps non-test management mutations pinned to known origi
assert.ok(issued);
const response = await pipeline.runAuthzPipeline(
request("http://127.0.0.1:20128/api/keys", {
method: "POST",
request("http://127.0.0.1:20128/api/settings", {
method: "PATCH",
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",
"sec-fetch-site": "cross-site",
},
body: "{}",
}),

View File

@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf";
import {
__resetDashboardCsrfTokenForTests,
installDashboardCsrfFetch,
prefetchDashboardCsrfToken,
} from "@/shared/utils/dashboardCsrf";
let fetchMock: ReturnType<typeof vi.fn>;
let uninstall: (() => void) | null = null;
function csrfResponse(): Response {
return new Response(
JSON.stringify({
token: "csrf-token",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
beforeEach(() => {
__resetDashboardCsrfTokenForTests();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
uninstall = installDashboardCsrfFetch();
});
afterEach(() => {
uninstall?.();
uninstall = null;
__resetDashboardCsrfTokenForTests();
vi.unstubAllGlobals();
});
describe("installDashboardCsrfFetch", () => {
it("adds dashboard CSRF to same-origin unsafe requests", async () => {
fetchMock.mockResolvedValueOnce(csrfResponse()).mockResolvedValueOnce(new Response("{}"));
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: "{}",
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"/api/auth/csrf",
expect.objectContaining({ cache: "no-store", credentials: "same-origin" })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"/api/settings",
expect.objectContaining({ method: "PATCH" })
);
const headers = fetchMock.mock.calls[1][1]?.headers as Headers;
expect(headers.get(DASHBOARD_CSRF_HEADER)).toBe("csrf-token");
expect(headers.get("Content-Type")).toBe("application/json");
});
it("adds dashboard CSRF to explicit top-level management routes", async () => {
fetchMock.mockResolvedValueOnce(csrfResponse()).mockResolvedValueOnce(new Response("{}"));
await fetch("/a2a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/csrf");
expect(fetchMock.mock.calls[1][0]).toBe("/a2a");
const headers = fetchMock.mock.calls[1][1]?.headers as Headers;
expect(headers.get(DASHBOARD_CSRF_HEADER)).toBe("csrf-token");
});
it("reuses a prefetched dashboard CSRF token for later unsafe requests", async () => {
fetchMock.mockResolvedValueOnce(csrfResponse()).mockResolvedValueOnce(new Response("{}"));
await prefetchDashboardCsrfToken();
await fetch("/api/settings", { method: "PATCH", body: "{}" });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/csrf");
expect(fetchMock.mock.calls[1][0]).toBe("/api/settings");
const headers = fetchMock.mock.calls[1][1]?.headers as Headers;
expect(headers.get(DASHBOARD_CSRF_HEADER)).toBe("csrf-token");
});
it("does not modify safe, cross-origin, or already-protected requests", async () => {
fetchMock.mockResolvedValue(new Response("{}"));
await fetch("/api/settings");
await fetch("https://api.example.test/api/settings", { method: "PATCH" });
await fetch("/api/settings", {
method: "PATCH",
headers: { [DASHBOARD_CSRF_HEADER]: "existing-token" },
});
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
"/api/settings",
"https://api.example.test/api/settings",
"/api/settings",
]);
});
it("does not attach dashboard CSRF to client API aliases or public API routes", async () => {
fetchMock.mockResolvedValue(new Response("{}"));
await fetch("/api/v1/rerank", { method: "POST" });
await fetch("/v1/chat/completions", { method: "POST" });
await fetch("/v1beta/models", { method: "POST" });
await fetch("/responses", { method: "POST" });
await fetch("/codex", { method: "POST" });
await fetch("/api/auth/logout", { method: "POST" });
expect(fetchMock).toHaveBeenCalledTimes(6);
expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([
"/api/v1/rerank",
"/v1/chat/completions",
"/v1beta/models",
"/responses",
"/codex",
"/api/auth/logout",
]);
});
});

View File

@@ -1,25 +1,22 @@
/**
* Tests for useSystemProxyExitGuard — beforeunload listener + sendBeacon revert.
* Tests for useSystemProxyExitGuard — beforeunload listener + keepalive fetch revert.
*
* Strategy: test the hook logic directly without React — we exercise the same
* branches as the hook by simulating mount/unmount via the cleanup pattern.
* This matches how use-traffic-stream.test.tsx tests hook logic (pure logic,
* no React renderer needed).
*/
import { describe, it, before, after, beforeEach, afterEach, mock } from "node:test";
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
// ---------------------------------------------------------------------------
// Minimal beforeunload event simulation
// ---------------------------------------------------------------------------
type BeforeUnloadListener = (e: {
preventDefault: () => void;
returnValue: string;
}) => void;
type BeforeUnloadListener = (e: { preventDefault: () => void; returnValue: string }) => void;
let registeredListeners: Array<{ type: string; fn: BeforeUnloadListener }> = [];
let beaconCalls: Array<{ url: string; body: string }> = [];
let fetchCalls: Array<{ url: string; init: RequestInit }> = [];
const mockWindow = {
addEventListener(type: string, fn: BeforeUnloadListener) {
@@ -30,20 +27,9 @@ const mockWindow = {
},
};
const mockNavigator = {
sendBeacon(url: string, data: Blob | string | null) {
let body = "";
if (typeof data === "string") {
body = data;
} else if (data instanceof Blob) {
// In Node test env, Blob is available (Node 18+). We synchronously read
// the content by constructing from JSON directly via the known test data.
// We store the URL for assertion — exact body checked separately.
body = "<blob>";
}
beaconCalls.push({ url, body });
return true;
},
const mockFetch = (url: string, init: RequestInit) => {
fetchCalls.push({ url, init });
return Promise.resolve(new Response("{}"));
};
// ---------------------------------------------------------------------------
@@ -59,18 +45,21 @@ interface GuardOpts {
function mountGuard(opts: GuardOpts): () => void {
let appliedRef = opts.applied;
const endpoint =
opts.endpoint ?? "/api/tools/traffic-inspector/capture-modes/system-proxy";
const endpoint = opts.endpoint ?? "/api/tools/traffic-inspector/capture-modes/system-proxy";
const body = JSON.stringify({ action: "revert" });
const blob = new Blob([body], { type: "application/json" });
const revertSystemProxy = () => {
void mockFetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {});
};
const beforeUnload: BeforeUnloadListener = (e) => {
if (!appliedRef) return;
try {
mockNavigator.sendBeacon(endpoint, blob);
} catch {
/* ignore */
}
revertSystemProxy();
e.preventDefault();
e.returnValue = "System-wide proxy still active — leave page anyway?";
};
@@ -81,11 +70,7 @@ function mountGuard(opts: GuardOpts): () => void {
const cleanup = () => {
mockWindow.removeEventListener("beforeunload", beforeUnload);
if (appliedRef) {
try {
mockNavigator.sendBeacon(endpoint, blob);
} catch {
/* ignore */
}
revertSystemProxy();
}
};
@@ -104,7 +89,7 @@ function mountGuard(opts: GuardOpts): () => void {
describe("useSystemProxyExitGuard hook logic", () => {
beforeEach(() => {
registeredListeners = [];
beaconCalls = [];
fetchCalls = [];
});
it("adds beforeunload listener on mount", () => {
@@ -114,7 +99,7 @@ describe("useSystemProxyExitGuard hook logic", () => {
cleanup();
});
it("fires sendBeacon with correct endpoint and body when applied=true on beforeunload", () => {
it("fires keepalive fetch with correct endpoint and body when applied=true on beforeunload", () => {
const endpoint = "/api/tools/traffic-inspector/capture-modes/system-proxy";
const cleanup = mountGuard({ applied: true, endpoint });
@@ -122,18 +107,21 @@ describe("useSystemProxyExitGuard hook logic", () => {
const fakeEvent = { preventDefault: () => {}, returnValue: "" };
registeredListeners[0].fn(fakeEvent);
assert.equal(beaconCalls.length, 1);
assert.equal(beaconCalls[0].url, endpoint);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, endpoint);
assert.equal(fetchCalls[0].init.method, "POST");
assert.equal(fetchCalls[0].init.body, JSON.stringify({ action: "revert" }));
assert.equal(fetchCalls[0].init.keepalive, true);
cleanup();
});
it("does NOT fire sendBeacon on beforeunload when applied=false", () => {
it("does NOT fire keepalive fetch on beforeunload when applied=false", () => {
const cleanup = mountGuard({ applied: false });
const fakeEvent = { preventDefault: () => {}, returnValue: "" };
registeredListeners[0].fn(fakeEvent);
assert.equal(beaconCalls.length, 0);
assert.equal(fetchCalls.length, 0);
cleanup();
});
@@ -144,30 +132,27 @@ describe("useSystemProxyExitGuard hook logic", () => {
assert.equal(registeredListeners.length, 0);
});
it("fires sendBeacon on unmount when applied=true (SPA navigation revert)", () => {
it("fires keepalive fetch on unmount when applied=true (SPA navigation revert)", () => {
const cleanup = mountGuard({ applied: true });
// No beforeunload triggered — just unmount (SPA navigation)
cleanup();
assert.equal(beaconCalls.length, 1);
assert.equal(
beaconCalls[0].url,
"/api/tools/traffic-inspector/capture-modes/system-proxy"
);
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, "/api/tools/traffic-inspector/capture-modes/system-proxy");
});
it("does NOT fire sendBeacon on unmount when applied=false", () => {
it("does NOT fire keepalive fetch on unmount when applied=false", () => {
const cleanup = mountGuard({ applied: false });
cleanup();
assert.equal(beaconCalls.length, 0);
assert.equal(fetchCalls.length, 0);
});
it("uses custom endpoint when provided", () => {
const customEndpoint = "/api/custom/system-proxy";
const cleanup = mountGuard({ applied: true, endpoint: customEndpoint });
cleanup();
assert.equal(beaconCalls[0].url, customEndpoint);
assert.equal(fetchCalls[0].url, customEndpoint);
});
it("sets returnValue on beforeunload event when applied=true", () => {