mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix: centralize public origin checks for proxied dashboards (#5278)
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 <Thinkscape@users.noreply.github.com>
This commit is contained in:
23
.env.example
23
.env.example
@@ -239,10 +239,13 @@ ALLOW_API_KEY_REVEAL=false
|
|||||||
# Default: 10485760 (10 MB)
|
# Default: 10485760 (10 MB)
|
||||||
# MAX_BODY_SIZE_BYTES=10485760
|
# MAX_BODY_SIZE_BYTES=10485760
|
||||||
|
|
||||||
# CORS configuration — controls which origins can call the API.
|
# CORS configuration — controls which cross-origin browser clients can call the API.
|
||||||
# Used by: Next.js middleware — sets Access-Control-Allow-Origin header.
|
# Used by: src/server/cors/origins.ts — sets Access-Control-Allow-Origin.
|
||||||
# Default: * (all origins) | Restrict for production security.
|
# Same-origin dashboard requests behind a reverse proxy do not need CORS; set
|
||||||
# CORS_ORIGIN=https://your-domain.com
|
# 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.).
|
# 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.
|
# 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.
|
# 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.
|
# 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
|
# Used by: src/lib/cloudSync.ts, src/lib/initCloudSync.ts
|
||||||
# Default: http://localhost:20128
|
# Default: http://localhost:20128
|
||||||
BASE_URL=http://localhost:20128
|
BASE_URL=http://localhost:20128
|
||||||
@@ -346,7 +350,8 @@ CLOUD_URL=
|
|||||||
# CLOUD_SYNC_TIMEOUT_MS=12000
|
# CLOUD_SYNC_TIMEOUT_MS=12000
|
||||||
|
|
||||||
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
|
# 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).
|
# 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
|
# Dashboard display behavior: when this variable is unset, the dashboard
|
||||||
@@ -360,6 +365,7 @@ CLOUD_URL=
|
|||||||
NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||||
|
|
||||||
# Browser-facing OmniRoute origin for generated assets in API responses.
|
# 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/<id>).
|
# Used by: chatgpt-web image generation cache URLs (/v1/chatgpt-web/image/<id>).
|
||||||
# Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL
|
# 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.
|
# 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.
|
# Legacy alias — fallback for NEXT_PUBLIC_BASE_URL in sync schedulers.
|
||||||
# NEXT_PUBLIC_APP_URL=http://localhost:20128
|
# 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.).
|
# Public callback URL for asynchronous image/audio jobs (kie.ai, etc.).
|
||||||
# Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl().
|
# Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl().
|
||||||
# Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL.
|
# Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL.
|
||||||
|
|||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
|
||||||
|
docs/reference/ENVIRONMENT.md
|
||||||
16
SECURITY.md
16
SECURITY.md
@@ -113,14 +113,14 @@ PII_REDACTION_ENABLED=true
|
|||||||
|
|
||||||
### 🌐 Network Security
|
### 🌐 Network Security
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
| ------------------------ | ---------------------------------------------------------------- |
|
| ------------------------ | ------------------------------------------------------------------------------ |
|
||||||
| **CORS** | Configurable origin control (`CORS_ORIGIN` env var, default `*`) |
|
| **CORS** | Explicit cross-origin allowlist (`CORS_ALLOWED_ORIGINS`; legacy `CORS_ORIGIN`) |
|
||||||
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
|
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
|
||||||
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
|
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
|
||||||
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
|
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
|
||||||
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
|
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
|
||||||
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
|
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
|
||||||
|
|
||||||
### 🔌 Resilience & Availability
|
### 🔌 Resilience & Availability
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,11 @@ services:
|
|||||||
- omniroute-data:/app/data
|
- omniroute-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PORT=20128
|
- PORT=20128
|
||||||
|
# Browser-facing origin for OAuth callbacks, dashboard links, and same-origin checks.
|
||||||
- NEXT_PUBLIC_BASE_URL=https://your-domain.com
|
- 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:
|
caddy:
|
||||||
image: caddy:latest
|
image: caddy:latest
|
||||||
@@ -205,6 +209,11 @@ volumes:
|
|||||||
omniroute-data:
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -113,12 +113,16 @@ NODE_ENV=production
|
|||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
DATA_DIR=/app/data
|
DATA_DIR=/app/data
|
||||||
APP_LOG_TO_FILE=true
|
APP_LOG_TO_FILE=true
|
||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=true
|
||||||
REQUIRE_API_KEY=false
|
REQUIRE_API_KEY=false
|
||||||
|
|
||||||
# === Domain (change to your domain) ===
|
# === URLs (change to your domain) ===
|
||||||
BASE_URL=https://llms.seudominio.com
|
# 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
|
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 Sync (optional) ===
|
||||||
# CLOUD_URL=https://cloud.omniroute.online
|
# CLOUD_URL=https://cloud.omniroute.online
|
||||||
@@ -207,6 +211,7 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:20128;
|
proxy_pass http://127.0.0.1:20128;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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`
|
`FETCH_TIMEOUT_MS` / `STREAM_IDLE_TIMEOUT_MS`, raise `proxy_read_timeout` / `proxy_send_timeout`
|
||||||
above the same threshold.
|
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
|
### 3.3 Enable and Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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_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) |
|
| `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
|
AUTH_COOKIE_SECURE=true # Requires HTTPS
|
||||||
REQUIRE_API_KEY=true # Authenticate all proxy calls
|
REQUIRE_API_KEY=true # Authenticate all proxy calls
|
||||||
ALLOW_API_KEY_REVEAL=false # Never expose keys in UI
|
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
|
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 |
|
| 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_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. |
|
| `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_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_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_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. |
|
| `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_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`. |
|
| `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/<id>`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. |
|
| `OMNIROUTE_PUBLIC_BASE_URL` | _(unset)_ | Public-origin resolver, image URLs | Highest-priority browser-facing OmniRoute origin used for public URL generation and origin validation (for example `/v1/chatgpt-web/image/<id>`). Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL but the user's browser must fetch images from a LAN, tunnel, or public origin. Do **not** include `/v1`. |
|
||||||
|
| `OMNIROUTE_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. |
|
||||||
| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000` (3 min) | `open-sse/executors/chatgpt-web.ts` | Max wait time for an async chatgpt-web image to land via the celsius WebSocket. Increase during upstream queue-deep windows. |
|
| `OMNIROUTE_CGPT_WEB_IMAGE_TIMEOUT_MS` | `180000` (3 min) | `open-sse/executors/chatgpt-web.ts` | Max wait time for an async chatgpt-web image to land via the celsius WebSocket. Increase during upstream queue-deep windows. |
|
||||||
| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | Total in-memory byte budget (MB) for the chatgpt-web image cache serving `/v1/chatgpt-web/image/<id>`. Lower on memory-constrained hosts; raise if image generation is heavy and clients race the 30-minute TTL. |
|
| `OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB` | `256` | `open-sse/services/chatgptImageCache.ts` | Total in-memory byte budget (MB) for the chatgpt-web image cache serving `/v1/chatgpt-web/image/<id>`. Lower on memory-constrained hosts; raise if image generation is heavy and clients race the 30-minute TTL. |
|
||||||
| `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. |
|
| `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. |
|
| `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]
|
> [!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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -24,28 +24,10 @@ const actionSchema = z.object({
|
|||||||
confirm: z.boolean().optional(),
|
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) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requireManagementAuth(request);
|
const authError = await requireManagementAuth(request);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
if (!hasSafeMutationOrigin(request)) {
|
|
||||||
return NextResponse.json({ error: { message: "Invalid request origin" } }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let rawBody: unknown;
|
let rawBody: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isDraining } from "../../lib/gracefulShutdown";
|
|||||||
import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard";
|
import { checkBodySize, getBodySizeLimit } from "../../shared/middleware/bodySizeGuard";
|
||||||
import { generateRequestId } from "../../shared/utils/requestId";
|
import { generateRequestId } from "../../shared/utils/requestId";
|
||||||
import { applyCorsHeaders } from "../cors/origins";
|
import { applyCorsHeaders } from "../cors/origins";
|
||||||
|
import { validateBrowserMutationOrigin } from "../origin/publicOrigin";
|
||||||
import { classifyRoute } from "./classify";
|
import { classifyRoute } from "./classify";
|
||||||
import { classifyStampedPeerLocality } from "./peerStamp";
|
import { classifyStampedPeerLocality } from "./peerStamp";
|
||||||
import { clientApiPolicy } from "./policies/clientApi";
|
import { clientApiPolicy } from "./policies/clientApi";
|
||||||
@@ -168,6 +169,25 @@ function drainingResponse(requestId: string): NextResponse {
|
|||||||
return response;
|
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(
|
function stampRouteResponse(
|
||||||
response: Response,
|
response: Response,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -287,6 +307,20 @@ export async function runAuthzPipeline(
|
|||||||
return rejection;
|
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);
|
stampSubject(requestHeaders, outcome.subject);
|
||||||
|
|
||||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
|
|||||||
213
src/server/origin/publicOrigin.ts
Normal file
213
src/server/origin/publicOrigin.ts
Normal file
@@ -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<string>();
|
||||||
|
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" };
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ const pipeline = await import("../../../src/server/authz/pipeline.ts");
|
|||||||
const ORIGINAL_JWT = process.env.JWT_SECRET;
|
const ORIGINAL_JWT = process.env.JWT_SECRET;
|
||||||
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
|
const ORIGINAL_INITIAL = process.env.INITIAL_PASSWORD;
|
||||||
const ORIGINAL_AUTH_COOKIE_SECURE = process.env.AUTH_COOKIE_SECURE;
|
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() {
|
function resetEnvironment() {
|
||||||
core.resetDbInstance();
|
core.resetDbInstance();
|
||||||
@@ -27,6 +32,11 @@ function resetEnvironment() {
|
|||||||
process.env.JWT_SECRET = "pipeline-jwt-secret";
|
process.env.JWT_SECRET = "pipeline-jwt-secret";
|
||||||
process.env.INITIAL_PASSWORD = "pipeline-initial-password";
|
process.env.INITIAL_PASSWORD = "pipeline-initial-password";
|
||||||
delete process.env.AUTH_COOKIE_SECURE;
|
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 };
|
globalThis.__omnirouteShutdown = { init: false, shuttingDown: false, activeRequests: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +69,20 @@ test.after(() => {
|
|||||||
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
|
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL;
|
||||||
if (ORIGINAL_AUTH_COOKIE_SECURE === undefined) delete process.env.AUTH_COOKIE_SECURE;
|
if (ORIGINAL_AUTH_COOKIE_SECURE === undefined) delete process.env.AUTH_COOKIE_SECURE;
|
||||||
else process.env.AUTH_COOKIE_SECURE = ORIGINAL_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 };
|
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");
|
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 () => {
|
test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => {
|
||||||
await forceAuthRequired();
|
await forceAuthRequired();
|
||||||
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
|
||||||
|
|||||||
221
tests/unit/authz/public-origin.test.ts
Normal file
221
tests/unit/authz/public-origin.test.ts
Normal file
@@ -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<string, string> {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user