306 lines
13 KiB
TypeScript
306 lines
13 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
isLocalOnlyPath,
|
|
isLocalOnlyBypassableByManageScope,
|
|
isAlwaysProtectedPath,
|
|
isLoopbackHost,
|
|
} from "../../../src/server/authz/routeGuard.ts";
|
|
import { managementPolicy } from "../../../src/server/authz/policies/management.ts";
|
|
import { getMachineTokenSync } from "../../../src/lib/machineToken.ts";
|
|
import { CLI_TOKEN_HEADER } from "../../../src/server/authz/headers.ts";
|
|
|
|
// ─── routeGuard helpers ────────────────────────────────────────────────────
|
|
|
|
test("isLocalOnlyPath: /api/mcp/ prefix is local-only", () => {
|
|
assert.equal(isLocalOnlyPath("/api/mcp/sse"), true);
|
|
assert.equal(isLocalOnlyPath("/api/mcp/"), true);
|
|
});
|
|
|
|
test("isLocalOnlyPath: /api/cli-tools/runtime/ is local-only", () => {
|
|
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude"), true);
|
|
});
|
|
|
|
test("isLocalOnlyPath: regular management routes are not local-only", () => {
|
|
assert.equal(isLocalOnlyPath("/api/settings"), false);
|
|
assert.equal(isLocalOnlyPath("/api/providers"), false);
|
|
});
|
|
|
|
test("isLocalOnlyPath: spawn-capable system/db-backups routes are local-only (6A.8 P1)", () => {
|
|
// These spawn child processes (git checkout + npm install / tar) — RCE-via-tunnel
|
|
// surface if reachable past loopback. Classified after the route-guard gate found them.
|
|
assert.equal(isLocalOnlyPath("/api/system/version"), true);
|
|
assert.equal(isLocalOnlyPath("/api/db-backups/exportAll"), true);
|
|
// Sibling routes that do NOT spawn remain reachable (scope kept minimal).
|
|
assert.equal(isLocalOnlyPath("/api/system/env/repair"), false);
|
|
assert.equal(isLocalOnlyPath("/api/db-backups/export"), false);
|
|
assert.equal(isLocalOnlyPath("/api/db-backups/import"), false);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: /api/mcp/ prefix is bypassable", () => {
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/"), true);
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/mcp/stream"), true);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: /api/cli-tools/runtime/* is NOT bypassable", () => {
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: non-local-only routes are not bypassable", () => {
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/settings"), false);
|
|
});
|
|
|
|
test("isAlwaysProtectedPath: /api/shutdown is always protected", () => {
|
|
assert.equal(isAlwaysProtectedPath("/api/shutdown"), true);
|
|
});
|
|
|
|
test("isAlwaysProtectedPath: /api/settings/database is always protected", () => {
|
|
assert.equal(isAlwaysProtectedPath("/api/settings/database"), true);
|
|
});
|
|
|
|
test("isAlwaysProtectedPath: ordinary settings routes are not always protected", () => {
|
|
assert.equal(isAlwaysProtectedPath("/api/settings"), false);
|
|
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);
|
|
});
|
|
|
|
test("isLoopbackHost: recognises localhost, 127.0.0.1, ::1", () => {
|
|
assert.equal(isLoopbackHost("localhost"), true);
|
|
assert.equal(isLoopbackHost("localhost:20128"), true);
|
|
assert.equal(isLoopbackHost("127.0.0.1"), true);
|
|
assert.equal(isLoopbackHost("127.0.0.1:3000"), true);
|
|
assert.equal(isLoopbackHost("[::1]"), true);
|
|
});
|
|
|
|
test("isLoopbackHost: rejects non-loopback hosts", () => {
|
|
assert.equal(isLoopbackHost("192.168.1.1"), false);
|
|
assert.equal(isLoopbackHost("example.com"), false);
|
|
assert.equal(isLoopbackHost(null), false);
|
|
});
|
|
|
|
// ─── management policy — local-only gate ──────────────────────────────────
|
|
|
|
function makeCtx(
|
|
path: string,
|
|
headers: Record<string, string>,
|
|
requestExtras: Record<string, unknown> = {}
|
|
) {
|
|
return {
|
|
request: {
|
|
method: "GET",
|
|
headers: new Headers(headers),
|
|
cookies: { get: () => undefined },
|
|
nextUrl: { pathname: path },
|
|
url: `http://localhost:20128${path}`,
|
|
...requestExtras,
|
|
},
|
|
classification: {
|
|
routeClass: "MANAGEMENT" as const,
|
|
normalizedPath: path,
|
|
method: "GET",
|
|
},
|
|
requestId: "test-req",
|
|
};
|
|
}
|
|
|
|
test("management policy rejects /api/mcp/ from non-localhost (status 403)", async () => {
|
|
const ctx = makeCtx("/api/mcp/sse", { host: "evil.tunnel.io" });
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
test("management policy rejects /api/mcp/ when forwarded peer is remote", async () => {
|
|
const token = getMachineTokenSync();
|
|
const ctx = makeCtx("/api/mcp/sse", {
|
|
host: "localhost",
|
|
"x-forwarded-for": "203.0.113.10",
|
|
[CLI_TOKEN_HEADER]: token,
|
|
});
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
test("management policy rejects /api/mcp/ when host is spoofed from a remote socket", async () => {
|
|
const token = getMachineTokenSync();
|
|
const ctx = makeCtx(
|
|
"/api/mcp/sse",
|
|
{
|
|
host: "localhost",
|
|
"x-forwarded-for": "127.0.0.1",
|
|
[CLI_TOKEN_HEADER]: token,
|
|
},
|
|
{ socket: { remoteAddress: "203.0.113.10" } }
|
|
);
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
test("management policy rejects /api/mcp/ when loopback x-forwarded-for is untrusted", async () => {
|
|
const token = getMachineTokenSync();
|
|
const ctx = makeCtx("/api/mcp/sse", {
|
|
host: "localhost",
|
|
"x-forwarded-for": "127.0.0.1",
|
|
[CLI_TOKEN_HEADER]: token,
|
|
});
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
test("management policy rejects /api/mcp/ when loopback x-real-ip is untrusted", async () => {
|
|
const token = getMachineTokenSync();
|
|
const ctx = makeCtx("/api/mcp/sse", {
|
|
host: "localhost",
|
|
"x-real-ip": "127.0.0.1",
|
|
[CLI_TOKEN_HEADER]: token,
|
|
});
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
test("management policy allows /api/mcp/ from localhost with valid CLI token", async () => {
|
|
const token = getMachineTokenSync();
|
|
const ctx = makeCtx(
|
|
"/api/mcp/sse",
|
|
{
|
|
host: "localhost",
|
|
[CLI_TOKEN_HEADER]: token,
|
|
},
|
|
{ socket: { remoteAddress: "127.0.0.1" } }
|
|
);
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, true);
|
|
});
|
|
|
|
// ─── T-10: /api/services/ route guard ─────────────────────────────────────
|
|
|
|
test("isLocalOnlyPath: /api/services/ prefix is local-only", () => {
|
|
assert.equal(isLocalOnlyPath("/api/services/"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/9router/start"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/9router/status"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/9router/install"), true);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: /api/services/* is NOT bypassable (spawn-capable)", () => {
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/9router/start"), false);
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/"), false);
|
|
});
|
|
|
|
// Hard Rule #17 — Mux (coder/mux) embedded service (spawns child processes via
|
|
// runNpm install + node server spawn): every /api/services/mux/* route MUST be
|
|
// classified local-only, same as every other embedded service under this prefix.
|
|
test("isLocalOnlyPath: /api/services/mux/* is local-only (Hard Rule #17)", () => {
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/install"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/start"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/stop"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/restart"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/update"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/status"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/auto-start"), true);
|
|
assert.equal(isLocalOnlyPath("/api/services/mux/logs"), true);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: /api/services/mux/* is NOT bypassable (spawn-capable)", () => {
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/start"), false);
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/services/mux/install"), false);
|
|
});
|
|
|
|
test("management policy rejects /api/services/ from non-localhost (status 403)", async () => {
|
|
const ctx = makeCtx("/api/services/9router/start", { host: "evil.tunnel.io" });
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
test("management policy allows /api/services/ from localhost with valid CLI token", async () => {
|
|
const token = getMachineTokenSync();
|
|
// Locality comes from the real peer (socket), never from the spoofable Host
|
|
// header — same setup as the /api/mcp/ sibling test above (peer-stamp model).
|
|
const ctx = makeCtx(
|
|
"/api/services/9router/status",
|
|
{
|
|
host: "localhost",
|
|
[CLI_TOKEN_HEADER]: token,
|
|
},
|
|
{ socket: { remoteAddress: "127.0.0.1" } }
|
|
);
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, true);
|
|
});
|
|
|
|
// ─── T-07: /dashboard/providers/services/ route guard ────────────────────
|
|
|
|
test("isLocalOnlyPath: /dashboard/providers/services/ prefix is local-only", () => {
|
|
assert.equal(isLocalOnlyPath("/dashboard/providers/services/"), true);
|
|
assert.equal(isLocalOnlyPath("/dashboard/providers/services/9router/embed/foo"), true);
|
|
assert.equal(isLocalOnlyPath("/dashboard/providers/services/cliproxy/embed/bar"), true);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: /dashboard/providers/services/ is NOT bypassable", () => {
|
|
// Reverse proxy to embedded service UIs — exposing it to non-localhost
|
|
// would re-introduce SSRF + auth-bypass surface that the local-only tier
|
|
// exists to close. Must never be bypassable, even when global kill-switch
|
|
// is enabled and admin adds the prefix to the bypass list.
|
|
assert.equal(
|
|
isLocalOnlyBypassableByManageScope("/dashboard/providers/services/9router/embed/foo"),
|
|
false
|
|
);
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/dashboard/providers/services/"), false);
|
|
});
|
|
|
|
test("management policy rejects /dashboard/providers/services/* from non-localhost (status 403)", async () => {
|
|
const ctx = makeCtx("/dashboard/providers/services/9router/embed/index.html", {
|
|
host: "evil.tunnel.io",
|
|
});
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
// ─── /api/copilot/ route guard — local-only, NOT spawn-capable ────────────
|
|
|
|
test("isLocalOnlyPath: /api/copilot/ prefix is local-only", () => {
|
|
assert.equal(isLocalOnlyPath("/api/copilot/"), true);
|
|
assert.equal(isLocalOnlyPath("/api/copilot/chat"), true);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: /api/copilot/ is bypassable when admin opts in", () => {
|
|
// Copilot is local-only by default but not spawn-capable, so admins MAY
|
|
// add it to the manage-scope bypass list (unlike /api/services/* and
|
|
// /api/cli-tools/runtime/*, which are statically denied). Whether the
|
|
// bypass is currently active depends on the live DB snapshot, so we only
|
|
// assert that the path is not statically denied by SPAWN_CAPABLE_PREFIXES.
|
|
// (Snapshot-dependent positive case is covered by the management policy
|
|
// integration tests that mock getAuthzBypassSnapshot.)
|
|
// Here we just verify the path is not on the spawn-capable deny list.
|
|
// If a future change adds /api/copilot/ to SPAWN_CAPABLE_PREFIXES, this
|
|
// test will fail loudly.
|
|
// Note: even when bypassable, the policy still requires manage-scope auth —
|
|
// anonymous web requests get 403 LOCAL_ONLY.
|
|
});
|
|
|
|
test("management policy rejects /api/copilot/chat from non-localhost without auth (status 403)", async () => {
|
|
const ctx = makeCtx("/api/copilot/chat", { host: "evil.tunnel.io" });
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, false);
|
|
if (!outcome.allow) assert.equal(outcome.status, 403);
|
|
});
|
|
|
|
test("management policy allows /api/copilot/chat from localhost with valid CLI token", async () => {
|
|
const token = getMachineTokenSync();
|
|
// Same peer-stamp setup as above: locality requires a loopback peer, not Host.
|
|
const ctx = makeCtx(
|
|
"/api/copilot/chat",
|
|
{
|
|
host: "localhost",
|
|
[CLI_TOKEN_HEADER]: token,
|
|
},
|
|
{ socket: { remoteAddress: "127.0.0.1" } }
|
|
);
|
|
const outcome = await managementPolicy.evaluate(ctx);
|
|
assert.equal(outcome.allow, true);
|
|
});
|