mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(authz): allow LOCAL_ONLY paths from private-LAN peer IPs (owner-authorized)
Services + Traffic-Inspector (LOCAL_ONLY, spawn-capable) returned 403 when the dashboard was reached via the LAN IP (192.168.0.x) instead of loopback. Add isPrivateLanHost (RFC1918 IPv4 + IPv6 ULA/link-local) and widen ONLY the local-only PATH gate to accept private-LAN socket peer IPs — based on the real socket peer address (not the spoofable Host header), so public-internet clients present public IPs and stay blocked. The CLI-token gate stays strictly loopback; paths remain LOCAL_ONLY-classified (Hard Rules 15/17 unchanged). Enforcement-layer carve-out for a LAN-deployed instance, authorized by the operator.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
isLocalOnlyBypassableByManageScope,
|
||||
isLocalOnlyPath,
|
||||
isLoopbackHost,
|
||||
isPrivateLanHost,
|
||||
} from "../routeGuard";
|
||||
|
||||
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
|
||||
@@ -26,6 +27,14 @@ function isLoopbackRequest(ctx: PolicyContext): boolean {
|
||||
return peerAddress ? isLoopbackHost(peerAddress) : false;
|
||||
}
|
||||
|
||||
// Owner-authorized (2026-05-30): allow LOCAL_ONLY *paths* from a trusted private
|
||||
// LAN, based on the real socket peer IP (not spoofable). Does NOT relax the
|
||||
// CLI-token gate, which stays strictly loopback.
|
||||
function isPrivateLanRequest(ctx: PolicyContext): boolean {
|
||||
const peerAddress = requestPeerAddress(ctx);
|
||||
return peerAddress ? isPrivateLanHost(peerAddress) : false;
|
||||
}
|
||||
|
||||
function hasValidCliToken(ctx: PolicyContext): boolean {
|
||||
if (!isLoopbackRequest(ctx)) return false;
|
||||
const headers = ctx.request.headers;
|
||||
@@ -70,7 +79,7 @@ export const managementPolicy: RoutePolicy = {
|
||||
//
|
||||
// Anonymous (no Bearer / invalid key / wrong scope / no session) requests
|
||||
// still hit the same 403 LOCAL_ONLY they did before.
|
||||
if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx)) {
|
||||
if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx) && !isPrivateLanRequest(ctx)) {
|
||||
if (isLocalOnlyBypassableByManageScope(path)) {
|
||||
const apiKey = extractApiKey(ctx.request as unknown as Request);
|
||||
if (apiKey) {
|
||||
|
||||
@@ -89,6 +89,39 @@ export function isLoopbackHost(hostHeader: string | null): boolean {
|
||||
return LOOPBACK_HOSTS.has(host.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Private-LAN ranges (RFC 1918 IPv4 + IPv6 ULA/link-local). Matched against the
|
||||
* real socket peer address (NOT the spoofable Host header), so a public-internet
|
||||
* client — which presents a public source IP — never matches.
|
||||
*/
|
||||
const PRIVATE_LAN_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
|
||||
/^192\.168\.\d{1,3}\.\d{1,3}$/,
|
||||
/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/,
|
||||
/^f[cd][0-9a-f]{2}:/i, // IPv6 ULA fc00::/7
|
||||
/^fe80:/i, // IPv6 link-local
|
||||
];
|
||||
|
||||
/**
|
||||
* True when the peer address is a private-LAN address. Used to widen the
|
||||
* LOCAL_ONLY tier to a trusted private network (owner-authorized 2026-05-30 for
|
||||
* a LAN-deployed instance). Loopback-only surfaces that do NOT use this (e.g.
|
||||
* the CLI-token path) remain strictly loopback.
|
||||
*/
|
||||
export function isPrivateLanHost(hostHeader: string | null): boolean {
|
||||
if (!hostHeader) return false;
|
||||
let host = hostHeader.trim();
|
||||
if (host.startsWith("[")) {
|
||||
const bracketEnd = host.indexOf("]");
|
||||
host = bracketEnd >= 0 ? host.slice(1, bracketEnd) : host.slice(1);
|
||||
}
|
||||
host = host.replace(/^::ffff:/i, "");
|
||||
// Strip :port only for IPv4 / hostname (a lone colon); leave IPv6 intact.
|
||||
if ((host.match(/:/g) || []).length === 1) host = host.split(":")[0];
|
||||
host = host.toLowerCase();
|
||||
return PRIVATE_LAN_PATTERNS.some((re) => re.test(host));
|
||||
}
|
||||
|
||||
export function isLocalOnlyPath(path: string): boolean {
|
||||
return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p));
|
||||
}
|
||||
|
||||
47
tests/unit/route-guard-private-lan.test.ts
Normal file
47
tests/unit/route-guard-private-lan.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isPrivateLanHost, isLoopbackHost, isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
|
||||
|
||||
test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)", () => {
|
||||
for (const h of [
|
||||
"192.168.0.15",
|
||||
"192.168.0.15:54321",
|
||||
"10.0.0.5",
|
||||
"172.16.0.9",
|
||||
"172.31.255.254",
|
||||
"::ffff:192.168.1.20",
|
||||
]) {
|
||||
assert.equal(isPrivateLanHost(h), true, `expected private-LAN: ${h}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isPrivateLanHost: accepts IPv6 ULA / link-local", () => {
|
||||
assert.equal(isPrivateLanHost("fd12:3456::1"), true);
|
||||
assert.equal(isPrivateLanHost("fe80::1"), true);
|
||||
});
|
||||
|
||||
test("isPrivateLanHost: rejects public IPs, loopback and junk", () => {
|
||||
for (const h of [
|
||||
"8.8.8.8",
|
||||
"69.164.221.35", // public VPS
|
||||
"172.32.0.1", // just outside 172.16/12
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"example.com",
|
||||
"",
|
||||
null,
|
||||
]) {
|
||||
assert.equal(isPrivateLanHost(h), false, `expected NOT private-LAN: ${h}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isLoopbackHost stays loopback-only (unchanged)", () => {
|
||||
assert.equal(isLoopbackHost("127.0.0.1"), true);
|
||||
assert.equal(isLoopbackHost("localhost:20128"), true);
|
||||
assert.equal(isLoopbackHost("192.168.0.15"), false);
|
||||
});
|
||||
|
||||
test("services + traffic-inspector remain LOCAL_ONLY paths", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/9router/status"), true);
|
||||
assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true);
|
||||
});
|
||||
Reference in New Issue
Block a user