62 lines
3.4 KiB
TypeScript
62 lines
3.4 KiB
TypeScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
isLocalOnlyPath,
|
|
isLocalOnlyBypassableByManageScope,
|
|
} from "../../../src/server/authz/routeGuard.ts";
|
|
|
|
// ─── T-12 (#3932 PR-3): /api/local/ is local-only ────────────────────────
|
|
|
|
test("isLocalOnlyPath: /api/local/ prefix is local-only (T-12, #3932)", () => {
|
|
// 1-click local service launchers (Redis today) spawn podman/docker — must
|
|
// be loopback-enforced before any auth check, same as /api/mcp/.
|
|
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
|
|
assert.equal(isLocalOnlyPath("/api/local/redis/stop"), true);
|
|
assert.equal(isLocalOnlyPath("/api/local/redis/status"), true);
|
|
assert.equal(isLocalOnlyPath("/api/local/"), true);
|
|
// Future /api/local/* sub-paths must also be classified — prefix is generic.
|
|
assert.equal(isLocalOnlyPath("/api/local/postgres/start"), true);
|
|
assert.equal(isLocalOnlyPath("/api/local/ollama/status"), true);
|
|
});
|
|
|
|
test("isLocalOnlyPath: /api/local* does NOT match the bare /api/localifications path", () => {
|
|
// Regression guard: the prefix must end with "/" to avoid over-broadening.
|
|
// (We don't have such a route today, but if /api/localization ever appears,
|
|
// it should NOT be loopback-enforced just because it shares a prefix.)
|
|
assert.equal(isLocalOnlyPath("/api/localization"), false);
|
|
assert.equal(isLocalOnlyPath("/api/localhost-check"), false);
|
|
});
|
|
|
|
// ─── /api/oauth/cursor/auto-import is local-only (spawns `which cursor`) ──
|
|
|
|
test("isLocalOnlyPath: /api/oauth/cursor/auto-import is local-only (spawns child process)", () => {
|
|
// The Cursor auto-import route runs `execFile("which", ["cursor"])` to verify a
|
|
// local Cursor install before importing its credentials — a child-process spawn
|
|
// that must be loopback-enforced BEFORE any auth check (Hard Rules #15/#17), so a
|
|
// leaked JWT via tunnel cannot reach the spawn. Found by check:route-guard-membership.
|
|
assert.equal(isLocalOnlyPath("/api/oauth/cursor/auto-import"), true);
|
|
});
|
|
|
|
test("isLocalOnlyPath: the rest of /api/oauth/ stays remote-reachable (no over-broadening)", () => {
|
|
// Only the spawn-capable auto-import path is loopback-locked. The rest of the OAuth
|
|
// surface (browser redirect / callback flows) MUST remain reachable remotely — a
|
|
// flat /api/oauth/ prefix would wrongly lock the whole OAuth subtree.
|
|
assert.equal(isLocalOnlyPath("/api/oauth/cursor"), false);
|
|
assert.equal(isLocalOnlyPath("/api/oauth/cursor/callback"), false);
|
|
assert.equal(isLocalOnlyPath("/api/oauth/anthropic/callback"), false);
|
|
});
|
|
|
|
test("isLocalOnlyBypassableByManageScope: /api/local/ is NOT bypassable (defence in depth)", () => {
|
|
// The kill-switch path. Even if a DB row tries to whitelist /api/local/ via
|
|
// the manage-scope bypass list, the runtime predicate must reject it because
|
|
// /api/local/ is in SPAWN_CAPABLE_PREFIXES.
|
|
//
|
|
// The predicate reads from runtime settings; here we exercise the
|
|
// defence-in-depth clause directly by checking the relevant invariant:
|
|
// /api/local/ must be in the same spawn-capable set as /api/cli-tools/runtime/.
|
|
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
|
|
// Same-origin false-positive guard: ensure /api/local is treated like every
|
|
// other spawn-capable prefix (no whitelist carve-out).
|
|
assert.equal(isLocalOnlyBypassableByManageScope("/api/local/redis/start"), false);
|
|
});
|