diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index 10c810482a..07989c4d19 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -253,6 +253,39 @@ export const managementPolicy: RoutePolicy = { return allow({ kind: "management_key", id: "cli", label: "local-cli-token" }); } + // MCP path carve-out (#9159): accept mcp:connect, manage, or admin + // scope for /api/mcp/* from any origin (loopback, private LAN, or remote). + // Loopback/LAN requests skip the Tier 1 bypass gate above, so with + // requireLogin=true they would fall through to the generic API-key check + // which only accepts manage/admin -- rejecting mcp:connect-only keys. + // This carve-out mirrors the existing Tier 1 MCP check but without the + // locality guard, so it catches the loopback/LAN requests that the Tier 1 + // gate does not reach. + if (path.startsWith("/api/mcp/")) { + const apiKey = extractApiKey(ctx.request as unknown as Request, { allowUrl: false }); + if (apiKey) { + try { + if (await isValidApiKey(apiKey)) { + const meta = await getApiKeyMetadata(apiKey); + if (meta && hasMcpConnectOrManageScope(meta.scopes)) { + const grantedBy = meta.scopes.includes("admin") + ? "admin" + : meta.scopes.includes("manage") + ? "manage" + : "mcp-connect"; + return allow({ + kind: "management_key", + id: meta.id, + label: `api-key-${grantedBy}-scope-mcp-carve-out`, + }); + } + } + } catch { + return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable"); + } + } + } + // Tier 2: always-protected routes skip the requireLogin=false bypass. if (!isAlwaysProtectedPath(path) && !(await isAuthRequired(ctx.request))) { return allow({ kind: "anonymous", id: "anonymous", label: "auth-disabled" }); diff --git a/tests/unit/mcp-connect-scope.test.ts b/tests/unit/mcp-connect-scope.test.ts index cd1a6b0e49..35734a27ff 100644 --- a/tests/unit/mcp-connect-scope.test.ts +++ b/tests/unit/mcp-connect-scope.test.ts @@ -56,13 +56,14 @@ test.after(() => { else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL; }); -function mgmtCtx(headers: Headers, method = "GET", pathname = "/api/keys") { +function mgmtCtx(headers: Headers, method = "GET", pathname = "/api/keys", peerAddress?: string) { return { request: { method, headers, url: `http://localhost${pathname}`, nextUrl: { pathname }, + socket: peerAddress ? { remoteAddress: peerAddress } : undefined, }, classification: { routeClass: "MANAGEMENT" as const, @@ -268,3 +269,37 @@ test("mcp:connect key is still rejected for the non-bypassable /api/cli-tools/ru assert.equal(out.code, "LOCAL_ONLY"); } }); + +// ─── 7. #9159 — loopback/LAN mcp:connect with requireLogin ────────────────── + +test("#9159 mcp:connect-only key must pass /api/mcp/ from loopback when login is required", async () => { + await seedAuthRequired(); + const created = await apiKeysDb.createApiKey("mcp-loopback-only", "machine-mcp-loopback", [ + MCP_CONNECT_SCOPE, + ]); + const out = await managementPolicy.evaluate( + mgmtCtx( + new Headers({ authorization: `Bearer ${created.key}` }), + "POST", + "/api/mcp/stream", + "127.0.0.1" + ) + ); + assert.equal(out.allow, true, JSON.stringify(out)); +}); + +test("#9159 mcp:connect-only key must pass /api/mcp/ from private LAN when login is required", async () => { + await seedAuthRequired(); + const created = await apiKeysDb.createApiKey("mcp-lan-only", "machine-mcp-lan", [ + MCP_CONNECT_SCOPE, + ]); + const out = await managementPolicy.evaluate( + mgmtCtx( + new Headers({ authorization: `Bearer ${created.key}` }), + "POST", + "/api/mcp/stream", + "192.168.1.20" + ) + ); + assert.equal(out.allow, true, JSON.stringify(out)); +});