From 5a61ae9a98be04e8bf441258010cf3b871bf9572 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 30 May 2026 17:26:10 -0300 Subject: [PATCH] feat(authz): allow LOCAL_ONLY paths from private-LAN peer IPs (owner-authorized) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/server/authz/policies/management.ts | 11 ++++- src/server/authz/routeGuard.ts | 33 +++++++++++++++ tests/unit/route-guard-private-lan.test.ts | 47 ++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/unit/route-guard-private-lan.test.ts diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 27efce2aaa..9a6cb65af5 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -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) { diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 5cf979986b..44ec9409cf 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -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 = [ + /^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)); } diff --git a/tests/unit/route-guard-private-lan.test.ts b/tests/unit/route-guard-private-lan.test.ts new file mode 100644 index 0000000000..1f41500788 --- /dev/null +++ b/tests/unit/route-guard-private-lan.test.ts @@ -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); +});