Spaces:
Runtime error
Runtime error
File size: 12,432 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 | /**
* 3-tier route guard constants and helpers.
*
* Tier 1 β LOCAL_ONLY: accessible only from loopback. These routes spawn
* child processes; exposing them to non-local traffic is a known CVE class
* (GHSA-fhh6-4qxv-rpqj). Blocked unconditionally regardless of auth state.
*
* Carve-out: paths matching the live manage-scope bypass list (DB-stored,
* read via `getAuthzBypassSnapshot()`) MAY also be accessed from
* non-loopback if and only if the request carries an API key with the
* `manage` scope (or an authenticated dashboard session β see
* `policies/management.ts`). The bypass is opt-in per prefix and can be
* killed globally via the `localOnlyManageScopeBypassEnabled` setting.
* Unauthenticated requests to bypassable paths are still rejected with
* 403 LOCAL_ONLY.
*
* Tier 2 β ALWAYS_PROTECTED: auth is always required, even when
* requireLogin=false. Covers destructive / irreversible operations.
*
* Tier 3 β MANAGEMENT (default): auth required, but bypassed when
* requireLogin=false (existing behaviour).
*/
import { getAuthzBypassSnapshot } from "@/lib/config/runtimeSettings";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/mcp/",
"/api/cli-tools/runtime/",
"/api/services/", // T-10: embedded service lifecycle (spawn child processes)
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
"/api/copilot/", // unauthenticated LLM driver β CLI-only by default; admins can opt-in to remote access via manage-scope bypass
"/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17)
"/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
"/api/plugins", // bare path: GET list + POST install also trigger plugin loading
"/api/system/version", // auto-update: spawns git checkout + npm install β RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
"/api/db-backups/exportAll", // spawns tar for export archive (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
"/api/local/", // T-12: 1-click local service launchers (Redis today; spawns podman/docker) β loopback-enforced by isLocalRequestAllowed() in src/lib/security/localEndpoints.ts (Hard Rules #15 + #17)
"/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17)
"/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17)
"/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds β RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable.
];
/**
* LOCAL_ONLY routes whose spawn-capable segment sits AFTER a dynamic path
* parameter, so a flat prefix in `LOCAL_ONLY_API_PREFIXES` cannot target them
* without over-broadening (e.g. locking the entire `/api/providers/` subtree,
* which remote dashboards legitimately use for provider CRUD). These are matched
* by regex instead.
*
* - `POST /api/providers/{id}/login` launches a headful Playwright Chromium
* (a child process) to drive a web-cookie login. Loopback enforcement must
* happen unconditionally before any auth check (Hard Rules #15 + #17), so a
* leaked JWT via tunnel cannot trigger a browser spawn.
*/
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
/^\/api\/providers\/[^/]+\/login\/?$/,
];
/**
* Compile-time deny-list: route prefixes that can spawn arbitrary local
* subprocesses on behalf of the caller. These MUST NEVER appear in the
* manage-scope bypass list β regardless of DB state β because reaching them
* from non-loopback would re-introduce the GHSA-fhh6-4qxv-rpqj surface that
* the LOCAL_ONLY tier exists to close.
*
* Enforced at two layers:
* 1. zod schema (`settingsSchemas.ts`): rejects `PATCH /api/settings` with
* error code `BYPASS_PREFIX_NOT_ALLOWED` if any entry in
* `localOnlyManageScopeBypassPrefixes` falls inside this set.
* 2. runtime (`isLocalOnlyBypassableByManageScope` below): even if a
* malformed DB row somehow claims a spawn-capable path is bypassable,
* the policy still refuses to honour it.
*/
export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/api/cli-tools/runtime/",
"/api/services/", // T-10: can run npm install + spawn node processes
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
"/api/local/", // T-12: 1-click local service launchers (Redis today) β must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
"/api/headroom/start", // spawns headroom-ai python CLI β must never be bypassable (Hard Rules #15 + #17)
"/api/headroom/stop", // kills tracked PID β must never be bypassable (Hard Rules #15 + #17)
];
/**
* Compile-time default of the manage-scope bypass list. Kept as an exported
* constant so the Settings inventory page (and audit code) can render the
* "available bypassable prefixes" choices independent of current DB state.
*
* The RUNTIME decision in `isLocalOnlyBypassableByManageScope` does NOT
* consult this constant β it reads `getAuthzBypassSnapshot().prefixes`,
* which is hot-reloaded on every settings PATCH.
*/
export const LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES: ReadonlyArray<string> = ["/api/mcp/"];
export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
"/api/shutdown",
"/api/providers/health-autopilot/actions",
"/api/settings/database",
];
export function isLoopbackHost(hostHeader: string | null): boolean {
if (!hostHeader) return false;
let host = hostHeader.trim();
if (host.startsWith("[")) {
// IPv6 literal: [::1] or [::1]:port
const bracketEnd = host.indexOf("]");
host = bracketEnd >= 0 ? host.slice(1, bracketEnd) : host.slice(1);
} else if ((host.match(/:/g) || []).length === 1) {
// IPv4 / hostname with a single :port β strip it. A bare IPv6 address
// ("::1", "::ffff:127.0.0.1") has multiple colons and must stay intact
// (splitting on ":" would mangle it to "" and miss the loopback match).
host = host.split(":")[0];
}
host = host.replace(/^::ffff:/i, "");
return LOOPBACK_HOSTS.has(host.toLowerCase());
}
/**
* Classify a resolved peer IP into the locality tiers the authz layer cares
* about. `null`/unknown β "remote" (fail closed). Used by the pipeline to stamp
* a trusted locality marker that route handlers read without re-deriving it
* from the spoofable Host header.
*/
export function classifyHostLocality(ip: string | null): "loopback" | "lan" | "remote" {
if (!ip) return "remote";
if (isLoopbackHost(ip)) return "loopback";
if (isPrivateLanHost(ip)) return "lan";
return "remote";
}
/**
* Private-LAN ranges (RFC 1918 IPv4 + IPv6 ULA/link-local). Matched against the
* real socket peer address (NOT the spoofable Host header), so a public-internet
* client β which presents a public source IP β never matches.
*/
const PRIVATE_LAN_PATTERNS: ReadonlyArray<RegExp> = [
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^192\.168\.\d{1,3}\.\d{1,3}$/,
/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/,
/^f[cd][0-9a-f]{2}:/i, // IPv6 ULA fc00::/7
/^fe80:/i, // IPv6 link-local
];
/**
* True when the peer address is a private-LAN address. Used to widen the
* LOCAL_ONLY tier to a trusted private network (owner-authorized 2026-05-30 for
* a LAN-deployed instance). Loopback-only surfaces that do NOT use this (e.g.
* the CLI-token path) remain strictly loopback.
*/
export function isPrivateLanHost(hostHeader: string | null): boolean {
if (!hostHeader) return false;
let host = hostHeader.trim();
if (host.startsWith("[")) {
const bracketEnd = host.indexOf("]");
host = bracketEnd >= 0 ? host.slice(1, bracketEnd) : host.slice(1);
}
host = host.replace(/^::ffff:/i, "");
// Strip :port only for IPv4 / hostname (a lone colon); leave IPv6 intact.
if ((host.match(/:/g) || []).length === 1) host = host.split(":")[0];
host = host.toLowerCase();
return PRIVATE_LAN_PATTERNS.some((re) => re.test(host));
}
/**
* Paths that are LOCAL_ONLY for all write methods but may be accessed from
* non-loopback clients when the request method is GET, HEAD, or OPTIONS.
*
* Rule: a path belongs here only when the read methods perform NO child-process
* spawn and expose NO privileged mutation β only the write methods do.
*
* Current exemptions:
* /api/system/version β GET reads package.json + npm registry; only POST
* triggers the auto-update flow (spawns git checkout + npm install + pm2).
* Hard Rules #15/#17 still apply to POST.
*/
export const LOCAL_ONLY_API_GET_EXEMPTIONS: ReadonlySet<string> = new Set([
"/api/system/version",
]);
/** Safe HTTP methods that can be exempted for read-only paths. */
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* Returns true when `path` is a local-only route that must be blocked from
* non-loopback / non-LAN callers.
*
* @param path Normalized request path (e.g. "/api/mcp/sse").
* @param method Optional HTTP method. When provided and the method is a safe
* read-only method (GET/HEAD/OPTIONS) AND the path exactly
* matches an entry in `LOCAL_ONLY_API_GET_EXEMPTIONS`, this
* function returns false β i.e. the path is NOT local-only for
* that specific safe method. With no method argument (e.g.
* from security-scan scripts that test paths without a method),
* the function returns true (safe default) to preserve the
* conservative classification used by `check-route-guard-membership`.
*/
export function isLocalOnlyPath(path: string, method?: string): boolean {
// Method-aware GET exemption: only exact-match paths in the exemption set
// are eligible; prefix/wildcard matching is intentionally NOT used to avoid
// accidentally opening sub-paths of a spawn-capable route.
if (method && SAFE_METHODS.has(method.toUpperCase()) && LOCAL_ONLY_API_GET_EXEMPTIONS.has(path)) {
return false;
}
return (
LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p)) ||
LOCAL_ONLY_API_PATTERNS.some((re) => re.test(path))
);
}
/**
* Runtime predicate consulted by the management policy on every non-loopback
* request to a LOCAL_ONLY path. Reads the live snapshot:
* - returns false if the global kill-switch is off
* (`localOnlyManageScopeBypassEnabled === false`),
* - returns true iff `path` matches one of the live bypass prefixes AND
* that prefix is not in `SPAWN_CAPABLE_PREFIXES` (defence-in-depth: the
* zod schema already rejects spawn-capable entries, but a malformed DB
* row should not be able to grant a bypass).
*
* O(1) (no I/O, no async). Hot-reload SLA: <50 ms β satisfied structurally.
*/
export function isLocalOnlyBypassableByManageScope(path: string): boolean {
const snapshot = getAuthzBypassSnapshot();
if (!snapshot.enabled) return false;
return snapshot.prefixes.some((p) => {
// Defence-in-depth: reject a bypass prefix that is the same as, child of,
// OR PARENT of any spawn-capable prefix. The parent case catches e.g.
// `/api/cli-tools/` (parent of `/api/cli-tools/runtime/`) β a request to
// `/api/cli-tools/runtime/foo` would otherwise satisfy `path.startsWith(p)`
// and reach the spawn-capable surface without a loopback check.
if (
SPAWN_CAPABLE_PREFIXES.some(
(spawn) => p === spawn || p.startsWith(spawn) || spawn.startsWith(p)
)
) {
return false;
}
return path === p || path.startsWith(p);
});
}
export function isAlwaysProtectedPath(path: string): boolean {
return ALWAYS_PROTECTED_API_PATHS.some((p) => path === p || path.startsWith(p));
}
|