fix(authz): accept direct LAN/loopback-IP host as a valid mutation origin (#5340) (#5341)

Integrated into release/v3.8.41. Accepts direct LAN/loopback-IP host as a valid mutation origin (#5340); origin trust gated on the server-stamped peer, not the spoofable Host. 6 security-boundary tests incl. DNS-rebinding.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 10:31:36 -03:00
committed by GitHub
parent ec8ad1a7ac
commit c50033f393
8 changed files with 218 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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