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"); +});