From 1c18be4f8f0e1dd458444f93ad79fe600af2b841 Mon Sep 17 00:00:00 2001 From: Arthur Bodera Date: Mon, 29 Jun 2026 14:43:14 +1000 Subject: [PATCH] fix: centralize public origin checks for proxied dashboards (#5278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralizes browser-mutation origin validation into `src/server/origin/publicOrigin.ts` and wires it through the authz pipeline, replacing the per-route same-origin-only check that 403'd dashboard mutations when served behind a reverse proxy on a different public origin. The new module resolves the allowed public origin from configured base-URL env vars or trusted forwarded headers (only when OMNIROUTE_TRUST_PROXY is set AND the peer is loopback/LAN via peer-stamp), validates Sec-Fetch-Site metadata, and sanitizes Host/Forwarded inputs (rejects control chars, userinfo, path/query in Host). Reviewed sound; validated locally: authz/public-origin + pipeline suites 27/27 green (incl. invalid-origin reject + configured-origin accept), typecheck clean. Maintainer fix-up: moved the new test from tests/unit/server/ (not collected by any runner — orphan-test gate fail) into tests/unit/authz/. Remaining red CI shards are the pre-existing #4076 Dockerfile heap base-red on `main` (unrelated; de-brittled in the v3.8.40 release line). Co-authored-by: Thinkscape --- .env.example | 23 +- .prettierignore | 2 + SECURITY.md | 16 +- docs/guides/DOCKER_GUIDE.md | 9 + docs/ops/VM_DEPLOYMENT_GUIDE.md | 17 +- docs/reference/ENVIRONMENT.md | 19 +- .../health-autopilot/actions/route.ts | 18 -- src/server/authz/pipeline.ts | 34 +++ src/server/origin/publicOrigin.ts | 213 +++++++++++++++++ tests/unit/authz/pipeline.test.ts | 68 ++++++ tests/unit/authz/public-origin.test.ts | 221 ++++++++++++++++++ 11 files changed, 600 insertions(+), 40 deletions(-) create mode 100644 .prettierignore create mode 100644 src/server/origin/publicOrigin.ts create mode 100644 tests/unit/authz/public-origin.test.ts diff --git a/.env.example b/.env.example index ba7e33056..d38fa84c9 100644 --- a/.env.example +++ b/.env.example @@ -239,10 +239,13 @@ ALLOW_API_KEY_REVEAL=false # Default: 10485760 (10 MB) # MAX_BODY_SIZE_BYTES=10485760 -# CORS configuration — controls which origins can call the API. -# Used by: Next.js middleware — sets Access-Control-Allow-Origin header. -# Default: * (all origins) | Restrict for production security. -# CORS_ORIGIN=https://your-domain.com +# 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. +# CORS_ALLOWED_ORIGINS=https://your-frontend.example.com +# CORS_ORIGIN=https://your-frontend.example.com # legacy single-origin alias +# CORS_ALLOW_ALL=false # Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, etc.). # REQUIRED for self-hosted providers: LM Studio, Ollama, vLLM, Llamafile, Triton, etc. @@ -332,6 +335,7 @@ ALLOW_API_KEY_REVEAL=false # URLs used for internal sync jobs, OAuth callbacks, and cloud relay. # Internal base URL — used by server-side sync jobs to call /api/sync/cloud. +# Keep this as a loopback/container URL even when the app is publicly proxied. # Used by: src/lib/cloudSync.ts, src/lib/initCloudSync.ts # Default: http://localhost:20128 BASE_URL=http://localhost:20128 @@ -346,7 +350,8 @@ CLOUD_URL= # 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, cloud/model sync. +# 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). # # Dashboard display behavior: when this variable is unset, the dashboard @@ -360,6 +365,7 @@ CLOUD_URL= 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. # 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. @@ -383,6 +389,13 @@ NEXT_PUBLIC_CLOUD_URL= # Legacy alias — fallback for NEXT_PUBLIC_BASE_URL in sync schedulers. # NEXT_PUBLIC_APP_URL=http://localhost:20128 +# Advanced reverse-proxy trust mode for deriving public origin from Forwarded / +# X-Forwarded-* headers when no explicit public base URL is set. Prefer setting +# NEXT_PUBLIC_BASE_URL. Only enable if direct client access to OmniRoute is blocked +# and your proxy strips/rebuilds incoming forwarded headers. +# Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers). +# OMNIROUTE_TRUST_PROXY= + # Public callback URL for asynchronous image/audio jobs (kie.ai, etc.). # Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl(). # Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL. diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..b2256c121 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Long reference tables are manually aligned; formatting the whole file causes noisy diffs. +docs/reference/ENVIRONMENT.md diff --git a/SECURITY.md b/SECURITY.md index 41f945175..6b9c5a4f6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -113,14 +113,14 @@ PII_REDACTION_ENABLED=true ### 🌐 Network Security -| Feature | Description | -| ------------------------ | ---------------------------------------------------------------- | -| **CORS** | Configurable origin control (`CORS_ORIGIN` env var, default `*`) | -| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard | -| **Rate Limiting** | Per-provider rate limits with automatic backoff | -| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s | -| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection | -| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures | +| Feature | Description | +| ------------------------ | ------------------------------------------------------------------------------ | +| **CORS** | Explicit cross-origin allowlist (`CORS_ALLOWED_ORIGINS`; legacy `CORS_ORIGIN`) | +| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard | +| **Rate Limiting** | Per-provider rate limits with automatic backoff | +| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s | +| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection | +| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures | ### 🔌 Resilience & Availability diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index f99a60075..18aa0aeb2 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -190,7 +190,11 @@ services: - omniroute-data:/app/data environment: - PORT=20128 + # Browser-facing origin for OAuth callbacks, dashboard links, and same-origin checks. - NEXT_PUBLIC_BASE_URL=https://your-domain.com + # Internal server-to-server URL for scheduled jobs / self-fetches. + - BASE_URL=http://omniroute:20128 + - AUTH_COOKIE_SECURE=true caddy: image: caddy:latest @@ -205,6 +209,11 @@ volumes: omniroute-data: ``` +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. + ## Cloudflare Quick Tunnel Dashboard support for Docker deployments includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL. diff --git a/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/ops/VM_DEPLOYMENT_GUIDE.md index ef9cad928..2daa45dc4 100644 --- a/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -113,12 +113,16 @@ NODE_ENV=production HOSTNAME=0.0.0.0 DATA_DIR=/app/data APP_LOG_TO_FILE=true -AUTH_COOKIE_SECURE=false +AUTH_COOKIE_SECURE=true REQUIRE_API_KEY=false -# === Domain (change to your domain) === -BASE_URL=https://llms.seudominio.com +# === 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. 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 # === Cloud Sync (optional) === # CLOUD_URL=https://cloud.omniroute.online @@ -207,6 +211,7 @@ server { location / { proxy_pass http://127.0.0.1:20128; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -238,6 +243,12 @@ 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. + ### 3.3 Enable and Test ```bash diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 6008d3f8c..c2c31a63c 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -176,7 +176,9 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `NO_LOG_API_KEY_IDS` | _(empty)_ | `src/lib/compliance/index.ts` | Comma-separated API key IDs that bypass request logging (GDPR compliance). | | `DEFAULT_RATE_LIMIT_PER_DAY` | `1000` | `src/shared/utils/apiKeyPolicy.ts` | Fallback per-day request budget applied to API keys whose `rate_limits` column is null. Default (unset/empty/malformed) keeps the legacy 1000/day, 5000/week, 20000/month windows. Set explicitly to `0` to opt out (unlimited). Any positive integer N enables N/day, 5N/week, 20N/month. Zod-validated; invalid values log a warning and use the legacy default. | | `MAX_BODY_SIZE_BYTES` | `10485760` (10 MB) | `src/shared/middleware/bodySizeGuard.ts` | Maximum allowed request body size. Rejects payloads exceeding this limit. | -| `CORS_ORIGIN` | `*` | Next.js middleware | CORS `Access-Control-Allow-Origin` value. Restrict for production. | +| `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_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. | | `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` | `false` | `src/shared/network/outboundUrlGuard.ts` | Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, 10.x.x.x, etc.). **REQUIRED for self-hosted providers** (LM Studio, Ollama, vLLM, Llamafile, Triton, SearXNG). When `false`, the dashboard rejects validation of local URLs. | | `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` | `true` | `src/shared/network/outboundUrlGuard.ts` | Allow adding/validating providers on local/private addresses (127.0.0.1, localhost, LAN, private ranges) — scoped to the provider validation path. **Default `true`** (local-first); set `false` to enforce strict public-only blocking. Cloud-metadata endpoints (169.254.169.254, metadata.google.internal) stay blocked regardless. (#5066) | @@ -188,7 +190,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari AUTH_COOKIE_SECURE=true # Requires HTTPS REQUIRE_API_KEY=true # Authenticate all proxy calls ALLOW_API_KEY_REVEAL=false # Never expose keys in UI -CORS_ORIGIN=https://your.domain.com +CORS_ALLOWED_ORIGINS=https://your.domain.com MAX_BODY_SIZE_BYTES=5242880 # 5 MB limit ``` @@ -245,17 +247,18 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | Variable | Default | Source File | Description | | --------------------------------------- | --------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | Server-side URL for internal sync jobs to call `/api/sync/cloud`. | +| `BASE_URL` | `http://localhost:20128` | `src/lib/cloudSync.ts` | Server-side URL for internal sync jobs to call `/api/sync/cloud`. Keep this as a loopback/container URL even when the app is publicly proxied. | | `CLOUD_URL` | _(empty)_ | `src/lib/cloudSync.ts` | Cloud relay endpoint URL (premium feature). | | `CLOUD_SYNC_TIMEOUT_MS` | `12000` | `src/lib/cloudSync.ts` | HTTP timeout for cloud sync requests. | | `OMNIROUTE_BUILD_PROFILE` | `full` | Webpack build config | Build-time profile (set to `minimal` to physically exclude privileged modules from bundle). | | `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. **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, generated public URLs, and same-origin browser mutation checks. **Must match your public URL behind reverse proxy.** | | `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)_ | `open-sse/executors/chatgpt-web.ts` | Browser-facing OmniRoute origin used for image URLs in API responses (e.g., `/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 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. | | `THEOLDLLM_NAV_TIMEOUT_MS` | `30000` (30s) | `open-sse/executors/theoldllm.ts` | Playwright navigation timeout (ms) for the browser-backed token capture used by the The Old LLM (theoldllm) free provider. Raise on slow networks if the relay page is slow to settle. | @@ -278,7 +281,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 will fail because the redirect_uri won't match. +> 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. +> +> 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. --- diff --git a/src/app/api/providers/health-autopilot/actions/route.ts b/src/app/api/providers/health-autopilot/actions/route.ts index 7f7dbc3fc..4bff91fcd 100644 --- a/src/app/api/providers/health-autopilot/actions/route.ts +++ b/src/app/api/providers/health-autopilot/actions/route.ts @@ -24,28 +24,10 @@ const actionSchema = z.object({ confirm: z.boolean().optional(), }); -function hasSafeMutationOrigin(request: Request): boolean { - const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase(); - if (fetchSite && !["same-origin", "same-site", "none"].includes(fetchSite)) return false; - - const origin = request.headers.get("origin"); - if (!origin) return true; - - try { - return new URL(origin).origin === new URL(request.url).origin; - } catch { - return false; - } -} - export async function POST(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - if (!hasSafeMutationOrigin(request)) { - return NextResponse.json({ error: { message: "Invalid request origin" } }, { status: 403 }); - } - try { let rawBody: unknown; try { diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 81b039b49..413c4c4f9 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -5,6 +5,7 @@ import { isDraining } from "../../lib/gracefulShutdown"; import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard"; import { generateRequestId } from "../../shared/utils/requestId"; import { applyCorsHeaders } from "../cors/origins"; +import { validateBrowserMutationOrigin } from "../origin/publicOrigin"; import { classifyRoute } from "./classify"; import { classifyStampedPeerLocality } from "./peerStamp"; import { clientApiPolicy } from "./policies/clientApi"; @@ -168,6 +169,25 @@ function drainingResponse(requestId: string): NextResponse { return response; } +function invalidOriginResponse(requestId: string): NextResponse { + const response = NextResponse.json( + { + error: { + code: "INVALID_ORIGIN", + message: "Invalid request origin", + correlation_id: requestId, + }, + }, + { status: 403 } + ); + response.headers.set(AUTHZ_HEADER_REQUEST_ID, requestId); + return response; +} + +function isUnsafeMutationMethod(method: string): boolean { + return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase()); +} + function stampRouteResponse( response: Response, requestId: string, @@ -287,6 +307,20 @@ export async function runAuthzPipeline( return rejection; } + if ( + classification.routeClass === "MANAGEMENT" && + outcome.subject.kind === "dashboard_session" && + isUnsafeMutationMethod(method) + ) { + const originVerdict = validateBrowserMutationOrigin(request); + if (!originVerdict.ok) { + const rejection = invalidOriginResponse(requestId); + rejection.headers.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); + applyCorsHeaders(rejection, request); + return rejection; + } + } + stampSubject(requestHeaders, outcome.subject); const response = NextResponse.next({ request: { headers: requestHeaders } }); diff --git a/src/server/origin/publicOrigin.ts b/src/server/origin/publicOrigin.ts new file mode 100644 index 000000000..87a943132 --- /dev/null +++ b/src/server/origin/publicOrigin.ts @@ -0,0 +1,213 @@ +import { classifyHostLocality } from "@/server/authz/routeGuard"; +import { PEER_IP_HEADER } from "@/server/authz/headers"; +import { resolveStampedPeer } from "@/server/authz/peerStamp"; + +export type PublicOriginSource = "configured" | "trusted-forwarded" | "request-url"; + +export interface PublicOriginCandidate { + origin: string; + source: PublicOriginSource; +} + +export interface BrowserMutationOriginVerdict { + ok: boolean; + reason?: "cross-site-fetch-metadata" | "invalid-origin"; +} + +const PUBLIC_BASE_URL_ENV = [ + "OMNIROUTE_PUBLIC_BASE_URL", + "NEXT_PUBLIC_BASE_URL", + "NEXT_PUBLIC_APP_URL", +] as const; + +function uniqueCandidates(candidates: PublicOriginCandidate[]): PublicOriginCandidate[] { + const seen = new Set(); + const result: PublicOriginCandidate[] = []; + for (const candidate of candidates) { + const normalized = normalizeOrigin(candidate.origin); + if (seen.has(normalized)) continue; + seen.add(normalized); + result.push({ ...candidate, origin: normalized }); + } + return result; +} + +export function firstHeaderValue(value: string | null): string | null { + return value?.split(",")[0]?.trim() || null; +} + +export function normalizeOrigin(origin: string): string { + const parsed = new URL(origin); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Unsupported origin protocol"); + } + return parsed.origin.toLowerCase(); +} + +function configuredPublicOrigins(): PublicOriginCandidate[] { + const candidates: PublicOriginCandidate[] = []; + for (const name of PUBLIC_BASE_URL_ENV) { + const value = process.env[name]?.trim(); + if (!value) continue; + try { + candidates.push({ origin: normalizeOrigin(value), source: "configured" }); + } catch { + continue; + } + } + return candidates; +} + +function forwardedHeaderPart(value: string | undefined): string | null { + if (!value) return null; + let result = value.trim(); + if (!result) return null; + if (result.startsWith('"') && result.endsWith('"') && result.length >= 2) { + result = result.slice(1, -1); + } + return result || null; +} + +function parseForwardedHeader(value: string | null): { proto: string | null; host: string | null } { + const first = firstHeaderValue(value); + if (!first) return { proto: null, host: null }; + + let proto: string | null = null; + let host: string | null = null; + for (const segment of first.split(";")) { + const [rawKey, ...rawValue] = segment.split("="); + const key = rawKey?.trim().toLowerCase(); + const part = forwardedHeaderPart(rawValue.join("=")); + if (!key || !part) continue; + if (key === "proto") proto = part; + if (key === "host") host = part; + } + return { proto, host }; +} + +function sanitizeForwardedProto(proto: string | null): "http" | "https" | null { + const normalized = proto?.trim().toLowerCase(); + if (normalized === "http" || normalized === "https") return normalized; + return null; +} + +function sanitizeForwardedHost(host: string | null): string | null { + const trimmed = host?.trim(); + if (!trimmed) return null; + if (/[/\\\s\x00-\x1f\x7f]/.test(trimmed)) return null; + try { + const parsed = new URL(`http://${trimmed}`); + if ( + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + return null; + } + return parsed.host.toLowerCase(); + } catch { + return null; + } +} + +function trustProxyMode(): "none" | "loopback" | "private" { + const raw = process.env.OMNIROUTE_TRUST_PROXY?.trim().toLowerCase(); + if (!raw || ["0", "false", "none", "off", "no", "disable", "disabled"].includes(raw)) { + return "none"; + } + if (["true", "1", "loopback"].includes(raw)) return "loopback"; + if (raw === "private" || raw === "lan") return "private"; + return "none"; +} + +export function trustsForwardedHeaders(request: Request): boolean { + const mode = trustProxyMode(); + if (mode === "none") return false; + + const peer = resolveStampedPeer( + request.headers.get(PEER_IP_HEADER), + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + const locality = classifyHostLocality(peer); + if (mode === "loopback") return locality === "loopback"; + return locality === "loopback" || locality === "lan"; +} + +function trustedForwardedOrigin(request: Request): string | null { + if (!trustsForwardedHeaders(request)) return null; + + const forwarded = parseForwardedHeader(request.headers.get("forwarded")); + const proto = sanitizeForwardedProto( + forwarded.proto ?? firstHeaderValue(request.headers.get("x-forwarded-proto")) + ); + const host = sanitizeForwardedHost( + forwarded.host ?? firstHeaderValue(request.headers.get("x-forwarded-host")) + ); + if (!proto || !host) return null; + + try { + return normalizeOrigin(`${proto}://${host}`); + } catch { + return null; + } +} + +function requestUrlOrigin(request: Request): string | null { + try { + return normalizeOrigin(new URL(request.url).origin); + } catch { + return null; + } +} + +export function getPublicOriginCandidates(request: Request): PublicOriginCandidate[] { + const candidates: PublicOriginCandidate[] = []; + + const requestOrigin = requestUrlOrigin(request); + if (requestOrigin) candidates.push({ origin: requestOrigin, source: "request-url" }); + + const configured = configuredPublicOrigins(); + candidates.push(...configured); + + if (configured.length === 0) { + const forwarded = trustedForwardedOrigin(request); + if (forwarded) candidates.push({ origin: forwarded, source: "trusted-forwarded" }); + } + + return uniqueCandidates(candidates); +} + +export function resolvePublicOrigin(request: Request): PublicOriginCandidate { + const configured = uniqueCandidates(configuredPublicOrigins()); + if (configured.length > 0) return configured[0]; + + const forwarded = trustedForwardedOrigin(request); + if (forwarded) return { origin: forwarded, source: "trusted-forwarded" }; + + const requestOrigin = requestUrlOrigin(request); + if (requestOrigin) return { origin: requestOrigin, source: "request-url" }; + + return { origin: "http://localhost:20128", source: "request-url" }; +} + +export function validateBrowserMutationOrigin(request: Request): BrowserMutationOriginVerdict { + const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase(); + if (fetchSite && !["same-origin", "same-site", "none"].includes(fetchSite)) { + return { ok: false, reason: "cross-site-fetch-metadata" }; + } + + const origin = request.headers.get("origin"); + if (!origin) return { ok: true }; + + let normalizedOrigin: string; + try { + normalizedOrigin = normalizeOrigin(origin); + } catch { + return { ok: false, reason: "invalid-origin" }; + } + + const allowed = new Set(getPublicOriginCandidates(request).map((candidate) => candidate.origin)); + return allowed.has(normalizedOrigin) ? { ok: true } : { ok: false, reason: "invalid-origin" }; +} diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 814ebb2fe..a08d41d3b 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -18,6 +18,11 @@ const pipeline = await import("../../../src/server/authz/pipeline.ts"); const ORIGINAL_JWT = process.env.JWT_SECRET; const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD; const ORIGINAL_AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE; +const ORIGINAL_OMNIROUTE_PUBLIC_BASE_URL = process.env.OMNIROUTE_PUBLIC_BASE_URL; +const ORIGINAL_NEXT_PUBLIC_BASE_URL = process.env.NEXT_PUBLIC_BASE_URL; +const ORIGINAL_NEXT_PUBLIC_APP_URL = process.env.NEXT_PUBLIC_APP_URL; +const ORIGINAL_OMNIROUTE_TRUST_PROXY = process.env.OMNIROUTE_TRUST_PROXY; +const ORIGINAL_OMNIROUTE_PEER_STAMP_TOKEN = process.env.OMNIROUTE_PEER_STAMP_TOKEN; function resetEnvironment() { core.resetDbInstance(); @@ -27,6 +32,11 @@ function resetEnvironment() { process.env.JWT_SECRET = "pipeline-jwt-secret"; process.env.INITIAL_PASSWORD = "pipeline-initial-password"; delete process.env.AUTH_COOKIE_SECURE; + delete process.env.OMNIROUTE_PUBLIC_BASE_URL; + delete process.env.NEXT_PUBLIC_BASE_URL; + delete process.env.NEXT_PUBLIC_APP_URL; + delete process.env.OMNIROUTE_TRUST_PROXY; + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 }; } @@ -59,6 +69,20 @@ test.after(() => { else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL; if (ORIGINAL_AUTH_COOKIE_SECURE === undefined) delete process.env.AUTH_COOKIE_SECURE; else process.env.AUTH_COOKIE_SECURE = ORIGINAL_AUTH_COOKIE_SECURE; + if (ORIGINAL_OMNIROUTE_PUBLIC_BASE_URL === undefined) + delete process.env.OMNIROUTE_PUBLIC_BASE_URL; + else process.env.OMNIROUTE_PUBLIC_BASE_URL = ORIGINAL_OMNIROUTE_PUBLIC_BASE_URL; + if (ORIGINAL_NEXT_PUBLIC_BASE_URL === undefined) delete process.env.NEXT_PUBLIC_BASE_URL; + else process.env.NEXT_PUBLIC_BASE_URL = ORIGINAL_NEXT_PUBLIC_BASE_URL; + if (ORIGINAL_NEXT_PUBLIC_APP_URL === undefined) delete process.env.NEXT_PUBLIC_APP_URL; + else process.env.NEXT_PUBLIC_APP_URL = ORIGINAL_NEXT_PUBLIC_APP_URL; + if (ORIGINAL_OMNIROUTE_TRUST_PROXY === undefined) delete process.env.OMNIROUTE_TRUST_PROXY; + else process.env.OMNIROUTE_TRUST_PROXY = ORIGINAL_OMNIROUTE_TRUST_PROXY; + if (ORIGINAL_OMNIROUTE_PEER_STAMP_TOKEN === undefined) { + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; + } else { + process.env.OMNIROUTE_PEER_STAMP_TOKEN = ORIGINAL_OMNIROUTE_PEER_STAMP_TOKEN; + } globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 }; }); @@ -246,6 +270,50 @@ test("runAuthzPipeline allows dashboard sessions to reach DB health management A assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); }); +test("runAuthzPipeline accepts dashboard mutations from configured public origin", async () => { + await forceAuthRequired(); + process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test"; + + const response = await pipeline.runAuthzPipeline( + request("http://omniroute:20128/api/providers/health-autopilot/actions", { + method: "POST", + headers: { + cookie: await dashboardCookie(), + origin: "https://gateway.example.test", + "content-type": "application/json", + }, + body: "{}", + }), + { enforce: true } + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); +}); + +test("runAuthzPipeline rejects dashboard mutations from invalid browser origin", async () => { + await forceAuthRequired(); + process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test"; + + const response = await pipeline.runAuthzPipeline( + request("http://omniroute:20128/api/providers/health-autopilot/actions", { + method: "POST", + headers: { + cookie: await dashboardCookie(), + origin: "https://evil.example", + "content-type": "application/json", + }, + body: "{}", + }), + { enforce: true } + ); + const body = await response.json(); + + assert.equal(response.status, 403); + assert.equal(body.error.code, "INVALID_ORIGIN"); + assert.equal(body.error.message, "Invalid request origin"); +}); + test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => { await forceAuthRequired(); const secret = new TextEncoder().encode(process.env.JWT_SECRET); diff --git a/tests/unit/authz/public-origin.test.ts b/tests/unit/authz/public-origin.test.ts new file mode 100644 index 000000000..70ef420fb --- /dev/null +++ b/tests/unit/authz/public-origin.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { describe, it, beforeEach, after } from "node:test"; + +import { PEER_IP_HEADER } from "@/server/authz/headers"; +import { + getPublicOriginCandidates, + resolvePublicOrigin, + trustsForwardedHeaders, + validateBrowserMutationOrigin, +} from "@/server/origin/publicOrigin"; + +const ORIGINAL_ENV = { + OMNIROUTE_PUBLIC_BASE_URL: process.env.OMNIROUTE_PUBLIC_BASE_URL, + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + OMNIROUTE_TRUST_PROXY: process.env.OMNIROUTE_TRUST_PROXY, + OMNIROUTE_PEER_STAMP_TOKEN: process.env.OMNIROUTE_PEER_STAMP_TOKEN, +}; + +function restoreEnv() { + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function clearPublicOriginEnv() { + delete process.env.OMNIROUTE_PUBLIC_BASE_URL; + delete process.env.NEXT_PUBLIC_BASE_URL; + delete process.env.NEXT_PUBLIC_APP_URL; + delete process.env.OMNIROUTE_TRUST_PROXY; + delete process.env.OMNIROUTE_PEER_STAMP_TOKEN; +} + +function stampedPeer(ip: string): Record { + const token = randomUUID(); + process.env.OMNIROUTE_PEER_STAMP_TOKEN = token; + return { [PEER_IP_HEADER]: `${token}|${ip}` }; +} + +beforeEach(() => { + clearPublicOriginEnv(); +}); + +after(() => { + restoreEnv(); +}); + +describe("public origin resolution", () => { + it("uses configured public base URLs before the internal request URL", () => { + process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test/app/"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions"); + + assert.deepEqual(resolvePublicOrigin(request), { + origin: "https://gateway.example.test", + source: "configured", + }); + assert.equal( + validateBrowserMutationOrigin( + new Request(request.url, { + method: "POST", + headers: { origin: "https://gateway.example.test" }, + }) + ).ok, + true + ); + }); + + it("preserves configured source priority when it equals the request URL", () => { + process.env.NEXT_PUBLIC_BASE_URL = "http://omniroute:20128/app"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions"); + + assert.deepEqual(resolvePublicOrigin(request), { + origin: "http://omniroute:20128", + source: "configured", + }); + }); + + it("accepts all configured public origins while resolving the highest-priority one", () => { + process.env.OMNIROUTE_PUBLIC_BASE_URL = "https://assets.example.test/images"; + process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test/app"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { origin: "https://gateway.example.test" }, + }); + + assert.deepEqual(resolvePublicOrigin(request), { + origin: "https://assets.example.test", + source: "configured", + }); + assert.deepEqual( + getPublicOriginCandidates(request).filter((candidate) => candidate.source === "configured"), + [ + { origin: "https://assets.example.test", source: "configured" }, + { origin: "https://gateway.example.test", source: "configured" }, + ] + ); + assert.equal(validateBrowserMutationOrigin(request).ok, true); + }); + + it("keeps the internal request URL as an accepted candidate", () => { + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions"); + + assert.deepEqual(getPublicOriginCandidates(request), [ + { origin: "http://omniroute:20128", source: "request-url" }, + ]); + }); + + it("does not trust spoofed forwarded headers by default", () => { + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { + origin: "https://attacker.example", + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "https", + }, + }); + + assert.equal(trustsForwardedHeaders(request), false); + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("fails closed for unknown proxy trust mode values", () => { + process.env.OMNIROUTE_TRUST_PROXY = "flase"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { + ...stampedPeer("127.0.0.1"), + origin: "https://gateway.example.test", + "x-forwarded-host": "gateway.example.test", + "x-forwarded-proto": "https", + }, + }); + + assert.equal(trustsForwardedHeaders(request), false); + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("can trust forwarded headers from a token-stamped loopback proxy when explicitly enabled", () => { + process.env.OMNIROUTE_TRUST_PROXY = "true"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { + ...stampedPeer("127.0.0.1"), + origin: "https://gateway.example.test", + "x-forwarded-host": "gateway.example.test", + "x-forwarded-proto": "https", + }, + }); + + assert.equal(trustsForwardedHeaders(request), true); + assert.equal(validateBrowserMutationOrigin(request).ok, true); + }); + + it("does not allow trusted forwarded headers to widen a configured public origin", () => { + process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test"; + process.env.OMNIROUTE_TRUST_PROXY = "true"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { + ...stampedPeer("127.0.0.1"), + origin: "https://evil.example.test", + "x-forwarded-host": "evil.example.test", + "x-forwarded-proto": "https", + }, + }); + + assert.equal( + getPublicOriginCandidates(request).some( + (candidate) => candidate.origin === "https://evil.example.test" + ), + false + ); + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("does not derive trusted forwarded origin from the raw host header", () => { + process.env.OMNIROUTE_TRUST_PROXY = "true"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { + ...stampedPeer("127.0.0.1"), + host: "gateway.example.test", + origin: "https://gateway.example.test", + "x-forwarded-proto": "https", + }, + }); + + assert.equal( + getPublicOriginCandidates(request).some( + (candidate) => candidate.source === "trusted-forwarded" + ), + false + ); + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("rejects malformed forwarded origins even when proxy trust is enabled", () => { + process.env.OMNIROUTE_TRUST_PROXY = "true"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { + ...stampedPeer("127.0.0.1"), + origin: "https://gateway.example.test", + "x-forwarded-host": "gateway.example.test/evil", + "x-forwarded-proto": "https", + }, + }); + + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("rejects cross-site fetch metadata before origin candidate matching", () => { + process.env.NEXT_PUBLIC_BASE_URL = "https://gateway.example.test"; + const request = new Request("http://omniroute:20128/api/providers/health-autopilot/actions", { + headers: { + origin: "https://gateway.example.test", + "sec-fetch-site": "cross-site", + }, + }); + + assert.deepEqual(validateBrowserMutationOrigin(request), { + ok: false, + reason: "cross-site-fetch-metadata", + }); + }); +});