diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2bf8aee8..8f668c9391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ _In development — bullets added per PR; finalized at release._ +### 🔧 Bug Fixes + +- **authz/dashboard:** stop rejecting every dashboard mutation with `403 INVALID_ORIGIN` when the dashboard is reached over a LAN IP / non-localhost host. The origin-pinning check (#5278) only accepted the configured `*_PUBLIC_BASE_URL` (typically `http://localhost:20128`) plus the internal `request.url` origin — which Next.js standalone reports as the bind host, not the real `Host`. So opening the dashboard at e.g. `http://192.168.0.15:20128` made the browser's same-origin `Origin` match no candidate, and **every** POST/PUT/DELETE (save API key, save provider, test connection) failed while GETs still worked. Two fixes: **(a)** the request `Host` (or a trusted `X-Forwarded-Host`) is now accepted as a valid mutation origin, gated by two independent checks — the token-stamped socket peer must be loopback/private-LAN **and** the Host itself must be a loopback/private-LAN IP literal, so a DNS-rebinding domain (which classifies as `remote`) can never become a trusted origin and the protocol is pinned to the actual connection; **(b)** the `INVALID_ORIGIN` response now carries an actionable message (set `OMNIROUTE_PUBLIC_BASE_URL`) and the dashboard surfaces API error `.message` via a shared `extractApiErrorMessage` helper instead of rendering the raw error object. Regression guards: `tests/unit/authz/public-origin.test.ts` (direct LAN/loopback + DNS-rebinding defense), `tests/unit/api-error-message-5340.test.ts`. ([#5340](https://github.com/diegosouzapw/OmniRoute/issues/5340)) + --- ## [3.8.40] — 2026-06-29 diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index b776f2455b..15224fb377 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -22,6 +22,7 @@ import type { KeyStatus, KeyType } from "./apiManagerPageUtils"; import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage"; import { buildApiKeyCreateScopes, mergeApiKeyPermissionScopes } from "./apiManagerScopes"; import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/selfServiceScopes"; +import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; import { UsageLimitSettings } from "./components/UsageLimitSettings"; // Constants for validation @@ -550,7 +551,7 @@ export default function ApiManagerPageClient() { setNewKeyAllowUsageCommand(false); setShowAddModal(false); } else { - setCreateError(data.error || t("failedCreateKey")); + setCreateError(extractApiErrorMessage(data, t("failedCreateKey"))); } } catch (error) { console.error("Error creating key:", error); @@ -585,7 +586,7 @@ export default function ApiManagerPageClient() { setVisibleKeys((prev) => (prev.has(id) ? toggleKeyVisibility(prev, id) : prev)); } else { const data = await res.json(); - setPageError(data.error || t("failedDeleteKey")); + setPageError(extractApiErrorMessage(data, t("failedDeleteKey"))); } } catch (error) { console.error("Error deleting key:", error); @@ -609,7 +610,7 @@ export default function ApiManagerPageClient() { setCreatedKey(data.key); await fetchData(); } else { - setPageError(data.error || t("failedRegenerateKey")); + setPageError(extractApiErrorMessage(data, t("failedRegenerateKey"))); } } catch (error) { console.error("Error regenerating key:", error); @@ -785,7 +786,7 @@ export default function ApiManagerPageClient() { setEditingKey(null); } else { const data = await res.json(); - setPageError(data.error || t("failedUpdatePermissions")); + setPageError(extractApiErrorMessage(data, t("failedUpdatePermissions"))); } } catch (error) { console.error("Error updating permissions:", error); diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 413c4c4f9d..21cf43db92 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -174,7 +174,9 @@ function invalidOriginResponse(requestId: string): NextResponse { { error: { code: "INVALID_ORIGIN", - message: "Invalid request origin", + message: + "Invalid request origin. If you reach the dashboard over a custom host or an HTTPS proxy, " + + "set OMNIROUTE_PUBLIC_BASE_URL to that exact URL and restart. Direct loopback/LAN-IP access works without it.", correlation_id: requestId, }, }, diff --git a/src/server/origin/publicOrigin.ts b/src/server/origin/publicOrigin.ts index 87a9431325..c925b8e166 100644 --- a/src/server/origin/publicOrigin.ts +++ b/src/server/origin/publicOrigin.ts @@ -2,7 +2,11 @@ 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 type PublicOriginSource = + | "configured" + | "trusted-forwarded" + | "request-url" + | "direct-local-host"; export interface PublicOriginCandidate { origin: string; @@ -162,6 +166,56 @@ function requestUrlOrigin(request: Request): string | null { } } +function requestUrlProtocol(request: Request): "http" | "https" { + try { + return new URL(request.url).protocol === "https:" ? "https" : "http"; + } catch { + return "http"; + } +} + +/** + * Direct (no-proxy) LAN/loopback access (#5340). When the dashboard is reached + * over a private-network host — e.g. `http://192.168.0.15:20128` instead of the + * configured `localhost` base URL — Next.js standalone reports the internal bind + * host in `request.url`, so the browser's same-origin `Origin` matches no + * candidate and every mutation is rejected with INVALID_ORIGIN. + * + * This accepts the request `Host` (or a trusted `X-Forwarded-Host`) as a valid + * mutation origin, gated by TWO independent checks so it cannot be abused: + * 1. The token-stamped socket peer must be loopback or private-LAN (a public + * client never matches — it presents a public source IP). + * 2. The Host itself must be a loopback/private-LAN IP literal. A DNS-rebinding + * attacker carries a *domain* in the Host (classifies as "remote"), so a + * rebind to a loopback/LAN socket cannot turn an attacker domain into a + * trusted origin. Direct IP access over the LAN/loopback still works. + * The protocol is pinned to the actual connection (or a trusted forwarded proto), + * never derived from the attacker-controllable Origin. + */ +function directLocalHostOrigin(request: Request): string | null { + const peer = resolveStampedPeer( + request.headers.get(PEER_IP_HEADER), + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + if (classifyHostLocality(peer) === "remote") return null; + + const rawHost = trustsForwardedHeaders(request) + ? firstHeaderValue(request.headers.get("x-forwarded-host")) ?? request.headers.get("host") + : request.headers.get("host"); + const host = sanitizeForwardedHost(rawHost); + if (!host) return null; + if (classifyHostLocality(host) === "remote") return null; + + const proto = + sanitizeForwardedProto(firstHeaderValue(request.headers.get("x-forwarded-proto"))) ?? + requestUrlProtocol(request); + try { + return normalizeOrigin(`${proto}://${host}`); + } catch { + return null; + } +} + export function getPublicOriginCandidates(request: Request): PublicOriginCandidate[] { const candidates: PublicOriginCandidate[] = []; @@ -176,6 +230,9 @@ export function getPublicOriginCandidates(request: Request): PublicOriginCandida if (forwarded) candidates.push({ origin: forwarded, source: "trusted-forwarded" }); } + const directLocal = directLocalHostOrigin(request); + if (directLocal) candidates.push({ origin: directLocal, source: "direct-local-host" }); + return uniqueCandidates(candidates); } diff --git a/src/shared/http/apiErrorMessage.ts b/src/shared/http/apiErrorMessage.ts new file mode 100644 index 0000000000..8c0ce412d9 --- /dev/null +++ b/src/shared/http/apiErrorMessage.ts @@ -0,0 +1,19 @@ +/** + * Extract a human-readable message from an API error response body. + * + * OmniRoute's structured error envelope is `{ error: { code, message, + * correlation_id } }`, but some routes return `{ error: "string" }`. Rendering + * the raw `error` object in the dashboard yields "[object Object]" (or nothing), + * which hid actionable messages such as `INVALID_ORIGIN` (#5340) — the operator + * saw a silent failure instead of guidance. Funnel API error bodies through this + * so the message (and its actionable hint) always surfaces. + */ +export function extractApiErrorMessage(body: unknown, fallback: string): string { + const err = (body as { error?: unknown } | null | undefined)?.error; + if (typeof err === "string" && err.trim()) return err.trim(); + if (err && typeof err === "object") { + const message = (err as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return message.trim(); + } + return fallback; +} diff --git a/tests/unit/api-error-message-5340.test.ts b/tests/unit/api-error-message-5340.test.ts new file mode 100644 index 0000000000..fa825685c0 --- /dev/null +++ b/tests/unit/api-error-message-5340.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; + +describe("extractApiErrorMessage (#5340)", () => { + it("surfaces the message from a structured error envelope", () => { + const body = { + error: { code: "INVALID_ORIGIN", message: "Invalid request origin", correlation_id: "x" }, + }; + assert.equal(extractApiErrorMessage(body, "fallback"), "Invalid request origin"); + }); + + it("returns a plain string error as-is", () => { + assert.equal(extractApiErrorMessage({ error: "boom" }, "fallback"), "boom"); + }); + + it("never renders a raw error object — falls back when message is missing", () => { + assert.equal(extractApiErrorMessage({ error: { code: "X" } }, "fallback"), "fallback"); + }); + + it("falls back for empty, null, or malformed bodies", () => { + assert.equal(extractApiErrorMessage({ error: " " }, "fallback"), "fallback"); + assert.equal(extractApiErrorMessage(null, "fallback"), "fallback"); + assert.equal(extractApiErrorMessage({}, "fallback"), "fallback"); + assert.equal(extractApiErrorMessage({ error: { message: 42 } }, "fallback"), "fallback"); + }); +}); diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 000b63a836..876253d519 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -343,7 +343,8 @@ test("runAuthzPipeline rejects dashboard mutations from invalid browser origin", assert.equal(response.status, 403); assert.equal(body.error.code, "INVALID_ORIGIN"); - assert.equal(body.error.message, "Invalid request origin"); + assert.match(body.error.message, /^Invalid request origin\./); + assert.match(body.error.message, /OMNIROUTE_PUBLIC_BASE_URL/); }); test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => { diff --git a/tests/unit/authz/public-origin.test.ts b/tests/unit/authz/public-origin.test.ts index 70ef420fb9..8bdd18dece 100644 --- a/tests/unit/authz/public-origin.test.ts +++ b/tests/unit/authz/public-origin.test.ts @@ -219,3 +219,102 @@ describe("public origin resolution", () => { }); }); }); + +describe("direct LAN/loopback host origin (#5340)", () => { + it("accepts a direct LAN-IP host even when a localhost public base URL is configured", () => { + process.env.NEXT_PUBLIC_BASE_URL = "http://localhost:20128"; + const request = new Request("http://omniroute:20128/api/keys", { + method: "POST", + headers: { + ...stampedPeer("192.168.0.50"), + host: "192.168.0.15:20128", + origin: "http://192.168.0.15:20128", + "sec-fetch-site": "same-origin", + }, + }); + + assert.equal( + getPublicOriginCandidates(request).some( + (candidate) => candidate.origin === "http://192.168.0.15:20128" + ), + true + ); + assert.equal(validateBrowserMutationOrigin(request).ok, true); + }); + + it("accepts a direct loopback-IP host with no configured public origin", () => { + const request = new Request("http://omniroute:20128/api/keys", { + method: "POST", + headers: { + ...stampedPeer("127.0.0.1"), + host: "127.0.0.1:20128", + origin: "http://127.0.0.1:20128", + }, + }); + + assert.equal(validateBrowserMutationOrigin(request).ok, true); + }); + + it("rejects a DNS-rebinding domain host even when the peer is loopback", () => { + // evil.example rebinds to a loopback socket; the Host header carries the + // attacker domain, which classifies as remote → no trusted candidate. + const request = new Request("http://omniroute:20128/api/keys", { + method: "POST", + headers: { + ...stampedPeer("127.0.0.1"), + host: "evil.example:20128", + origin: "http://evil.example:20128", + "sec-fetch-site": "same-origin", + }, + }); + + assert.equal( + getPublicOriginCandidates(request).some( + (candidate) => candidate.origin === "http://evil.example:20128" + ), + false + ); + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("does not widen the origin for a remote peer even when the Host is a LAN IP", () => { + const request = new Request("http://omniroute:20128/api/keys", { + method: "POST", + headers: { + ...stampedPeer("203.0.113.7"), + host: "192.168.0.15:20128", + origin: "http://192.168.0.15:20128", + "sec-fetch-site": "same-origin", + }, + }); + + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("does not trust the Host header when the peer stamp is absent", () => { + const request = new Request("http://omniroute:20128/api/keys", { + method: "POST", + headers: { + host: "192.168.0.15:20128", + origin: "http://192.168.0.15:20128", + "sec-fetch-site": "same-origin", + }, + }); + + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); + + it("pins the protocol to the connection — a mismatched https origin is rejected", () => { + const request = new Request("http://omniroute:20128/api/keys", { + method: "POST", + headers: { + ...stampedPeer("192.168.0.50"), + host: "192.168.0.15:20128", + origin: "https://192.168.0.15:20128", + "sec-fetch-site": "same-origin", + }, + }); + + assert.equal(validateBrowserMutationOrigin(request).ok, false); + }); +});