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:
diegosouzapw
2026-05-30 17:26:10 -03:00
parent 6095842ef0
commit 5a61ae9a98
3 changed files with 90 additions and 1 deletions

View File

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

View File

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

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