From 6b0e89fb42e6cde7f43d6b7e8d5d8e50d3d8463e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 30 May 2026 18:20:39 -0300 Subject: [PATCH] fix(authz): derive LOCAL_ONLY locality from Host header (middleware has no socket IP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authz pipeline runs in the Next middleware runtime (proxy.ts -> runAuthzPipeline) where ctx.request is a NextRequest with no .socket/.ip. requestPeerAddress therefore returned null, so isLoopbackRequest was ALWAYS false and every LOCAL_ONLY path 403'd even from loopback (Services/MCP/Traffic-Inspector were unusable). Read the Host header instead — exactly what isLoopbackHost/isPrivateLanHost were built to parse — which restores loopback and, combined with isPrivateLanHost, enables the owner-authorized private-LAN access. Spawn-capable endpoints still require manage-scope auth after this gate. --- src/server/authz/policies/management.ts | 11 ++++++++++- tests/unit/route-guard-private-lan.test.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 9a6cb65af5..e4832d078a 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -19,7 +19,16 @@ import { const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/; function requestPeerAddress(ctx: PolicyContext): string | null { - return ctx.request.ip || ctx.request.socket?.remoteAddress || null; + // In the Next middleware runtime (proxy.ts → runAuthzPipeline), ctx.request is + // a NextRequest with no socket/.ip, so the only locality signal is the Host + // header — which is exactly what isLoopbackHost/isPrivateLanHost parse (they + // strip :port). This both fixes the loopback gate (previously the null socket + // made every LOCAL_ONLY request 403, even from localhost) and enables the + // owner-authorized private-LAN carve-out. Fall back to .ip/.socket for any + // non-middleware caller that provides them. Spawn-capable endpoints still + // require manage-scope auth after this gate. + const hostHeader = ctx.request.headers?.get?.("host") ?? null; + return hostHeader || ctx.request.ip || ctx.request.socket?.remoteAddress || null; } function isLoopbackRequest(ctx: PolicyContext): boolean { diff --git a/tests/unit/route-guard-private-lan.test.ts b/tests/unit/route-guard-private-lan.test.ts index 1f41500788..9117af0b75 100644 --- a/tests/unit/route-guard-private-lan.test.ts +++ b/tests/unit/route-guard-private-lan.test.ts @@ -1,5 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { isPrivateLanHost, isLoopbackHost, isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)", () => { @@ -45,3 +47,11 @@ 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); }); + +test("management policy derives locality from the Host header (middleware socket is null)", () => { + const src = readFileSync( + join(import.meta.dirname, "../../src/server/authz/policies/management.ts"), + "utf8" + ); + assert.ok(src.includes('headers?.get?.("host")'), "requestPeerAddress must read the Host header"); +});