fix(management): authorize mcp:connect-only keys on loopback/LAN when requireLogin is enabled (#9159)

Closes #9159
Refs: base-red #9737
fix/9159-mcp-connect-require-login-lo
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-08 11:26:49 -03:00
committed by GitHub
parent 3cae1b1480
commit 9c343237d3
2 changed files with 69 additions and 1 deletions

View File

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

View File

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