mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
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:
committed by
GitHub
parent
ec8ad1a7ac
commit
c50033f393
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
19
src/shared/http/apiErrorMessage.ts
Normal file
19
src/shared/http/apiErrorMessage.ts
Normal 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;
|
||||
}
|
||||
28
tests/unit/api-error-message-5340.test.ts
Normal file
28
tests/unit/api-error-message-5340.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user