fix(authz): derive LOCAL_ONLY locality from Host header (middleware has no socket IP)

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.
This commit is contained in:
diegosouzapw
2026-05-30 18:20:39 -03:00
parent 270c2eb925
commit 6b0e89fb42
2 changed files with 20 additions and 1 deletions

View File

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

View File

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