From 103a0ee2a20369e7a5319bcd5d564fcaef6b42bd Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:00:43 -0400 Subject: [PATCH] fix: unify dashboard csrf origin fallback (#5856) Integrated into release/v3.8.43 --- .env.example | 19 +- docs/guides/DOCKER_GUIDE.md | 10 +- docs/ops/TUNNELS_GUIDE.md | 5 + docs/ops/VM_DEPLOYMENT_GUIDE.md | 14 +- docs/reference/ENVIRONMENT.md | 10 +- src/app/(dashboard)/dashboard/combos/page.tsx | 3 +- .../useModelVisibilityHandlers.test.tsx | 35 +--- .../components/PassthroughModelsSection.tsx | 3 +- .../[id]/hooks/useModelVisibilityHandlers.ts | 5 +- .../hooks/useSystemProxyExitGuard.ts | 34 ++-- src/server/authz/csrf.ts | 16 -- src/server/authz/pipeline.ts | 11 +- .../components/layouts/DashboardLayout.tsx | 16 +- src/shared/utils/dashboardCsrf.ts | 178 ++++++++++++++++-- tests/unit/authz/csrf.test.ts | 14 +- tests/unit/authz/pipeline.test.ts | 26 ++- tests/unit/ui/dashboard-csrf-fetch.test.tsx | 132 +++++++++++++ .../ui/use-system-proxy-exit-guard.test.tsx | 83 ++++---- 18 files changed, 429 insertions(+), 185 deletions(-) create mode 100644 tests/unit/ui/dashboard-csrf-fetch.test.tsx diff --git a/.env.example b/.env.example index d54669069c..8361ba3b59 100644 --- a/.env.example +++ b/.env.example @@ -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/). # 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. diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index bfbb6b1829..4e7fb6875a 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -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 diff --git a/docs/ops/TUNNELS_GUIDE.md b/docs/ops/TUNNELS_GUIDE.md index b5ade8c9a4..a168b7a469 100644 --- a/docs/ops/TUNNELS_GUIDE.md +++ b/docs/ops/TUNNELS_GUIDE.md @@ -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 diff --git a/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/ops/VM_DEPLOYMENT_GUIDE.md index 71f13dff00..b8dc7a5071 100644 --- a/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -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 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 30b8aa5437..b059aacb60 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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/`). 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/`). 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/`. 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. --- diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index e6d1e5351a..5f546db165 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -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(); 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 6dce9fa907..38e1d679d4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx @@ -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); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index 4398a114e4..e3dda0a620 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -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({ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts index 7f8f904e89..e9283d1b77 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts @@ -31,7 +31,6 @@ import { type CompatByProtocolMap, } from "../providerPageHelpers"; import { useNotificationStore } from "@/store/notificationStore"; -import { withDashboardCsrfHeader } from "@/shared/utils/dashboardCsrf"; type NotifyStore = ReturnType; @@ -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, diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts b/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts index c982cef570..9e86223428 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/hooks/useSystemProxyExitGuard.ts @@ -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]); diff --git a/src/server/authz/csrf.ts b/src/server/authz/csrf.ts index d735c1e17e..1491830217 100644 --- a/src/server/authz/csrf.ts +++ b/src/server/authz/csrf.ts @@ -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); diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index d05567fd52..247391c179 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -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); diff --git a/src/shared/components/layouts/DashboardLayout.tsx b/src/shared/components/layouts/DashboardLayout.tsx index f72841a6e7..505f5279c0 100644 --- a/src/shared/components/layouts/DashboardLayout.tsx +++ b/src/shared/components/layouts/DashboardLayout.tsx @@ -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. */}
-
- {children} -
+
{children}
diff --git a/src/shared/utils/dashboardCsrf.ts b/src/shared/utils/dashboardCsrf.ts index 6b3747bdfe..872f09644d 100644 --- a/src/shared/utils/dashboardCsrf.ts +++ b/src/shared/utils/dashboardCsrf.ts @@ -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 | 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 { - 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 { 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 { + 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 { + return getDashboardCsrfToken(); } export async function withDashboardCsrfHeader(headers?: HeadersInit): Promise { @@ -54,3 +85,112 @@ export async function withDashboardCsrfHeader(headers?: HeadersInit): Promise 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; + } + }; +} diff --git a/tests/unit/authz/csrf.test.ts b/tests/unit/authz/csrf.test.ts index eff1120e42..ee6419e6cc 100644 --- a/tests/unit/authz/csrf.test.ts +++ b/tests/unit/authz/csrf.test.ts @@ -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 ); }); diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index f2c0dbae5e..18d90b12d8 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -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: "{}", }), diff --git a/tests/unit/ui/dashboard-csrf-fetch.test.tsx b/tests/unit/ui/dashboard-csrf-fetch.test.tsx new file mode 100644 index 0000000000..2a75b5875e --- /dev/null +++ b/tests/unit/ui/dashboard-csrf-fetch.test.tsx @@ -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; +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", + ]); + }); +}); diff --git a/tests/unit/ui/use-system-proxy-exit-guard.test.tsx b/tests/unit/ui/use-system-proxy-exit-guard.test.tsx index 622484d08d..af1eaeeb1b 100644 --- a/tests/unit/ui/use-system-proxy-exit-guard.test.tsx +++ b/tests/unit/ui/use-system-proxy-exit-guard.test.tsx @@ -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 = ""; - } - 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", () => {