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:
Arthur Bodera 2026-06-29 14:43:14 +10:00 committed by GitHub
parent bc9d18457d
commit 1c18be4f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 600 additions and 40 deletions

View file

@ -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/<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.
@ -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.

2
.prettierignore Normal file
View file

@ -0,0 +1,2 @@
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
docs/reference/ENVIRONMENT.md

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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/<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_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. |
@ -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.
---

View file

@ -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 {

View file

@ -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 } });

View 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" };
}

View file

@ -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);

View 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",
});
});
});