mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00: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:
parent
bc9d18457d
commit
1c18be4f8f
11 changed files with 600 additions and 40 deletions
23
.env.example
23
.env.example
|
|
@ -239,10 +239,13 @@ ALLOW_API_KEY_REVEAL=false
|
|||
# Default: 10485760 (10 MB)
|
||||
# MAX_BODY_SIZE_BYTES=10485760
|
||||
|
||||
# CORS configuration — controls which origins can call the API.
|
||||
# Used by: Next.js middleware — sets Access-Control-Allow-Origin header.
|
||||
# Default: * (all origins) | Restrict for production security.
|
||||
# CORS_ORIGIN=https://your-domain.com
|
||||
# CORS configuration — controls which cross-origin browser clients can call the API.
|
||||
# Used by: src/server/cors/origins.ts — sets Access-Control-Allow-Origin.
|
||||
# Same-origin dashboard requests behind a reverse proxy do not need CORS; set
|
||||
# NEXT_PUBLIC_BASE_URL instead. No wildcard is sent unless CORS_ALLOW_ALL=true.
|
||||
# CORS_ALLOWED_ORIGINS=https://your-frontend.example.com
|
||||
# CORS_ORIGIN=https://your-frontend.example.com # legacy single-origin alias
|
||||
# CORS_ALLOW_ALL=false
|
||||
|
||||
# Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, etc.).
|
||||
# REQUIRED for self-hosted providers: LM Studio, Ollama, vLLM, Llamafile, Triton, etc.
|
||||
|
|
@ -332,6 +335,7 @@ ALLOW_API_KEY_REVEAL=false
|
|||
# URLs used for internal sync jobs, OAuth callbacks, and cloud relay.
|
||||
|
||||
# Internal base URL — used by server-side sync jobs to call /api/sync/cloud.
|
||||
# Keep this as a loopback/container URL even when the app is publicly proxied.
|
||||
# Used by: src/lib/cloudSync.ts, src/lib/initCloudSync.ts
|
||||
# Default: http://localhost:20128
|
||||
BASE_URL=http://localhost:20128
|
||||
|
|
@ -346,7 +350,8 @@ CLOUD_URL=
|
|||
# CLOUD_SYNC_TIMEOUT_MS=12000
|
||||
|
||||
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
|
||||
# Used by: OAuth redirect_uri computation, Dashboard UI links, cloud/model sync.
|
||||
# Used by: OAuth redirect_uri computation, Dashboard UI links, generated public
|
||||
# URLs, and same-origin browser mutation checks.
|
||||
# Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com).
|
||||
#
|
||||
# Dashboard display behavior: when this variable is unset, the dashboard
|
||||
|
|
@ -360,6 +365,7 @@ CLOUD_URL=
|
|||
NEXT_PUBLIC_BASE_URL=http://localhost:20128
|
||||
|
||||
# Browser-facing OmniRoute origin for generated assets in API responses.
|
||||
# Highest-priority public origin override; also used by public-origin validation.
|
||||
# Used by: chatgpt-web image generation cache URLs (/v1/chatgpt-web/image/<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
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
|
||||
|
||||
| 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
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_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);
|
||||
|
|
|
|||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue