From 0e55bdabd4df4cf3814c19f55792b5941a816550 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:23:35 -0300 Subject: [PATCH 1/3] chore(docker): harden base image against container-scan CVEs (#5228) apt-get upgrade -y in the base stage pulls security-patched trixie packages at build time, and npm install -g npm@latest refreshes the globally-bundled undici/tar inside the npm CLI. Together these clear the subset of GitHub container-scan CVE alerts that have an upstream fix available. None of the flagged CVEs are in the application dependency tree (app already resolves undici@8.5.0 / tar@7.5.16, both fixed); they live in the node:24-trixie-slim base layer and npm's own internals, and none are reachable from the proxy request surface at runtime. CVEs without a published fix (local-only TOCTOU, etc.) remain until the distro patches them and the image is rebuilt. --- Dockerfile | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Dockerfile b/Dockerfile index b9cab220f6..89d0c2fad5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,12 +2,28 @@ FROM node:24-trixie-slim AS base WORKDIR /app +# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie) +# base-image packages at build time — clears the subset of container-scan CVEs +# (perl / util-linux / systemd / ncurses / zlib / tar / sqlite / shadow / pam …) +# that already have a fix published in trixie. CVEs without an upstream fix yet +# (local-only TOCTOU, etc.) remain until the distro patches them and the image +# is rebuilt; none are reachable from the proxy's request surface at runtime. RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \ --mount=type=cache,target=/var/lib/apt/lists,sharing=shared \ apt-get update \ + && apt-get upgrade -y \ && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ && rm -rf /var/lib/apt/lists/* +# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar) +# ship the patched versions. These are npm's own internals — not application +# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) — +# but the container scanner flags the stale copies under +# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in +# the runner stages, so this is hygiene, not an exploitable runtime path. +RUN npm install -g npm@latest \ + && npm cache clean --force + # ── Builder ──────────────────────────────────────────────────────────────── FROM base AS builder From bc9d18457df762d033b0acb0b3f951f9f8827d45 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:31:41 -0300 Subject: [PATCH 2/3] chore(ci): Trivy advisory scan ignores unfixed CVEs (Security-tab noise) (#5234) The advisory Trivy image scan uploaded every HIGH/CRITICAL into the Security tab without ignore-unfixed, flooding it with ~150 unfixable base-image OS CVEs (Debian trixie packages with no upstream patch, overwhelmingly local-only and not reachable from the proxy request surface). Operators cannot act on those, so they are pure noise. Add ignore-unfixed:true to the advisory step so it mirrors the existing CRITICAL blocking gate and surfaces only actionable, fixable vulnerabilities. Wire trivyignores to a new repo-root .trivyignore that documents the accepted-risk policy and is the single auditable home for the rare fixable CVE we must temporarily accept (none at present). Takes effect on the next release image build (Trivy only runs on tag builds, not main pushes); fixed CVEs drop out of the SARIF and GitHub auto-resolves the corresponding alerts. --- .github/workflows/docker-publish.yml | 11 +++++++++++ .trivyignore | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .trivyignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ec91e0c229..c21f233051 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -344,6 +344,15 @@ jobs: # Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but # never blocks (exit-code 0). The blocking gate below narrows to CRITICAL. + # + # ignore-unfixed mirrors the blocking gate: the Security tab must surface only + # ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding + # on a patched base or bumping the dep. Without it the advisory upload floods the + # tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream + # patch yet, overwhelmingly local-only and not reachable from the proxy request + # surface), which is noise an operator cannot act on. trivyignores points at the + # repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home. + # See docs/security/SUPPLY_CHAIN.md. - name: Trivy image scan (SARIF, advisory) if: needs.prepare.outputs.version != 'main' continue-on-error: true @@ -353,6 +362,8 @@ jobs: format: sarif output: trivy-results.sarif severity: HIGH,CRITICAL + ignore-unfixed: true + trivyignores: .trivyignore exit-code: "0" # BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000000..b047a520a2 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,22 @@ +# .trivyignore — accepted-risk suppressions for the container image scan +# +# Policy (see docs/security/SUPPLY_CHAIN.md): +# - The Trivy steps in .github/workflows/docker-publish.yml run with +# `ignore-unfixed: true`, so vulnerabilities WITHOUT a published fix are +# already excluded from both the blocking CRITICAL gate and the advisory +# Security-tab upload. You do NOT need an entry here for an unfixable +# base-image OS CVE — it will not be reported. +# - This file is the single auditable home for the rare case where a *fixable* +# CVE must be temporarily accepted (e.g. the upstream fix is not yet in the +# pinned base tag, or the affected package/binary is provably unreachable +# from the proxy request surface and rebuilding now is not justified). +# +# Format — one CVE id per line, each with a justification comment and, where +# possible, an expiry, e.g.: +# # CVE-XXXX-YYYY — ; revisit on next base-image bump (YYYY-MM-DD) +# CVE-XXXX-YYYY +# +# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a +# patched base / bump the dep) over suppressing. Stale entries are debt. +# +# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.) From 1c18be4f8f0e1dd458444f93ad79fe600af2b841 Mon Sep 17 00:00:00 2001 From: Arthur Bodera Date: Mon, 29 Jun 2026 14:43:14 +1000 Subject: [PATCH 3/3] 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 ba7e330569..d38fa84c92 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 0000000000..b2256c1219 --- /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 41f9451753..6b9c5a4f60 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 f99a60075c..18aa0aeb2d 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 ef9cad928b..2daa45dc43 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 6008d3f8c6..c2c31a63c4 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 7f7dbc3fcc..4bff91fcd8 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 81b039b497..413c4c4f9d 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 0000000000..87a9431325 --- /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 814ebb2fe8..a08d41d3bc 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 0000000000..70ef420fb9 --- /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", + }); + }); +});