From fb83be93117b2ba408fc3d1ef0e4d7d937ce569c Mon Sep 17 00:00:00 2001 From: "M.M" Date: Thu, 21 May 2026 23:23:39 +0200 Subject: [PATCH] feat(authz): manage-scope API keys may reach /api/mcp/* from non-loopback (#2473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(authz): manage-scope API keys may reach /api/mcp/* from non-loopback — integrated into release/v3.8.2 --- README.md | 18 + SECURITY.md | 20 +- docs/architecture/AUTHZ_GUIDE.md | 6 +- docs/frameworks/MCP-SERVER.md | 20 + docs/security/ROUTE_GUARD_TIERS.md | 77 ++- .../api-manager/ApiManagerPageClient.tsx | 54 +- .../settings/components/AuthzSection.tsx | 471 ++++++++++++++++ .../settings/components/SecurityTab.tsx | 2 + src/app/api/settings/authz-inventory/route.ts | 156 ++++++ src/app/api/settings/route.ts | 235 +++++++- src/i18n/messages/en.json | 60 +- src/lib/compliance/index.ts | 36 +- src/lib/config/runtimeSettings.ts | 74 ++- src/lib/db/apiKeys.ts | 111 +++- src/lib/db/settings.ts | 8 + src/server/authz/policies/management.ts | 75 ++- src/server/authz/routeGuard.ts | 71 +++ src/shared/constants/sidebarVisibility.ts | 8 + src/shared/validation/settingsSchemas.ts | 513 ++++++++++-------- src/types/settings.ts | 6 + tests/unit/_mocks/settings.ts | 80 +++ tests/unit/api/authz-inventory.test.ts | 210 +++++++ tests/unit/api/settings-audit.test.ts | 307 +++++++++++ tests/unit/authz/management-policy.test.ts | 168 ++++++ tests/unit/authz/routeGuard.test.ts | 14 + tests/unit/db/api-keys.test.ts | 242 +++++++++ tests/unit/settings-route-password.test.ts | 5 +- tests/unit/settings/authz-bypass.test.ts | 286 ++++++++++ 28 files changed, 3015 insertions(+), 318 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx create mode 100644 src/app/api/settings/authz-inventory/route.ts create mode 100644 tests/unit/_mocks/settings.ts create mode 100644 tests/unit/api/authz-inventory.test.ts create mode 100644 tests/unit/api/settings-audit.test.ts create mode 100644 tests/unit/db/api-keys.test.ts create mode 100644 tests/unit/settings/authz-bypass.test.ts diff --git a/README.md b/README.md index 4427be4225..ef8f6b9c5d 100644 --- a/README.md +++ b/README.md @@ -1316,6 +1316,24 @@ omniroute --mcp curl http://localhost:20128/.well-known/agent.json ``` +### Remote MCP from a public hostname (v3.8.1+) + +`/api/mcp/*` is LOCAL_ONLY by default. To reach the remote MCP server through +a tunnel or reverse proxy, issue an API key with the `manage` scope (API +Manager → toggle **Management Access** on the key) and send it as a Bearer: + +```bash +curl -i \ + -H "Authorization: Bearer sk-…" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-client","version":"0"}}}' \ + https://your-public-host.example/api/mcp/stream +``` + +The carve-out is intentionally narrow — `/api/cli-tools/runtime/*` stays +strict-loopback regardless of scope. See [docs/security/ROUTE_GUARD_TIERS.md](docs/security/ROUTE_GUARD_TIERS.md#manage-scope-carve-out). + ### Key Environment Variables | Variable | Default | Purpose | diff --git a/SECURITY.md b/SECURITY.md index f5cd205afa..bda9d808aa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,15 +38,17 @@ Request → CORS → Authz pipeline (classify → policies → enforce) ### 🔐 Authentication & Authorization -| Feature | Implementation | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) | -| **API Key Auth** | HMAC-signed keys with CRC validation | -| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) | -| **Token Refresh** | Automatic OAuth token refresh before expiry | -| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments | -| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` | -| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` | +| Feature | Implementation | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) | +| **API Key Auth** | HMAC-signed keys with CRC validation | +| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) | +| **Token Refresh** | Automatic OAuth token refresh before expiry | +| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments | +| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` | +| **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` | +| **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS | +| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` | ### 🛡️ Encryption at Rest diff --git a/docs/architecture/AUTHZ_GUIDE.md b/docs/architecture/AUTHZ_GUIDE.md index 533e3509fa..e01b73d098 100644 --- a/docs/architecture/AUTHZ_GUIDE.md +++ b/docs/architecture/AUTHZ_GUIDE.md @@ -74,7 +74,7 @@ Each route class has a policy in `src/server/authz/policies/`: - **`publicPolicy`** (`policies/public.ts`) — always returns `allow({ kind: "anonymous", id: "anonymous" })`. - **`clientApiPolicy`** (`policies/clientApi.ts`) — extracts Bearer, validates via `validateApiKey()`. Falls through to anonymous if `REQUIRE_API_KEY != "true"`. Allows dashboard-session GET on `/api/v1/models` (used by the dashboard model catalog). -- **`managementPolicy`** (`policies/management.ts`) — accepts dashboard session, internal model-sync requests (matched against `/api/providers/[name]/(sync-models|models)`), or skips entirely if `isAuthRequired()` returns false. Returns 403 (`AUTH_001`) when a Bearer token is present but invalid, 401 otherwise. +- **`managementPolicy`** (`policies/management.ts`) — accepts dashboard session, internal model-sync requests (matched against `/api/providers/[name]/(sync-models|models)`), or skips entirely if `isAuthRequired()` returns false. Returns 403 (`AUTH_001`) when a Bearer token is present but invalid, 401 otherwise. Also enforces the route-guard tiers (LOCAL_ONLY / ALWAYS_PROTECTED) before any auth branch — see [Route Guard Tiers](../security/ROUTE_GUARD_TIERS.md). LOCAL_ONLY paths in `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` (today: `/api/mcp/`) may be accessed from non-loopback when the Bearer key carries the `manage` scope; all other LOCAL_ONLY paths remain strict-loopback regardless of scope. A successful policy returns `AuthSubject` with `kind ∈ { client_api_key, dashboard_session, management_key, anonymous }`. Downstream handlers can read it via `assertAuth(request, "CLIENT_API")` in `src/server/authz/assertAuth.ts` instead of re-running auth logic. @@ -177,6 +177,10 @@ Preset bundles (`MCP_SCOPE_PRESETS`): `readonly`, `full`, `monitor`, `agent`. Us The `/api/v1/agents/tasks/*` and `/api/resilience/model-cooldowns` endpoints **now require management auth** (commit `588a0333`). Clients previously sending a normal API key without the `manage` scope receive `403`. Migration: either issue the key the `manage` scope in the API Manager dashboard, or use a logged-in dashboard session. +## Behaviour Change — v3.8.1 + +`/api/mcp/*` (the remote MCP server) is still LOCAL_ONLY by default but now accepts non-loopback requests when the `Authorization: Bearer ` header carries the `manage` scope. The carve-out is gated explicitly per-path via `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` in `src/server/authz/routeGuard.ts`; the sibling LOCAL_ONLY prefix `/api/cli-tools/runtime/*` is intentionally NOT bypassable because it can spawn arbitrary subprocesses. Anonymous requests to `/api/mcp/*` from non-loopback continue to return `403 LOCAL_ONLY` — the default for any new LOCAL_ONLY path remains strict-loopback. See [Route Guard Tiers](../security/ROUTE_GUARD_TIERS.md#manage-scope-carve-out). + ## Testing - Unit tests: `tests/unit/authz/` — `classify.test.ts`, `pipeline.test.ts`, `client-api-policy.test.ts`, `management-policy.test.ts`, `public-policy.test.ts`. diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index e660923b30..9855d0b168 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -41,6 +41,26 @@ The MCP server exposes three transports, all backed by the same `createMcpServer The active HTTP transport (`sse` or `streamable-http`) is selected by the `mcpTransport` setting. Switching transports closes existing sessions on the other transport. +### Remote access (manage-scope bypass) + +`/api/mcp/*` is in the LOCAL_ONLY tier (`src/server/authz/routeGuard.ts`) — by default only loopback hosts (`localhost`, `127.0.0.1`, `::1`) can reach it. Since v3.8.1, non-loopback clients may connect if they present an `Authorization: Bearer ` whose key carries the `manage` scope. This is the only way to reach the remote MCP server through a tunnel, reverse proxy, or public hostname. + +```bash +# Grant manage scope: open the dashboard API Manager and toggle +# "Management Access" on the key, or POST scopes:["manage"] when creating. + +# Then connect from a remote MCP client: +curl -i \ + -H "Host: your-public-host.example" \ + -H "Authorization: Bearer sk-…" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"my-client","version":"0"}}}' \ + https://your-public-host.example/api/mcp/stream +``` + +A non-manage key (or no Bearer) returns `403 LOCAL_ONLY`. The sibling prefix `/api/cli-tools/runtime/*` is intentionally NOT bypassable — see [Route Guard Tiers — Manage-scope carve-out](../security/ROUTE_GUARD_TIERS.md#manage-scope-carve-out). + ## IDE Configuration See [MCP Client Configuration](../guides/SETUP_GUIDE.md#mcp-client-configuration) for Claude Desktop, diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index 382b55eb08..e9a459291c 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -4,30 +4,54 @@ All OmniRoute management API routes are classified into one of three protection tiers. Classification is static, defined in `src/server/authz/routeGuard.ts`, -and evaluated unconditionally on every request before any auth logic runs. +and evaluated before any other auth branch runs. ## Tiers ### Tier 1 — LOCAL_ONLY -**Enforced by:** `isLocalOnlyPath(path)` → loopback host check -**Bypass:** None — not overridable by JWT, CLI token, or `requireLogin=false` +**Enforced by:** `isLocalOnlyPath(path)` → loopback host check +**Bypass:** None by default. Narrow carve-out for paths in +`LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` when the request carries a valid +API key with the `manage` scope (see [Manage-scope carve-out](#manage-scope-carve-out)). These routes spawn child processes or execute runtime code. Exposing them to non-loopback traffic would allow an attacker who obtained a valid JWT (e.g., via a Cloudflared/Ngrok tunnel) to trigger process spawning — a known CVE class (GHSA-fhh6-4qxv-rpqj). -| Prefix | Reason | -| ------------------------- | -------------------------------------------------- | -| `/api/mcp/` | MCP server — spawns stdio bridges and SSE handlers | -| `/api/cli-tools/runtime/` | CLI tool runtime — executes plugin code | +| Prefix | Reason | Bypassable by `manage`? | +| ------------------------- | -------------------------------------------------- | ----------------------- | +| `/api/mcp/` | MCP server — spawns stdio bridges and SSE handlers | Yes | +| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No (strict-loopback) | **Response on violation:** `403 LOCAL_ONLY` +#### Manage-scope carve-out + +A subset of LOCAL_ONLY paths MAY also be accessed from non-loopback if and +only if the request carries an `Authorization: Bearer ` whose +metadata includes the `manage` scope (or `admin`). The carve-out is gated +explicitly per-path via `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` so the +default for any new LOCAL_ONLY path remains strict-loopback. Unauthenticated +requests and requests with non-manage keys are still rejected with +`403 LOCAL_ONLY`. + +Today the only bypassable prefix is `/api/mcp/`. `/api/cli-tools/runtime/` +is intentionally excluded because it can spawn arbitrary subprocesses, which +is the exact CVE class the LOCAL_ONLY tier exists to prevent. + +| Request | Path | Result | +| ------------------------------------------- | -------------------------- | ------------------- | +| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow | +| Non-loopback, Bearer without `manage` scope | `/api/mcp/*` | 403 LOCAL_ONLY | +| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY | +| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) | + ### Tier 2 — ALWAYS_PROTECTED -**Enforced by:** `isAlwaysProtectedPath(path)` → skip `requireLogin=false` bypass +**Enforced by:** `isAlwaysProtectedPath(path)` → skip `requireLogin=false` bypass **Bypass:** None when `requireLogin=false`; JWT always required These routes are destructive or irreversible. Allowing them in a "no-password" @@ -51,7 +75,10 @@ configured. CLI tokens can authenticate these routes (loopback + valid HMAC). ``` managementPolicy.evaluate(ctx) 1. isLocalOnlyPath(path)? - → not loopback → reject 403 LOCAL_ONLY + → loopback → fall through + → non-loopback, manage-scope Bearer + AND isLocalOnlyBypassableByManageScope → allow (management_key) + → otherwise → reject 403 LOCAL_ONLY 2. isInternalModelSyncRequest(ctx)? → allow (system) 3. hasValidCliToken(headers)? @@ -59,11 +86,17 @@ managementPolicy.evaluate(ctx) 4. isAlwaysProtectedPath(path) or requireLogin=true? → isDashboardSessionAuthenticated? → allow (dashboard_session) + → manage-scope Bearer on a non-bypassable path? + → allow (management_key) → reject 401/403 5. requireLogin=false? → allow (anonymous) ``` +Step 1's manage-scope branch is the only authenticated path that can satisfy a +LOCAL_ONLY route; the auth-backend failure mode returns 503 (not 403) so an +expired DB doesn't silently downgrade to "deny". + ## Adding a new spawn-capable route 1. Add the path prefix to `LOCAL_ONLY_API_PREFIXES` in @@ -71,16 +104,32 @@ managementPolicy.evaluate(ctx) 2. Add a test in `tests/unit/authz/routeGuard.test.ts` asserting that `isLocalOnlyPath()` returns true for the new prefix 3. **Never skip this step** — see Hard Rule #15 in `CLAUDE.md` +4. Decide: does this route ALSO belong in `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES`? + Default answer is **no**. Only opt-in when the route is safe to expose to a + manage-scope holder (i.e. does NOT spawn arbitrary user-controlled code). + +## Adding a manage-scope-bypassable path + +1. Confirm the route does not execute user-supplied code or commands. If it + does, stop — this carve-out is the wrong tool. +2. Append the prefix to `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` in + `src/server/authz/routeGuard.ts` +3. Add coverage in `tests/unit/authz/management-policy.test.ts` for all four + request shapes: no Bearer (403), manage Bearer (allow), non-manage Bearer + (403), and the per-prefix regression that `/api/cli-tools/runtime/*` stays + strict-loopback even with a manage Bearer. ## Files -| File | Purpose | -| ----------------------------------------- | ------------------------------ | -| `src/server/authz/routeGuard.ts` | Constants and helper functions | -| `src/server/authz/policies/management.ts` | Evaluation logic | -| `tests/unit/authz/routeGuard.test.ts` | Unit tests | +| File | Purpose | +| -------------------------------------------- | ------------------------------ | +| `src/server/authz/routeGuard.ts` | Constants and helper functions | +| `src/server/authz/policies/management.ts` | Evaluation logic | +| `tests/unit/authz/routeGuard.test.ts` | Unit tests for tier helpers | +| `tests/unit/authz/management-policy.test.ts` | Unit tests for evaluate() | ## See also - `docs/security/CLI_TOKEN.md` — CLI machine-ID token - `docs/architecture/AUTHZ_GUIDE.md` — full authorization pipeline +- `docs/frameworks/MCP-SERVER.md` — MCP server transports and scopes diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 2fc8c39429..887dbe6240 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -108,6 +108,7 @@ export default function ApiManagerPageClient() { const [loading, setLoading] = useState(true); const [showAddModal, setShowAddModal] = useState(false); const [newKeyName, setNewKeyName] = useState(""); + const [newKeyManageEnabled, setNewKeyManageEnabled] = useState(false); const [createdKey, setCreatedKey] = useState(null); const [editingKey, setEditingKey] = useState(null); const [showPermissionsModal, setShowPermissionsModal] = useState(false); @@ -255,7 +256,10 @@ export default function ApiManagerPageClient() { const res = await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: sanitizedName }), + body: JSON.stringify({ + name: sanitizedName, + scopes: newKeyManageEnabled ? ["manage"] : [], + }), }); const data = await res.json(); @@ -263,6 +267,7 @@ export default function ApiManagerPageClient() { setCreatedKey(data.key); await fetchData(); setNewKeyName(""); + setNewKeyManageEnabled(false); setShowAddModal(false); } else { setCreateError(data.error || t("failedCreateKey")); @@ -829,6 +834,7 @@ export default function ApiManagerPageClient() { onClose={() => { setShowAddModal(false); setNewKeyName(""); + setNewKeyManageEnabled(false); setNameError(null); setCreateError(null); }} @@ -851,6 +857,26 @@ export default function ApiManagerPageClient() { />

{t("keyNameDesc")}

+
+
+

{t("managementAccess")}

+

{t("managementAccessDesc")}

+
+ +
{createError && (
error @@ -862,6 +888,7 @@ export default function ApiManagerPageClient() { onClick={() => { setShowAddModal(false); setNewKeyName(""); + setNewKeyManageEnabled(false); setNameError(null); setCreateError(null); }} @@ -1539,31 +1566,6 @@ const PermissionsModal = memo(function PermissionsModal({ {keyIsBanned ? "Banned" : "Active"}
- {/* Management API Access Toggle */} -
-
-

{t("managementApiAccess")}

-

- Allow this key to call management routes (providers, combos, settings) via{" "} - Authorization: Bearer. Use for LLM agents only. -

-
- -
- {/* Expiration Date */}
diff --git a/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx b/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx new file mode 100644 index 0000000000..ae4092677c --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx @@ -0,0 +1,471 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge, Button, Card, Input, Modal, Toggle } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +type TierName = "LOCAL_ONLY" | "ALWAYS_PROTECTED" | "MANAGEMENT" | "CLIENT_API" | "PUBLIC"; + +interface TierEntry { + name: TierName; + prefixes: string[]; + description: string; + bypassable: boolean; +} + +interface InventoryPayload { + tiers: TierEntry[]; + bypassEnabled: boolean; + bypassPrefixes: string[]; + spawnCapablePrefixes: string[]; +} + +interface StatusMessage { + type: "success" | "error"; + message: string; +} + +type ErrorCode = + | "PASSWORD_REQUIRED" + | "PASSWORD_MISMATCH" + | "INSUFFICIENT_SCOPE" + | "BYPASS_PREFIX_NOT_ALLOWED" + | "GENERIC"; + +// ─── helpers ────────────────────────────────────────────────────────────── + +function tierBadgeVariant( + tier: TierName, + prefix: string, + spawnCapable: ReadonlyArray, + bypassPrefixes: ReadonlyArray, + bypassEnabled: boolean +): { variant: "default" | "success" | "warning" | "error" | "info"; key: string } { + if (spawnCapable.some((p) => prefix === p || prefix.startsWith(p))) { + return { variant: "error", key: "spawn_capable" }; + } + if (tier === "LOCAL_ONLY") { + const isLive = bypassEnabled && bypassPrefixes.some((p) => p === prefix); + return isLive ? { variant: "warning", key: "bypassable" } : { variant: "info", key: "strict" }; + } + if (tier === "ALWAYS_PROTECTED") return { variant: "error", key: "always_protected" }; + if (tier === "PUBLIC") return { variant: "default", key: "public" }; + return { variant: "info", key: "auth_required" }; +} + +function parseErrorCode(payload: unknown): ErrorCode { + if (!payload || typeof payload !== "object") return "GENERIC"; + const errorField = (payload as { error?: unknown }).error; + if (typeof errorField === "string") { + if (errorField.toLowerCase().includes("manage")) return "INSUFFICIENT_SCOPE"; + return "GENERIC"; + } + if (errorField && typeof errorField === "object") { + const code = (errorField as { code?: unknown }).code; + if ( + code === "PASSWORD_REQUIRED" || + code === "PASSWORD_MISMATCH" || + code === "INSUFFICIENT_SCOPE" || + code === "BYPASS_PREFIX_NOT_ALLOWED" + ) { + return code; + } + // Zod validation surface (T-011 emits BYPASS_PREFIX_NOT_ALLOWED inside + // `error.details[].message`). + const details = (errorField as { details?: unknown }).details; + if (Array.isArray(details)) { + for (const d of details) { + const m = (d as { message?: unknown }).message; + if (typeof m === "string" && m.includes("BYPASS_PREFIX_NOT_ALLOWED")) { + return "BYPASS_PREFIX_NOT_ALLOWED"; + } + } + } + } + return "GENERIC"; +} + +// ─── component ──────────────────────────────────────────────────────────── + +export default function AuthzSection() { + const t = useTranslations("settings"); + const [inventory, setInventory] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + // Draft state — only persisted on Save (security-impacting fields require + // a password re-prompt before the PATCH is fired). + const [draftEnabled, setDraftEnabled] = useState(true); + const [draftPrefixes, setDraftPrefixes] = useState([]); + const [newPrefixInput, setNewPrefixInput] = useState(""); + + const [passwordModalOpen, setPasswordModalOpen] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [status, setStatus] = useState(null); + + const loadInventory = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + const res = await fetch("/api/settings/authz-inventory", { + credentials: "include", + }); + if (!res.ok) { + const code = parseErrorCode(await res.json().catch(() => null)); + setLoadError(t(`authz.error.${code}`)); + return; + } + const data = (await res.json()) as InventoryPayload; + setInventory(data); + setDraftEnabled(data.bypassEnabled); + setDraftPrefixes([...data.bypassPrefixes]); + } catch { + setLoadError(t("authz.loadError")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadInventory(); + }, [loadInventory]); + + const dirty = useMemo(() => { + if (!inventory) return false; + if (draftEnabled !== inventory.bypassEnabled) return true; + if (draftPrefixes.length !== inventory.bypassPrefixes.length) return true; + const a = [...draftPrefixes].sort(); + const b = [...inventory.bypassPrefixes].sort(); + return a.some((p, i) => p !== b[i]); + }, [draftEnabled, draftPrefixes, inventory]); + + const spawnCapable = inventory?.spawnCapablePrefixes ?? []; + + const isSpawnCapable = useCallback( + (prefix: string) => spawnCapable.some((p) => prefix === p || prefix.startsWith(p)), + [spawnCapable] + ); + + const handleAddPrefix = () => { + const trimmed = newPrefixInput.trim(); + if (!trimmed) return; + if (draftPrefixes.includes(trimmed)) { + setNewPrefixInput(""); + return; + } + setDraftPrefixes((prev) => [...prev, trimmed]); + setNewPrefixInput(""); + }; + + const handleRemovePrefix = (prefix: string) => { + if (isSpawnCapable(prefix)) return; + setDraftPrefixes((prev) => prev.filter((p) => p !== prefix)); + }; + + const handleSaveRequest = () => { + if (!dirty) return; + setCurrentPassword(""); + setStatus(null); + setPasswordModalOpen(true); + }; + + const handleSubmit = async () => { + if (!currentPassword) { + setStatus({ type: "error", message: t("authz.error.PASSWORD_REQUIRED") }); + return; + } + setSubmitting(true); + setStatus(null); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + localOnlyManageScopeBypassEnabled: draftEnabled, + localOnlyManageScopeBypassPrefixes: draftPrefixes, + currentPassword, + }), + }); + if (!res.ok) { + const payload = await res.json().catch(() => null); + const code = parseErrorCode(payload); + setStatus({ type: "error", message: t(`authz.error.${code}`) }); + return; + } + setStatus({ type: "success", message: t("authz.saved") }); + setPasswordModalOpen(false); + setCurrentPassword(""); + await loadInventory(); + } catch { + setStatus({ type: "error", message: t("authz.error.GENERIC") }); + } finally { + setSubmitting(false); + } + }; + + // ─── render ───────────────────────────────────────────────────────────── + + if (loading) { + return ( + +
+
+ +
+

{t("authz.title")}

+
+

{t("authz.loading")}

+
+ ); + } + + if (loadError || !inventory) { + return ( + +
+
+ +
+

{t("authz.title")}

+
+

{loadError ?? t("authz.loadError")}

+
+ ); + } + + return ( + <> + {/* Authz header + tier inventory */} + +
+
+ +
+
+

{t("authz.title")}

+

{t("authz.description")}

+
+
+ +
+ {inventory.tiers.map((tier) => ( +
+
+
+

{t(`authz.tier.${tier.name}`)}

+ {tier.bypassable && ( + + {t("authz.badge.bypassable")} + + )} +
+
+

{tier.description}

+
    + {tier.prefixes.map((prefix) => { + const badge = tierBadgeVariant( + tier.name, + prefix, + inventory.spawnCapablePrefixes, + inventory.bypassPrefixes, + inventory.bypassEnabled + ); + return ( +
  • + {prefix} + + {t(`authz.badge.${badge.key}`)} + +
  • + ); + })} +
+
+ ))} +
+
+ + {/* Bypass policy editor */} + +
+
+ +
+

{t("authz.bypass.section")}

+
+ +
+
+

{t("authz.bypass.kill_switch.label")}

+

{t("authz.bypass.kill_switch.desc")}

+
+ setDraftEnabled((prev) => !prev)} + disabled={submitting} + /> +
+ +
+
+

{t("authz.bypass.prefix.label")}

+

{t("authz.bypass.prefix.desc")}

+
+ + {draftPrefixes.length === 0 && ( +

{t("authz.bypass.prefix.empty")}

+ )} + +
    + {draftPrefixes.map((prefix) => { + const locked = isSpawnCapable(prefix); + return ( +
  • +
    + {prefix} + {locked && ( + + {t("authz.bypass.cli_tools_runtime_note")} + + )} +
    + +
  • + ); + })} + {/* Static read-only rows for spawn-capable prefixes that are NOT + in the draft list — surface them so the operator understands + they are intentionally not toggleable. */} + {spawnCapable + .filter((p) => !draftPrefixes.includes(p)) + .map((prefix) => ( +
  • +
    + {prefix} + + {t("authz.bypass.cli_tools_runtime_note")} + +
    + + {t("authz.badge.spawn_capable")} + +
  • + ))} +
+ +
+ setNewPrefixInput(e.target.value)} + disabled={submitting} + /> + +
+
+ + {/* Save bar */} +
+
+ {dirty && ( + {t("authz.pending")} + )} + {status && ( + + {status.message} + + )} +
+ +
+
+ + {/* Password re-auth modal — fires for every security-impacting PATCH */} + { + if (!submitting) { + setPasswordModalOpen(false); + setCurrentPassword(""); + } + }} + title={t("authz.password.prompt.label")} + footer={ +
+ + +
+ } + > +
+

{t("authz.password.prompt.desc")}

+ setCurrentPassword(e.target.value)} + autoFocus + /> + {status?.type === "error" &&

{status.message}

} +
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx index ff5e85de83..de5351852f 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx @@ -5,6 +5,7 @@ import { Card, Button, Input, Toggle } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import IPFilterSection from "./IPFilterSection"; import SessionInfoCard from "./SessionInfoCard"; +import AuthzSection from "./AuthzSection"; import { useTranslations } from "next-intl"; export default function SecurityTab() { @@ -274,6 +275,7 @@ export default function SecurityTab() { +
); } diff --git a/src/app/api/settings/authz-inventory/route.ts b/src/app/api/settings/authz-inventory/route.ts new file mode 100644 index 0000000000..a9bc671f5f --- /dev/null +++ b/src/app/api/settings/authz-inventory/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/localDb"; +import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { createErrorResponse } from "@/lib/api/errorResponse"; +import { extractApiKey, isValidApiKey } from "@/sse/services/auth"; +import { + LOCAL_ONLY_API_PREFIXES, + ALWAYS_PROTECTED_API_PATHS, + LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES, + SPAWN_CAPABLE_PREFIXES, +} from "@/server/authz/routeGuard"; + +/** + * Static MANAGEMENT-tier example prefixes. Render-only — never consulted by + * the runtime policy. The actual MANAGEMENT classification is "any /api/* + * that is not LOCAL_ONLY, not v1/client, not on the public allowlist", so the + * inventory shows representative entries rather than a generated enumeration. + */ +const MANAGEMENT_TIER_PREFIXES: ReadonlyArray = [ + "/api/settings", + "/api/providers/", + "/api/api-keys", +]; + +const CLIENT_API_TIER_PREFIXES: ReadonlyArray = ["/v1/", "/api/v1/"]; + +const PUBLIC_TIER_PREFIXES: ReadonlyArray = ["/api/health", "/api/version", "/_next/"]; + +type TierName = "LOCAL_ONLY" | "ALWAYS_PROTECTED" | "MANAGEMENT" | "CLIENT_API" | "PUBLIC"; + +interface TierEntry { + name: TierName; + prefixes: string[]; + description: string; + bypassable: boolean; +} + +/** + * OQ-5: viewing the inventory requires authentication (dashboard session OR + * any valid API key, regardless of scope). The inventory leaks route-prefix + * taxonomy + current bypass state (reconnaissance value), so we never expose + * it anonymously — but a non-manage key holder may still inspect it. + * + * Compare with `requireManagementAuth` which would refuse anything below the + * manage scope; this endpoint is intentionally read-only and one rung lower. + */ +async function requireInventoryReadAuth(request: Request): Promise { + if (!(await isAuthRequired(request))) { + return null; + } + + if (await isDashboardSessionAuthenticated(request)) { + return null; + } + + const apiKey = extractApiKey(request); + if (apiKey) { + try { + if (await isValidApiKey(apiKey)) { + return null; + } + } catch { + return createErrorResponse({ + status: 503, + message: "Service temporarily unavailable", + type: "server_error", + }); + } + return createErrorResponse({ + status: 403, + message: "Invalid API key", + type: "invalid_request", + }); + } + + return createErrorResponse({ + status: 401, + message: "Authentication required", + type: "invalid_request", + }); +} + +function isBypassableConstant(prefix: string): boolean { + // A LOCAL_ONLY prefix is bypassable iff it appears in the compile-time + // bypass constant AND is not a SPAWN_CAPABLE prefix. Runtime DB state is + // surfaced separately via `bypassEnabled` / `bypassPrefixes`. + if (SPAWN_CAPABLE_PREFIXES.some((p) => p === prefix || prefix.startsWith(p))) { + return false; + } + return LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES.some((p) => p === prefix); +} + +export async function GET(request: Request) { + const authError = await requireInventoryReadAuth(request); + if (authError) return authError; + + try { + const settings = await getSettings(); + + const tiers: TierEntry[] = [ + { + name: "LOCAL_ONLY", + prefixes: [...LOCAL_ONLY_API_PREFIXES], + description: + "Loopback-only routes. Spawn child processes; exposing them to non-local traffic is a known CVE class (GHSA-fhh6-4qxv-rpqj). Some entries are opt-in bypassable via the manage scope (kill-switch gated).", + bypassable: LOCAL_ONLY_API_PREFIXES.some(isBypassableConstant), + }, + { + name: "ALWAYS_PROTECTED", + prefixes: [...ALWAYS_PROTECTED_API_PATHS], + description: + "Auth required unconditionally, even when requireLogin=false. Covers destructive / irreversible operations (shutdown, database settings).", + bypassable: false, + }, + { + name: "MANAGEMENT", + prefixes: [...MANAGEMENT_TIER_PREFIXES], + description: + "Default tier for /api/* admin endpoints. Auth required unless requireLogin=false. PATCHes touching security-impacting keys require currentPassword re-auth.", + bypassable: false, + }, + { + name: "CLIENT_API", + prefixes: [...CLIENT_API_TIER_PREFIXES], + description: + "Client-facing inference APIs. Accept Bearer API keys; not gated by dashboard sessions.", + bypassable: false, + }, + { + name: "PUBLIC", + prefixes: [...PUBLIC_TIER_PREFIXES], + description: "Unauthenticated routes: health probes, public assets, onboarding bootstrap.", + bypassable: false, + }, + ]; + + const bypassEnabled = + typeof settings.localOnlyManageScopeBypassEnabled === "boolean" + ? settings.localOnlyManageScopeBypassEnabled + : true; + const bypassPrefixesRaw = settings.localOnlyManageScopeBypassPrefixes; + const bypassPrefixes = Array.isArray(bypassPrefixesRaw) + ? bypassPrefixesRaw.filter((p): p is string => typeof p === "string") + : [...LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES]; + + return NextResponse.json({ + tiers, + bypassEnabled, + bypassPrefixes, + spawnCapablePrefixes: [...SPAWN_CAPABLE_PREFIXES], + }); + } catch (error) { + console.log("Error loading authz inventory:", error); + return NextResponse.json({ error: "Failed to load authz inventory" }, { status: 500 }); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 403a2a6ff2..60da92f71c 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -13,6 +13,124 @@ import { verifyManagementPassword, } from "@/lib/auth/managementPassword"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance"; +import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; +import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; +import { extractApiKey } from "@/sse/services/auth"; +import { getApiKeyMetadata } from "@/lib/db/apiKeys"; + +/** + * Settings keys whose change broadens attack surface. Spec §Security: + * password re-auth is required when any of these is present in a PATCH body. + * + * - `localOnlyManageScopeBypassEnabled` / `localOnlyManageScopeBypassPrefixes`: + * T-011 bypass kill-switch + per-prefix list. Operator must re-confirm + * before broadening the LOCAL_ONLY carve-out. + * - `requireLogin`: dashboard login enforcement toggle. + * - `newPassword`: password rotation (existing). Handled by the same gate so + * the password-verify only fires ONCE per PATCH. + * + * Note: `mcpEnabled` is NOT gated server-side — the dedicated MCP page + * (/dashboard/mcp) toggles it via patchSetting() without a currentPassword + * prompt. The Authz section can still prompt client-side for consistency, + * but the server accepts the change without re-auth. + */ +const SECURITY_IMPACTING_KEYS = [ + "localOnlyManageScopeBypassEnabled", + "localOnlyManageScopeBypassPrefixes", + "requireLogin", + "newPassword", +] as const; + +/** + * Derive an audit actor string from the inbound request. Falls back to + * `"dashboard"` for cookie sessions, `"apikey:"` for Bearer API keys, + * `"cli"` for CLI machine-token sessions, and `"anonymous"` otherwise. Best + * effort — any lookup error degrades to `"unknown"` so the audit row still + * carries actor context. + */ +async function deriveAuditActor(request: Request): Promise { + try { + if (await isDashboardSessionAuthenticated(request)) return "dashboard"; + } catch { + /* fall through */ + } + try { + if (await isCliTokenAuthValid(request)) return "cli"; + } catch { + /* fall through */ + } + try { + const apiKey = extractApiKey(request); + if (apiKey) { + const meta = await getApiKeyMetadata(apiKey); + if (meta?.id) return `apikey:${meta.id}`; + return "apikey:unknown"; + } + } catch { + return "unknown"; + } + return "anonymous"; +} + +/** Deep-equality for diff detection. JSON round-trip handles plain settings. */ +function isDeepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +/** Build per-key `{before, after}` diff for changed keys (top-level only). */ +function computeSettingsDiff( + before: Record, + after: Record, + candidateKeys: string[] +): Record { + const diff: Record = {}; + for (const key of candidateKeys) { + if (!isDeepEqual(before[key], after[key])) { + diff[key] = { before: before[key], after: after[key] }; + } + } + return diff; +} + +/** List of top-level body keys the operator attempted to change (audit context). */ +function attemptedKeysOf(body: Record | null | undefined): string[] { + if (!body || typeof body !== "object") return []; + return Object.keys(body).filter( + (k) => k !== "currentPassword" && k !== "newPassword" && k !== "password" + ); +} + +/** Emit a settings.update_failed row. Never throws — audit must not break flow. */ +function emitSettingsFailureAudit( + request: Request, + actor: string, + reason: string, + attemptedKeys: string[] +) { + try { + const { ipAddress, requestId } = getAuditRequestContext(request); + logAuditEvent({ + action: "settings.update_failed", + actor, + target: "settings", + resourceType: "settings", + status: "failure", + ipAddress: ipAddress || undefined, + requestId: requestId || undefined, + details: { reason, attempted_keys: attemptedKeys }, + }); + } catch { + /* best effort */ + } +} export async function GET(request: Request) { const authError = await requireManagementAuth(request); @@ -46,40 +164,105 @@ export async function PATCH(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; + // Derive actor + raw body once so the rejection paths can audit consistently. + const actor = await deriveAuditActor(request); + let rawBody: Record = {}; try { - const rawBody = await request.json(); + rawBody = (await request.json()) as Record; + } catch { + // Malformed JSON — surface a zod-style failure path so the rejection + // is auditable like every other 400. + emitSettingsFailureAudit(request, actor, "INVALID_JSON", []); + return NextResponse.json( + { error: { code: "INVALID_JSON", message: "Request body is not valid JSON" } }, + { status: 400 } + ); + } + const attemptedKeys = attemptedKeysOf(rawBody); + try { // Zod validation const validation = validateBody(updateSettingsSchema, rawBody); if (isValidationFailure(validation)) { + // Detect spawn-capable prefix rejection (spec AC-8) so the audit row + // names the correct error code; otherwise fall back to the generic + // validation-failure label. + const isBypassPrefixRejection = (validation.error.details || []).some( + (d) => typeof d.message === "string" && d.message.includes("BYPASS_PREFIX_NOT_ALLOWED") + ); + emitSettingsFailureAudit( + request, + actor, + isBypassPrefixRejection ? "BYPASS_PREFIX_NOT_ALLOWED" : "VALIDATION_FAILED", + attemptedKeys + ); return NextResponse.json({ error: validation.error }, { status: 400 }); } const body: typeof validation.data & { password?: string } = { ...validation.data }; - // If updating password, hash it - if (body.newPassword) { + // Security-impacting gate (T-011, spec AC-4 / AC-5). Computed from the + // VALIDATED body so we never trip on stray unknown keys. If any security + // key is present, require currentPassword + verify against the stored + // bcrypt hash. Dedupes with the previous inline newPassword reauth — the + // password is verified at most once per PATCH. + const touchedSecurityKeys = SECURITY_IMPACTING_KEYS.filter((k) => k in validation.data); + let storedPasswordHash = ""; + if (touchedSecurityKeys.length > 0) { const settings = await getSettings(); + // Lazy-hash any plaintext INITIAL_PASSWORD migration BEFORE we read the + // stored hash, so the gate works on fresh deploys too. const passwordState = await ensurePersistentManagementPasswordHash({ settings, - source: "settings.password_change", + source: "settings.security_impacting_update", }); - const currentHash = getStoredManagementPassword(passwordState.settings); - - if (currentHash) { + storedPasswordHash = getStoredManagementPassword(passwordState.settings); + // Cold-boot exception: same condition the existing newPassword path + // honoured before T-011 — when no password is configured yet AND login + // is currently disabled, allow the first write to set policy (incl. + // the password itself). Once a hash exists the gate always fires. + const isColdBoot = !storedPasswordHash && passwordState.settings.requireLogin === false; + if (!isColdBoot) { if (!body.currentPassword) { - return NextResponse.json({ error: "Current password required" }, { status: 400 }); + emitSettingsFailureAudit(request, actor, "PASSWORD_REQUIRED", attemptedKeys); + return NextResponse.json( + { + error: { + code: "PASSWORD_REQUIRED", + message: "currentPassword required for security-impacting setting changes", + keys: touchedSecurityKeys, + }, + }, + { status: 400 } + ); } - const isValid = await verifyManagementPassword(body.currentPassword, currentHash); + const isValid = await verifyManagementPassword(body.currentPassword, storedPasswordHash); if (!isValid) { - return NextResponse.json({ error: "Invalid current password" }, { status: 401 }); + emitSettingsFailureAudit(request, actor, "PASSWORD_MISMATCH", attemptedKeys); + return NextResponse.json( + { + error: { + code: "PASSWORD_MISMATCH", + message: "Invalid current password", + }, + }, + { status: 401 } + ); } } - - body.password = await hashManagementPassword(body.newPassword); - delete body.newPassword; - delete body.currentPassword; } + // Password rotation: hash the new value AFTER the gate has accepted the + // currentPassword (or the cold-boot exception fired). The gate already + // included `newPassword` in SECURITY_IMPACTING_KEYS, so no separate + // verify happens here — strictly hashing + body rewriting. + if (body.newPassword) { + body.password = await hashManagementPassword(body.newPassword); + delete body.newPassword; + } + delete body.currentPassword; + + // Snapshot BEFORE the write so the success row can record a real diff. + const beforeSnapshot = (await getSettings()) as Record; const settings = await updateSettings(body); // Sync CLIProxyAPI settings to upstream_proxy_config table @@ -88,6 +271,7 @@ export async function PATCH(request: Request) { if (cpaUrl && typeof cpaUrl === "string") { const urlValidation = validateProxyUrl(cpaUrl); if (urlValidation.valid === false) { + emitSettingsFailureAudit(request, actor, "CLIPROXY_URL_INVALID", attemptedKeys); return NextResponse.json( { error: `Invalid CLIProxyAPI URL: ${urlValidation.error}` }, { status: 400 } @@ -106,6 +290,29 @@ export async function PATCH(request: Request) { }); } + // Audit success — diff of changed keys only. Idempotent PATCH (no diff) + // intentionally writes NO row (spec §Observability + AC-9/AC-11). + try { + const afterSnapshot = settings as Record; + const candidateKeys = Object.keys(body); + const diff = computeSettingsDiff(beforeSnapshot, afterSnapshot, candidateKeys); + if (Object.keys(diff).length > 0) { + const { ipAddress, requestId } = getAuditRequestContext(request); + logAuditEvent({ + action: "settings.update", + actor, + target: "settings", + resourceType: "settings", + status: "success", + ipAddress: ipAddress || undefined, + requestId: requestId || undefined, + details: { diff }, + }); + } + } catch { + // Audit failure must never break the write — swallow. + } + const { password, ...safeSettings } = settings; return NextResponse.json(safeSettings); } catch (error) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index e355954350..89bc969435 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -842,6 +842,7 @@ "settingsAi": "AI Settings", "settingsSecurity": "Security", "settingsFeatureFlags": "Feature Flags", + "settingsAuthz": "Authz", "settingsRouting": "Routing", "settingsResilience": "Resilience", "settingsAdvanced": "Advanced", @@ -913,6 +914,7 @@ "settingsAdvancedSubtitle": "Power user options", "settingsSecuritySubtitle": "Auth and encryption", "settingsFeatureFlagsSubtitle": "Toggle system capabilities", + "settingsAuthzSubtitle": "Route inventory and bypass policy", "docsSubtitle": "Documentation", "issuesSubtitle": "Report a bug", "changelogSubtitle": "Release notes" @@ -1288,6 +1290,7 @@ "keyName": "Key Name", "keyNamePlaceholder": "e.g. Production Key", "keyNameDesc": "Choose a descriptive name to identify this key's purpose", + "managementAccessDesc": "Allow this API key to manage OmniRoute configuration.", "keyCreated": "API Key Created", "keyCreatedSuccess": "Key created successfully!", "keyCreatedNote": "Copy and store this key now — it won't be shown again.", @@ -4587,7 +4590,62 @@ "claudeFastModeHint": "Anthropic does not officially support Fast Mode for SDK-style clients. When enabled, OmniRoute forwards an X-CPA-Force-Fast-Mode header so a paired CLIProxyAPI build can opt-in spoof the entrypoint. Only the listed Opus models are gated by Anthropic's client-side check. Subscription tier, Max plan, and Fast Mode credit balance are still enforced server-side — Anthropic may return out_of_credits even when the toggle is on.", "claudeFastModeModelsLabel": "Applied to models ({count})", "claudeFastModeModelCheckbox": "Enable Fast Mode for {model}", - "claudeFastModeSaveError": "Failed to update Claude Fast Mode setting" + "claudeFastModeSaveError": "Failed to update Claude Fast Mode setting", + "authz": { + "title": "Authz Inventory", + "description": "5-tier route classification with live bypass policy. Read shows the full taxonomy; mutations re-prompt for the management password.", + "loading": "Loading inventory…", + "loadError": "Failed to load authz inventory", + "tier": { + "LOCAL_ONLY": "Local only", + "ALWAYS_PROTECTED": "Always protected", + "MANAGEMENT": "Management", + "CLIENT_API": "Client API", + "PUBLIC": "Public" + }, + "bypass": { + "section": "Manage-scope bypass", + "kill_switch": { + "label": "Bypass kill-switch", + "desc": "Master toggle. When off, no LOCAL_ONLY prefix is reachable from non-loopback regardless of the per-prefix list." + }, + "prefix": { + "label": "Bypassable prefixes", + "desc": "LOCAL_ONLY prefixes that manage-scope API keys (or dashboard sessions) may reach from non-loopback hosts.", + "add": "Add prefix", + "placeholder": "/api/mcp/v2/", + "empty": "No prefixes configured. Bypass effectively off." + }, + "cli_tools_runtime_note": "Spawn-capable prefix. Compile-time deny; cannot be made bypassable. Shown read-only." + }, + "password": { + "prompt": { + "label": "Current password", + "desc": "Re-confirm to apply security-impacting changes." + }, + "placeholder": "Current management password", + "cancel": "Cancel", + "submit": "Apply" + }, + "save": "Save changes", + "saved": "Authz settings updated", + "pending": "Unsaved changes", + "badge": { + "bypassable": "Bypassable via manage scope", + "strict": "Strict loopback", + "auth_required": "Auth required", + "public": "Public", + "always_protected": "Always protected", + "spawn_capable": "Spawn-capable" + }, + "error": { + "PASSWORD_REQUIRED": "Current password required to apply these changes.", + "PASSWORD_MISMATCH": "Current password is incorrect.", + "INSUFFICIENT_SCOPE": "API key lacks the manage scope.", + "BYPASS_PREFIX_NOT_ALLOWED": "One or more prefixes target spawn-capable routes and cannot be bypassed.", + "GENERIC": "Failed to update authz settings." + } + } }, "contextRtk": { "title": "RTK Engine", diff --git a/src/lib/compliance/index.ts b/src/lib/compliance/index.ts index 560b260508..b966813c81 100644 --- a/src/lib/compliance/index.ts +++ b/src/lib/compliance/index.ts @@ -57,6 +57,11 @@ type AuditLogFilter = { }; type AuditLogRow = Record & { + id?: number | null; + action?: string | null; + actor?: string | null; + target?: string | null; + status?: string | null; details?: string | null; metadata?: string | null; ip_address?: string | null; @@ -65,6 +70,33 @@ type AuditLogRow = Record & { timestamp?: string | null; }; +/** + * Public shape of a normalized audit-log entry returned by `getAuditLog` / + * `normalizeAuditLogRow`. Includes the column-level fields callers (and + * tests) reach into directly — `action`, `actor`, `target`, `id`, `status`, + * `timestamp` — alongside the derived/parsed fields. The + * `Record` intersection preserves the existing behaviour of + * spreading any extra DB columns (e.g. schema additions) without losing + * compile-time access to the known ones. + */ +export type AuditLogEntry = Record & { + id?: number | null; + action?: string | null; + actor?: string | null; + target?: string | null; + status: string | null; + timestamp: string; + createdAt: string; + details: unknown; + metadata: unknown; + ip_address: string | null; + ip: string | null; + resource_type: string | null; + resourceType: string | null; + request_id: string | null; + requestId: string | null; +}; + const AUDIT_LOG_REQUIRED_COLUMNS: Record = { resource_type: "TEXT", status: "TEXT", @@ -227,7 +259,7 @@ function buildAuditLogQuery(filter: AuditLogFilter = {}): AuditLogQuery { }; } -function normalizeAuditLogRow(row: AuditLogRow) { +function normalizeAuditLogRow(row: AuditLogRow): AuditLogEntry { const details = parseAuditValue(row.details); const metadata = parseAuditValue(row.metadata); const resourceType = typeof row.resource_type === "string" ? row.resource_type : null; @@ -349,7 +381,7 @@ export function logAuditEvent(entry: { * @param {number} [filter.offset=0] - Pagination offset * @returns {Array<{ id: number, timestamp: string, action: string, actor: string, target: string, details: any, ip_address: string }>} */ -export function getAuditLog(filter: AuditLogFilter = {}) { +export function getAuditLog(filter: AuditLogFilter = {}): AuditLogEntry[] { const db = getDb(); if (!db) return []; diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index 634fd5d9b0..b22f48c294 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -14,13 +14,19 @@ export type RuntimeReloadSection = | "modelsDevSync" | "corsOrigins" | "ccBridgeTransforms" - | "systemTransforms"; + | "systemTransforms" + | "authzBypass"; export interface RuntimeReloadChange { section: RuntimeReloadSection; source: string; } +interface AuthzBypassSnapshot { + enabled: boolean; + prefixes: string[]; +} + interface RuntimeSettingsSnapshot { payloadRules: unknown; modelAliases: Record; @@ -35,8 +41,17 @@ interface RuntimeSettingsSnapshot { corsOrigins: string; ccBridgeTransforms: unknown; systemTransforms: unknown; + authzBypass: AuthzBypassSnapshot; } +// Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the +// pre-T-011 compile-time constant so the route guard works identically before +// the first `applyRuntimeSettings` call (e.g. cold-boot requests). +const DEFAULT_AUTHZ_BYPASS_SNAPSHOT: AuthzBypassSnapshot = { + enabled: true, + prefixes: ["/api/mcp/"], +}; + const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { payloadRules: null, modelAliases: {}, @@ -51,10 +66,17 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { corsOrigins: "", ccBridgeTransforms: null, systemTransforms: null, + authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT, }; let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null; +// Module-local mirror of the current bypass policy. Read by the route guard +// on every non-loopback hit to a LOCAL_ONLY path via `getAuthzBypassSnapshot`. +// Initialised to the default so cold-boot requests (before any +// `applyRuntimeSettings` call) behave identically to PR #2473. +let currentAuthzBypass: AuthzBypassSnapshot = DEFAULT_AUTHZ_BYPASS_SNAPSHOT; + function isTruthyEnvFlag(value: string | undefined): boolean { if (typeof value !== "string") return false; return new Set(["1", "true", "yes", "on"]).has(value.trim().toLowerCase()); @@ -165,6 +187,41 @@ function normalizePayloadRules(value: unknown): unknown { return parseStoredJson(value, "payloadRules"); } +function normalizeAuthzBypass(settings: Record): AuthzBypassSnapshot { + const enabled = + settings.localOnlyManageScopeBypassEnabled === false + ? false + : settings.localOnlyManageScopeBypassEnabled === true + ? true + : DEFAULT_AUTHZ_BYPASS_SNAPSHOT.enabled; + const rawPrefixes = settings.localOnlyManageScopeBypassPrefixes; + const prefixes = Array.isArray(rawPrefixes) + ? Array.from( + new Set( + rawPrefixes + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0 && entry.startsWith("/")) + ) + ) + : [...DEFAULT_AUTHZ_BYPASS_SNAPSHOT.prefixes]; + return { enabled, prefixes }; +} + +/** + * O(1) accessor for the current LOCAL_ONLY manage-scope bypass policy. + * + * Consumed by the route-guard hot path (`isLocalOnlyBypassableByManageScope`). + * Returns the default snapshot (`{ enabled: true, prefixes: ["/api/mcp/"] }`) + * before the first `applyRuntimeSettings` call so cold-boot requests behave + * identically to PR #2473. Mutated only by `applyAuthzBypassSection`. + * + * Hot-reload latency: <50 ms (no I/O, no async, pure read of module-local + * state). Spec §Non-Functional Requirements / Performance. + */ +export function getAuthzBypassSnapshot(): AuthzBypassSnapshot { + return currentAuthzBypass; +} + export function buildRuntimeSettingsSnapshot( settings: Record ): RuntimeSettingsSnapshot { @@ -188,6 +245,7 @@ export function buildRuntimeSettingsSnapshot( corsOrigins: typeof settings.corsOrigins === "string" ? settings.corsOrigins : "", ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"), systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"), + authzBypass: normalizeAuthzBypass(settings), }; } @@ -283,6 +341,14 @@ async function applyCcBridgeTransformsSection(ccBridgeTransforms: unknown) { } } +/** + * Swap the in-process bypass policy. Synchronous, O(1), no I/O — the SLA + * (<50 ms hot-reload) is structurally satisfied by this shape. + */ +function applyAuthzBypassSection(snapshot: AuthzBypassSnapshot) { + currentAuthzBypass = { enabled: snapshot.enabled, prefixes: [...snapshot.prefixes] }; +} + async function applySystemTransformsSection(systemTransforms: unknown) { const { setSystemTransformsConfig, resetSystemTransformsConfig } = await import("@omniroute/open-sse/services/systemTransforms.ts"); @@ -447,6 +513,11 @@ export async function applyRuntimeSettings( markChanged("systemTransforms"); } + if (force || hasChanged(currentSnapshot.authzBypass, previousSnapshot.authzBypass)) { + applyAuthzBypassSection(currentSnapshot.authzBypass); + markChanged("authzBypass"); + } + lastAppliedSnapshot = currentSnapshot; return changes; } @@ -457,4 +528,5 @@ export function getLastAppliedRuntimeSettingsSnapshotForTests() { export function resetRuntimeSettingsStateForTests() { lastAppliedSnapshot = null; + currentAuthzBypass = DEFAULT_AUTHZ_BYPASS_SNAPSHOT; } diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index f056550f88..aca2127cb7 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -109,6 +109,7 @@ interface ApiKeyView extends JsonRecord { isActive: boolean; accessSchedule: AccessSchedule | null; rateLimits: RateLimitRule[] | null; + scopes: string[]; } // LRU cache for API key validation (valid keys only) @@ -379,6 +380,7 @@ export async function getApiKeys() { camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule); camelRow.rateLimits = parseRateLimits(camelRow.rateLimits); camelRow.isBanned = parseIsBanned(camelRow.isBanned); + camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -400,6 +402,7 @@ export async function getApiKeyById(id: string) { camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule); camelRow.rateLimits = parseRateLimits(camelRow.rateLimits); camelRow.isBanned = parseIsBanned(camelRow.isBanned); + camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes); if (typeof camelRow.id === "string" && camelRow.id.length > 0) { setNoLog(camelRow.id, camelRow.noLog === true); } @@ -762,14 +765,60 @@ export async function updateApiKeyPermissions( } const scopesUpdate = (normalized as Record).scopes; + const nextScopes: string[] = Array.isArray(scopesUpdate) + ? (scopesUpdate as unknown[]).filter((s): s is string => typeof s === "string") + : []; + // Capture previous scopes BEFORE the UPDATE so we can compare for the audit + // event below. We only fetch when the caller is actually changing scopes — + // a privileged change ("manage" grants management API surface access) that + // must always leave an audit trail per OWASP A09 / SOC2 CC7.2. + // + // The previous-scopes SELECT and the row UPDATE are wrapped in a single + // transaction so a concurrent writer cannot slip in between and make the + // audit log lie about what changed. SQLite is single-writer in practice, + // but the transaction also gives us atomicity if the underlying driver + // ever swaps to a backend that allows multiple writers (sqljsAdapter / + // nodeSqliteAdapter fall-back per v3.8.1 db driver cascade). + let previousScopes: string[] = []; + let changedRows = 0; if (scopesUpdate !== undefined) { updates.push("scopes = @scopes"); - params.scopes = JSON.stringify(Array.isArray(scopesUpdate) ? scopesUpdate : []); + params.scopes = JSON.stringify(nextScopes); + + // SELECT-then-UPDATE wrapped in an explicit transaction so a concurrent + // writer can't slip between the read and the write and make the audit + // log lie about what changed. `exec("BEGIN"/"COMMIT")` works across all + // driver backends (better-sqlite3 / node:sqlite / sql.js) wired by the + // v3.8.1 db driver cascade — none of them expose `db.transaction()` via + // ApiKeysDbLike, which is intentionally minimal. + db.exec("BEGIN IMMEDIATE"); + try { + const prevRow = db + .prepare<{ scopes: string | null }>("SELECT scopes FROM api_keys WHERE id = ?") + .get(id); + previousScopes = parseStringList(prevRow?.scopes ?? null); + const upd = db + .prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`) + .run(params); + changedRows = upd.changes ?? 0; + db.exec("COMMIT"); + } catch (err) { + // Guard the ROLLBACK: if it throws (e.g. transaction already ended + // due to an implicit commit, or backend in a bad state), the original + // error from the try block is the actionable one — don't shadow it. + try { + db.exec("ROLLBACK"); + } catch { + // swallow: original error is more important + } + throw err; + } + } else { + const upd = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params); + changedRows = upd.changes ?? 0; } - const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params); - - if (result.changes === 0) return false; + if (changedRows === 0) return false; const { logAuditEvent } = await import("@/lib/compliance"); @@ -787,6 +836,38 @@ export async function updateApiKeyPermissions( }); } + if (scopesUpdate !== undefined) { + // Compare prev vs next scope sets and emit a dedicated audit event when + // the privileged "manage" scope is granted or revoked. Other scope + // mutations also emit a generic "apiKey.scopes.update" so the audit log + // captures the full change history (action + details). + const hadManage = previousScopes.includes("manage"); + const hasManage = nextScopes.includes("manage"); + if (!hadManage && hasManage) { + logAuditEvent({ + action: "apiKey.scopes.grant", + target: id, + details: { scopes: nextScopes, previous: previousScopes }, + }); + } else if (hadManage && !hasManage) { + logAuditEvent({ + action: "apiKey.scopes.revoke", + target: id, + details: { scopes: nextScopes, previous: previousScopes }, + }); + } else if ( + previousScopes.length !== nextScopes.length || + previousScopes.some((s) => !nextScopes.includes(s)) || + nextScopes.some((s) => !previousScopes.includes(s)) + ) { + logAuditEvent({ + action: "apiKey.scopes.update", + target: id, + details: { scopes: nextScopes, previous: previousScopes }, + }); + } + } + if (normalized.noLog !== undefined) { setNoLog(id, normalized.noLog); } @@ -985,6 +1066,28 @@ export async function getApiKeyMetadata( // persistent env-var key support (persistent passthrough keys) (#1350) if (isConfiguredEnvApiKey(key)) { + // ─── Env-key management-scope bypass ────────────────────────────────── + // The deployment-time env key (`OMNIROUTE_API_KEY` / `ROUTER_API_KEY`) + // is granted the "manage" scope unconditionally. This is intentional: + // + // 1. The env key never exists in the SQLite `api_keys` table, so the + // DB-backed scopes column does not apply. We synthesize the + // metadata record here. + // 2. The operator who set the env var is presumed to be the deployment + // owner; rotating (or unsetting) the env var is the only way to + // rotate this privilege. There is no UI to change it. + // 3. Management API access via the env key still passes through + // `requireManagementAuth` → `hasManageScope`, so policy decisions + // remain centralised in `src/server/authz/*`. + // 4. Requests authenticated by the env key are tagged with + // `id: "env-key"` for downstream audit-log emitters, making it + // possible to distinguish env-key activity from user-created keys + // that happen to also hold "manage". + // + // DO NOT remove "manage" from this list — that would break the + // deployment-time bootstrap path that operators rely on for headless + // / CI / first-boot scenarios. If you need to disable env-key access, + // unset the env var instead. return { id: "env-key", name: "Environment Key", diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 9d3837c420..ece2968e3a 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -104,6 +104,14 @@ export async function getSettings() { wsAuth: false, maxBodySizeMb: requestBodyLimitMbFromEnv(process.env.MAX_BODY_SIZE_BYTES), debugMode: true, + // LOCAL_ONLY manage-scope bypass policy defaults (T-011 / spec §Data Model). + // Preserves PR #2473 behaviour on migration — the bypass starts ENABLED + // for `/api/mcp/` so existing manage-scope Bearer clients keep working. + // Operators flip the kill-switch to false (or drop the prefix) via the + // Settings UI; the change hot-reloads through `applyRuntimeSettings` → + // `applyAuthzBypassSection` → `getAuthzBypassSnapshot()`. + localOnlyManageScopeBypassEnabled: true, + localOnlyManageScopeBypassPrefixes: ["/api/mcp/"], }; for (const row of rows) { const record = toRecord(row); diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index e34ffe5161..cb3d89b7c3 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -8,7 +8,12 @@ import { extractApiKey, isValidApiKey } from "../../../sse/services/auth"; import { getApiKeyMetadata } from "../../../lib/db/apiKeys"; import { hasManageScope } from "../../../lib/api/requireManagementAuth"; import { CLI_TOKEN_HEADER } from "../headers"; -import { isAlwaysProtectedPath, isLocalOnlyPath, isLoopbackHost } from "../routeGuard"; +import { + isAlwaysProtectedPath, + isLocalOnlyBypassableByManageScope, + isLocalOnlyPath, + isLoopbackHost, +} from "../routeGuard"; const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/; @@ -41,10 +46,72 @@ export const managementPolicy: RoutePolicy = { const path = ctx.classification.normalizedPath; // Tier 1: local-only gate — block spawn-capable routes from non-loopback. - if (isLocalOnlyPath(path)) { - if (!isLoopbackRequest(ctx.request.headers)) { - return reject(403, "LOCAL_ONLY", "This endpoint requires localhost access"); + // + // Carve-out: a small allow-list of LOCAL_ONLY paths (see + // LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES) is reachable from non-loopback + // when the caller presents EITHER (a) a valid API key with the `manage` + // scope, or (b) an authenticated dashboard session. This lets: + // - headless / remote MCP clients drive the management surface with a + // manage-scope Bearer key, and + // - the Dashboard UI itself (cookie session) render its MCP pages + // (/api/mcp/status, /api/mcp/tools) from a public hostname. + // + // The strict-loopback default still applies to everything else (notably + // the subprocess-spawning /api/cli-tools/runtime/* surface, which is NOT + // in the bypass list). + // + // Anonymous (no Bearer / invalid key / wrong scope / no session) requests + // still hit the same 403 LOCAL_ONLY they did before. + if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx.request.headers)) { + if (isLocalOnlyBypassableByManageScope(path)) { + const apiKey = extractApiKey(ctx.request as unknown as Request); + if (apiKey) { + try { + if (await isValidApiKey(apiKey)) { + const meta = await getApiKeyMetadata(apiKey); + if (meta && hasManageScope(meta.scopes)) { + // Distinguish admin vs manage in the audit label so log review + // can tell which privilege actually granted the bypass. + const grantedBy = meta.scopes.includes("admin") ? "admin" : "manage"; + return allow({ + kind: "management_key", + id: meta.id, + label: `api-key-${grantedBy}-scope-local-only-bypass`, + }); + } + } + } catch (err) { + // Auth backend (DB / file store) failure: surface as 503 so the + // caller can retry. Anything else (TypeError / ReferenceError / + // programmer error) is logged so it's not silently swallowed — + // the policy still degrades closed (503) to avoid leaking the + // route, but we leave a breadcrumb for ops. + console.error("[managementPolicy] manage-scope bypass auth check failed", err); + return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable"); + } + } + // Dashboard session bypass: the Dashboard UI itself needs to render + // /api/mcp/status, /api/mcp/tools, etc. from a public hostname. Cookie + // auth is already proof of an authenticated admin — same trust level + // as a manage-scope Bearer for the surface in scope here. + try { + if (await isDashboardSessionAuthenticated(ctx.request)) { + return allow({ + kind: "dashboard_session", + id: "dashboard", + label: "dashboard-session-local-only-bypass", + }); + } + } catch (err) { + // Mirror the manage-scope branch above: degrade closed (503) rather + // than leaking the route through an unhandled 500, but log a + // breadcrumb for ops. Session-store DB failure / cookie parsing + // error / JWT decode throw all land here. + console.error("[managementPolicy] dashboard-session bypass auth check failed", err); + return reject(503, "AUTH_BACKEND_UNAVAILABLE", "Service temporarily unavailable"); + } } + return reject(403, "LOCAL_ONLY", "This endpoint requires localhost access"); } if (isInternalModelSyncRequest(ctx)) { diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index d746f74fa9..f82cec1263 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -5,6 +5,15 @@ * 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. * @@ -12,6 +21,8 @@ * 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 = [ @@ -19,6 +30,34 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/cli-tools/runtime/", ]; +/** + * 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 = ["/api/cli-tools/runtime/"]; + +/** + * 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 = ["/api/mcp/"]; + export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray = [ "/api/shutdown", "/api/settings/database", @@ -42,6 +81,38 @@ export function isLocalOnlyPath(path: string): boolean { return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p)); } +/** + * 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)); } diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 2e42dfd47f..8452cc1099 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -75,6 +75,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "settings-advanced", "settings-security", "settings-feature-flags", + "settings-authz", // Help "docs", "issues", @@ -659,6 +660,13 @@ const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [ subtitleKey: "settingsFeatureFlagsSubtitle", icon: "flag", }, + { + id: "settings-authz", + href: "/dashboard/settings/authz", + i18nKey: "settingsAuthz", + subtitleKey: "settingsAuthzSubtitle", + icon: "shield_lock", + }, ]; const HELP_ITEMS: readonly SidebarItemDefinition[] = [ diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index b93409a1ca..601717bb94 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -10,251 +10,282 @@ import { COMBO_CONFIG_MODES } from "@/shared/constants/comboConfigMode"; import { MAX_REQUEST_BODY_LIMIT_MB, MIN_REQUEST_BODY_LIMIT_MB } from "@/shared/constants/bodySize"; import { HIDEABLE_SIDEBAR_ITEM_IDS } from "@/shared/constants/sidebarVisibility"; import { ACCOUNT_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies"; +import { SPAWN_CAPABLE_PREFIXES } from "@/server/authz/routeGuard"; const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const; -export const updateSettingsSchema = z.object({ - newPassword: z.string().min(1).max(200).optional(), - currentPassword: z.string().max(200).optional(), - theme: z.string().max(50).optional(), - language: z.string().max(10).optional(), - requireLogin: z.boolean().optional(), - enableSocks5Proxy: z.boolean().optional(), - instanceName: z.string().max(100).optional(), - customLogoUrl: z.string().max(2000).optional(), - customLogoBase64: z.string().max(100000).optional(), - customFaviconUrl: z.string().max(2000).optional(), - customFaviconBase64: z.string().max(50000).optional(), - corsOrigins: z.string().max(500).optional(), - cloudUrl: z.string().max(500).optional(), - baseUrl: z.string().max(500).optional(), - setupComplete: z.boolean().optional(), - blockedProviders: z.array(z.string().max(100)).optional(), - hideHealthCheckLogs: z.boolean().optional(), - hideEndpointCloudflaredTunnel: z.boolean().optional(), - hideEndpointTailscaleFunnel: z.boolean().optional(), - hideEndpointNgrokTunnel: z.boolean().optional(), - debugMode: z.boolean().optional(), - hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), - comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(), - codexServiceTier: z - .object({ - enabled: z.boolean().optional(), - tier: z.enum(["default", "priority", "flex"]).optional(), - supportedModels: z.array(z.string().max(200)).max(200).optional(), - }) - .optional(), - // Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build - // (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can - // reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed - // Opus models are gated by the Anthropic binary KT() check. Schema is - // intentionally permissive on supportedModels so additional eligible model - // ids can be enabled without a schema bump. - claudeFastMode: z - .object({ - enabled: z.boolean().optional(), - supportedModels: z.array(z.string().max(200)).max(200).optional(), - }) - .optional(), - // Routing settings (#134) - fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(), - wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(), - stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(), - requestRetry: z.number().int().min(0).max(10).optional(), - maxRetryIntervalSec: z.number().int().min(0).max(300).optional(), - maxBodySizeMb: z - .number() - .int() - .min(MIN_REQUEST_BODY_LIMIT_MB) - .max(MAX_REQUEST_BODY_LIMIT_MB) - .optional(), - // Auto intent classifier settings (multilingual routing) - intentDetectionEnabled: z.boolean().optional(), - intentSimpleMaxWords: z.number().int().min(1).max(500).optional(), - intentExtraCodeKeywords: z.array(z.string().max(100)).optional(), - intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(), - intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(), - // Protocol toggles (default: disabled) - mcpEnabled: z.boolean().optional(), - mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(), - a2aEnabled: z.boolean().optional(), - wsAuth: z.boolean().optional(), - // CLI Fingerprint compatibility (per-provider) - cliCompatProviders: z.array(z.string().max(100)).optional(), - // CC bridge transforms (issue #2260): config-driven pipeline that normalizes - // system blocks at the Claude Code bridge so any client (OpenCode, Cline, - // Cursor, Continue, raw API) ends up with classifier-correct structure. - ccBridgeTransforms: z - .object({ - enabled: z.boolean(), - pipeline: z - .array( - z.discriminatedUnion("kind", [ - z.object({ - kind: z.literal("drop_paragraph_if_contains"), - needles: z.array(z.string().max(500)).max(50), - caseSensitive: z.boolean().optional(), - }), - z.object({ - kind: z.literal("drop_paragraph_if_starts_with"), - prefixes: z.array(z.string().max(500)).max(50), - caseSensitive: z.boolean().optional(), - }), - z.object({ - kind: z.literal("replace_text"), - match: z.string().min(1).max(500), - replacement: z.string().max(500), - allOccurrences: z.boolean().optional(), - }), - z.object({ - kind: z.literal("replace_regex"), - pattern: z.string().min(1).max(500), - flags: z.string().max(10).optional(), - replacement: z.string().max(500), - }), - z.object({ - kind: z.literal("drop_block_if_contains"), - needles: z.array(z.string().max(500)).max(50), - }), - z.object({ - kind: z.literal("prepend_system_block"), - text: z.string().min(1).max(2000), - idempotencyKey: z.string().max(100).optional(), - }), - z.object({ - kind: z.literal("append_system_block"), - text: z.string().min(1).max(2000), - idempotencyKey: z.string().max(100).optional(), - }), - z.object({ - kind: z.literal("inject_billing_header"), - entrypoint: z.string().min(1).max(50), - versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]), - cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]), - version: z.string().max(50).optional(), - }), - ]) - ) - .max(50), - }) - .optional(), - // System Transforms (issue #2260 v2): generic per-provider DSL covering - // native `claude`, `anthropic-compatible-cc-*` bridge, and any other - // provider key. Adds `obfuscate_words` op kind on top of the base set. - systemTransforms: z - .object({ - providers: z.record( - z.string().max(100), - z.object({ - enabled: z.boolean(), - pipeline: z - .array( - z.discriminatedUnion("kind", [ - z.object({ - kind: z.literal("drop_paragraph_if_contains"), - needles: z.array(z.string().max(500)).max(50), - caseSensitive: z.boolean().optional(), - }), - z.object({ - kind: z.literal("drop_paragraph_if_starts_with"), - prefixes: z.array(z.string().max(500)).max(50), - caseSensitive: z.boolean().optional(), - }), - z.object({ - kind: z.literal("replace_text"), - match: z.string().min(1).max(500), - replacement: z.string().max(500), - allOccurrences: z.boolean().optional(), - }), - z.object({ - kind: z.literal("replace_regex"), - pattern: z.string().min(1).max(500), - flags: z.string().max(10).optional(), - replacement: z.string().max(500), - }), - z.object({ - kind: z.literal("drop_block_if_contains"), - needles: z.array(z.string().max(500)).max(50), - }), - z.object({ - kind: z.literal("prepend_system_block"), - text: z.string().min(1).max(2000), - idempotencyKey: z.string().max(100).optional(), - }), - z.object({ - kind: z.literal("append_system_block"), - text: z.string().min(1).max(2000), - idempotencyKey: z.string().max(100).optional(), - }), - z.object({ - kind: z.literal("inject_billing_header"), - entrypoint: z.string().min(1).max(50), - versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]), - cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]), - version: z.string().max(50).optional(), - }), - z.object({ - kind: z.literal("obfuscate_words"), - words: z.array(z.string().max(100)).max(200), - targets: z - .array(z.enum(["system", "messages", "tools"])) - .max(3) - .optional(), - }), - ]) - ) - .max(50), - }) - ), - }) - .optional(), - // Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4") - stripModelPrefix: z.boolean().optional(), - // Cache control preservation mode - alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(), - antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(), - // Adaptive Volume Routing - adaptiveVolumeRouting: z.boolean().optional(), - // Usage token buffer — safety margin added to reported prompt/input token counts. - // Prevents CLI tools from overrunning context windows. Set to 0 to disable. - usageTokenBuffer: z.number().int().min(0).max(50000).optional(), - // Custom CLI agent definitions for ACP - customAgents: z - .array( - z.object({ - id: z.string().max(50), - name: z.string().max(100), - binary: z.string().max(200), - versionCommand: z.string().max(300), - providerAlias: z.string().max(50), - spawnArgs: z.array(z.string().max(200)), - protocol: z.enum(["stdio", "http"]), +export const updateSettingsSchema = z + .object({ + newPassword: z.string().min(1).max(200).optional(), + currentPassword: z.string().max(200).optional(), + theme: z.string().max(50).optional(), + language: z.string().max(10).optional(), + requireLogin: z.boolean().optional(), + enableSocks5Proxy: z.boolean().optional(), + instanceName: z.string().max(100).optional(), + customLogoUrl: z.string().max(2000).optional(), + customLogoBase64: z.string().max(100000).optional(), + customFaviconUrl: z.string().max(2000).optional(), + customFaviconBase64: z.string().max(50000).optional(), + corsOrigins: z.string().max(500).optional(), + cloudUrl: z.string().max(500).optional(), + baseUrl: z.string().max(500).optional(), + setupComplete: z.boolean().optional(), + blockedProviders: z.array(z.string().max(100)).optional(), + hideHealthCheckLogs: z.boolean().optional(), + hideEndpointCloudflaredTunnel: z.boolean().optional(), + hideEndpointTailscaleFunnel: z.boolean().optional(), + hideEndpointNgrokTunnel: z.boolean().optional(), + debugMode: z.boolean().optional(), + hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(), + comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(), + codexServiceTier: z + .object({ + enabled: z.boolean().optional(), + tier: z.enum(["default", "priority", "flex"]).optional(), + supportedModels: z.array(z.string().max(200)).max(200).optional(), }) - ) - .optional(), - // SkillsMP marketplace API key - skillsmpApiKey: z.string().max(200).optional(), - // Active skills provider (single source of truth for skills page) - skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(), - // models.dev sync settings - modelsDevSyncEnabled: z.boolean().optional(), - modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(), - // Vision Bridge settings - visionBridgeEnabled: z.boolean().optional(), - visionBridgeModel: z.string().max(200).optional(), - visionBridgePrompt: z.string().max(5000).optional(), - visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(), - visionBridgeMaxImages: z.number().int().min(1).max(20).optional(), - // Missing settings - lkgpEnabled: z.boolean().optional(), - backgroundDegradation: z.unknown().optional(), - bruteForceProtection: z.boolean().optional(), - // Auto-routing settings - autoRoutingEnabled: z.boolean().optional(), - autoRoutingDefaultVariant: z - .enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"]) - .optional(), -}); + .optional(), + // Claude Fast Mode: opt-in toggle that asks a paired CLIProxyAPI build + // (claude-fastmode-spoof) to rewrite SDK-shaped entrypoints so requests can + // reach Anthropic Fast Mode (speed:"fast"). Default off; only the listed + // Opus models are gated by the Anthropic binary KT() check. Schema is + // intentionally permissive on supportedModels so additional eligible model + // ids can be enabled without a schema bump. + claudeFastMode: z + .object({ + enabled: z.boolean().optional(), + supportedModels: z.array(z.string().max(200)).max(200).optional(), + }) + .optional(), + // Routing settings (#134) + fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(), + wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(), + stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(), + requestRetry: z.number().int().min(0).max(10).optional(), + maxRetryIntervalSec: z.number().int().min(0).max(300).optional(), + maxBodySizeMb: z + .number() + .int() + .min(MIN_REQUEST_BODY_LIMIT_MB) + .max(MAX_REQUEST_BODY_LIMIT_MB) + .optional(), + // Auto intent classifier settings (multilingual routing) + intentDetectionEnabled: z.boolean().optional(), + intentSimpleMaxWords: z.number().int().min(1).max(500).optional(), + intentExtraCodeKeywords: z.array(z.string().max(100)).optional(), + intentExtraReasoningKeywords: z.array(z.string().max(100)).optional(), + intentExtraSimpleKeywords: z.array(z.string().max(100)).optional(), + // Protocol toggles (default: disabled) + mcpEnabled: z.boolean().optional(), + mcpTransport: z.enum(["stdio", "sse", "streamable-http"]).optional(), + a2aEnabled: z.boolean().optional(), + wsAuth: z.boolean().optional(), + // CLI Fingerprint compatibility (per-provider) + cliCompatProviders: z.array(z.string().max(100)).optional(), + // CC bridge transforms (issue #2260): config-driven pipeline that normalizes + // system blocks at the Claude Code bridge so any client (OpenCode, Cline, + // Cursor, Continue, raw API) ends up with classifier-correct structure. + ccBridgeTransforms: z + .object({ + enabled: z.boolean(), + pipeline: z + .array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("drop_paragraph_if_contains"), + needles: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("drop_paragraph_if_starts_with"), + prefixes: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_text"), + match: z.string().min(1).max(500), + replacement: z.string().max(500), + allOccurrences: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_regex"), + pattern: z.string().min(1).max(500), + flags: z.string().max(10).optional(), + replacement: z.string().max(500), + }), + z.object({ + kind: z.literal("drop_block_if_contains"), + needles: z.array(z.string().max(500)).max(50), + }), + z.object({ + kind: z.literal("prepend_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("append_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("inject_billing_header"), + entrypoint: z.string().min(1).max(50), + versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]), + cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]), + version: z.string().max(50).optional(), + }), + ]) + ) + .max(50), + }) + .optional(), + // System Transforms (issue #2260 v2): generic per-provider DSL covering + // native `claude`, `anthropic-compatible-cc-*` bridge, and any other + // provider key. Adds `obfuscate_words` op kind on top of the base set. + systemTransforms: z + .object({ + providers: z.record( + z.string().max(100), + z.object({ + enabled: z.boolean(), + pipeline: z + .array( + z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("drop_paragraph_if_contains"), + needles: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("drop_paragraph_if_starts_with"), + prefixes: z.array(z.string().max(500)).max(50), + caseSensitive: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_text"), + match: z.string().min(1).max(500), + replacement: z.string().max(500), + allOccurrences: z.boolean().optional(), + }), + z.object({ + kind: z.literal("replace_regex"), + pattern: z.string().min(1).max(500), + flags: z.string().max(10).optional(), + replacement: z.string().max(500), + }), + z.object({ + kind: z.literal("drop_block_if_contains"), + needles: z.array(z.string().max(500)).max(50), + }), + z.object({ + kind: z.literal("prepend_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("append_system_block"), + text: z.string().min(1).max(2000), + idempotencyKey: z.string().max(100).optional(), + }), + z.object({ + kind: z.literal("inject_billing_header"), + entrypoint: z.string().min(1).max(50), + versionFormat: z.enum(["ex-machina", "omniroute-daystamp"]), + cchAlgo: z.enum(["sha256-first-user", "xxhash64-body", "static-zero"]), + version: z.string().max(50).optional(), + }), + z.object({ + kind: z.literal("obfuscate_words"), + words: z.array(z.string().max(100)).max(200), + targets: z + .array(z.enum(["system", "messages", "tools"])) + .max(3) + .optional(), + }), + ]) + ) + .max(50), + }) + ), + }) + .optional(), + // Strip provider/model prefix at proxy layer (e.g. "openai/gpt-4" → "gpt-4") + stripModelPrefix: z.boolean().optional(), + // Cache control preservation mode + alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(), + antigravitySignatureCacheMode: z.enum(signatureCacheModeValues).optional(), + // Adaptive Volume Routing + adaptiveVolumeRouting: z.boolean().optional(), + // Usage token buffer — safety margin added to reported prompt/input token counts. + // Prevents CLI tools from overrunning context windows. Set to 0 to disable. + usageTokenBuffer: z.number().int().min(0).max(50000).optional(), + // Custom CLI agent definitions for ACP + customAgents: z + .array( + z.object({ + id: z.string().max(50), + name: z.string().max(100), + binary: z.string().max(200), + versionCommand: z.string().max(300), + providerAlias: z.string().max(50), + spawnArgs: z.array(z.string().max(200)), + protocol: z.enum(["stdio", "http"]), + }) + ) + .optional(), + // SkillsMP marketplace API key + skillsmpApiKey: z.string().max(200).optional(), + // Active skills provider (single source of truth for skills page) + skillsProvider: z.enum(["skillsmp", "skillssh"]).optional(), + // models.dev sync settings + modelsDevSyncEnabled: z.boolean().optional(), + modelsDevSyncInterval: z.number().int().min(3600000).max(604800000).optional(), + // Vision Bridge settings + visionBridgeEnabled: z.boolean().optional(), + visionBridgeModel: z.string().max(200).optional(), + visionBridgePrompt: z.string().max(5000).optional(), + visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(), + visionBridgeMaxImages: z.number().int().min(1).max(20).optional(), + // Missing settings + lkgpEnabled: z.boolean().optional(), + backgroundDegradation: z.unknown().optional(), + bruteForceProtection: z.boolean().optional(), + // Auto-routing settings + autoRoutingEnabled: z.boolean().optional(), + autoRoutingDefaultVariant: z + .enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"]) + .optional(), + // LOCAL_ONLY manage-scope bypass policy (T-011). Kill-switch + per-prefix + // list, both DB-stored and hot-reloaded into `getAuthzBypassSnapshot()` on + // each PATCH. The prefix list MUST NOT include any spawn-capable path — + // enforced here and at runtime (GHSA-fhh6-4qxv-rpqj rationale). + localOnlyManageScopeBypassEnabled: z.boolean().optional(), + localOnlyManageScopeBypassPrefixes: z.array(z.string().startsWith("/")).max(20).optional(), + }) + .superRefine((data, ctx) => { + const prefixes = data.localOnlyManageScopeBypassPrefixes; + if (!prefixes) return; + for (let i = 0; i < prefixes.length; i++) { + const prefix = prefixes[i]; + // Reject prefixes that are the same as, child of, or PARENT of any + // spawn-capable prefix. The parent case catches e.g. `/api/cli-tools/` + // — a bypass on the parent would grant non-loopback access to the + // spawn-capable `/api/cli-tools/runtime/*` surface via path.startsWith. + if ( + SPAWN_CAPABLE_PREFIXES.some( + (spawn) => prefix === spawn || prefix.startsWith(spawn) || spawn.startsWith(prefix) + ) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["localOnlyManageScopeBypassPrefixes", i], + message: `BYPASS_PREFIX_NOT_ALLOWED: ${prefix} is spawn-capable and cannot be bypassed`, + params: { code: "BYPASS_PREFIX_NOT_ALLOWED", prefix }, + }); + } + } + }); export const databaseSettingsSchema = z.object( { diff --git a/src/types/settings.ts b/src/types/settings.ts index 0fb9956f68..2559944868 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -26,6 +26,12 @@ export interface Settings { hideEndpointNgrokTunnel?: boolean; hiddenSidebarItems?: HideableSidebarItemId[]; resilienceSettings?: ResilienceSettings; + // LOCAL_ONLY manage-scope bypass policy (DB-stored, hot-reloaded by + // `applyRuntimeSettings` → `applyAuthzBypassSection`). The route guard + // consults `getAuthzBypassSnapshot()` on the hot path; these fields are + // the persisted source of truth that feeds that snapshot. + localOnlyManageScopeBypassEnabled?: boolean; + localOnlyManageScopeBypassPrefixes?: string[]; } export interface ComboDefaults { diff --git a/tests/unit/_mocks/settings.ts b/tests/unit/_mocks/settings.ts new file mode 100644 index 0000000000..535f28cdf6 --- /dev/null +++ b/tests/unit/_mocks/settings.ts @@ -0,0 +1,80 @@ +/** + * Shared settings test fixture. + * + * Backs onto a real isolated SQLite DB + the production + * `updateSettings → applyRuntimeSettings` pipeline so callers exercise the + * actual hot-reload path. Tests that need to mock `getAuthzBypassSnapshot` + * directly defeat the integration value of AC-7 — use this helper instead. + * + * Usage: + * ```ts + * import { setupSettingsFixture, mockSettings, resetSettingsMock } from "../_mocks/settings"; + * const fixture = setupSettingsFixture("authz-bypass"); + * test.beforeEach(() => fixture.resetStorage()); + * await mockSettings({ localOnlyManageScopeBypassEnabled: false }); + * ``` + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { Settings } from "../../../src/types/settings"; + +let activeFixture: SettingsFixture | null = null; + +export interface SettingsFixture { + testDataDir: string; + resetStorage(): Promise; + cleanup(): void; +} + +/** + * Allocate an isolated DATA_DIR + reset DB state per test. Must run BEFORE + * any DB modules are imported by the test file. + */ +export function setupSettingsFixture(slug: string): SettingsFixture { + const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `omr-settings-mock-${slug}-`)); + process.env.DATA_DIR = testDataDir; + if (!process.env.API_KEY_SECRET) { + process.env.API_KEY_SECRET = `test-settings-mock-secret-${Date.now()}`; + } + + const fixture: SettingsFixture = { + testDataDir, + async resetStorage() { + const core = await import("../../../src/lib/db/core.ts"); + const runtime = await import("../../../src/lib/config/runtimeSettings.ts"); + core.resetDbInstance(); + runtime.resetRuntimeSettingsStateForTests(); + fs.rmSync(testDataDir, { recursive: true, force: true }); + fs.mkdirSync(testDataDir, { recursive: true }); + }, + cleanup() { + fs.rmSync(testDataDir, { recursive: true, force: true }); + }, + }; + activeFixture = fixture; + return fixture; +} + +/** + * Write a partial Settings patch through the production + * `updateSettings → applyRuntimeSettings` pipeline. Hot-reload side effects + * (route guard snapshot, etc.) fire exactly as they do in `PATCH /api/settings`. + */ +export async function mockSettings(partial: Partial): Promise> { + const settingsDb = await import("../../../src/lib/db/settings.ts"); + return settingsDb.updateSettings(partial as Record); +} + +/** + * Reset the in-process runtime snapshot to the cold-boot default. Called by + * test `beforeEach` hooks that need a clean slate without nuking the whole + * fixture directory. + */ +export async function resetSettingsMock(): Promise { + const runtime = await import("../../../src/lib/config/runtimeSettings.ts"); + runtime.resetRuntimeSettingsStateForTests(); + if (activeFixture) { + await activeFixture.resetStorage(); + } +} diff --git a/tests/unit/api/authz-inventory.test.ts b/tests/unit/api/authz-inventory.test.ts new file mode 100644 index 0000000000..e17d7bf797 --- /dev/null +++ b/tests/unit/api/authz-inventory.test.ts @@ -0,0 +1,210 @@ +/** + * T-013 — GET /api/settings/authz-inventory. + * + * Covers spec AC-1, AC-2, AC-12 (and AC-13 PATCH coverage continues to live + * in `tests/unit/settings/authz-bypass.test.ts`; this file only asserts the + * inventory endpoint itself). + * + * - AC-1 response shape: 5 tiers, each with prefixes; bypassEnabled / bypassPrefixes / spawnCapablePrefixes present. + * - AC-2 bypass-state flags match getSettings() and update after PATCH. + * - AC-12 anonymous request → 401/403 (no inventory leak). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupSettingsFixture } from "../_mocks/settings.ts"; +import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; + +const fixture = setupSettingsFixture("authz-inventory"); +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; + +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; + +const core = await import("../../../src/lib/db/core.ts"); +const settingsDb = await import("../../../src/lib/db/settings.ts"); +const runtime = await import("../../../src/lib/config/runtimeSettings.ts"); +const inventoryRoute = await import("../../../src/app/api/settings/authz-inventory/route.ts"); +const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); + +test.beforeEach(async () => { + await fixture.resetStorage(); + apiKeysDb.resetApiKeyState(); + runtime.resetRuntimeSettingsStateForTests(); +}); + +test.after(() => { + core.resetDbInstance(); + fixture.cleanup(); + if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; +}); + +// ─── AC-1 — shape ───────────────────────────────────────────────────────── + +test("AC-1: GET returns 5 tiers with prefixes + bypass state envelope", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-inventory"; + process.env.INITIAL_PASSWORD = "initial-pass-ac1"; + await settingsDb.updateSettings({ requireLogin: true }); + + const request = await makeManagementSessionRequest( + "http://localhost/api/settings/authz-inventory", + { method: "GET" } + ); + const response = await inventoryRoute.GET(request); + assert.equal(response.status, 200); + + const body = (await response.json()) as { + tiers: Array<{ name: string; prefixes: string[]; description: string; bypassable: boolean }>; + bypassEnabled: boolean; + bypassPrefixes: string[]; + spawnCapablePrefixes: string[]; + }; + + assert.equal(body.tiers.length, 5); + const names = body.tiers.map((t) => t.name).sort(); + assert.deepEqual(names, ["ALWAYS_PROTECTED", "CLIENT_API", "LOCAL_ONLY", "MANAGEMENT", "PUBLIC"]); + + const localOnly = body.tiers.find((t) => t.name === "LOCAL_ONLY"); + assert.ok(localOnly); + assert.ok(localOnly!.prefixes.includes("/api/mcp/")); + assert.ok(localOnly!.prefixes.includes("/api/cli-tools/runtime/")); + assert.equal(localOnly!.bypassable, true); + + const alwaysProtected = body.tiers.find((t) => t.name === "ALWAYS_PROTECTED"); + assert.ok(alwaysProtected); + assert.ok(alwaysProtected!.prefixes.includes("/api/shutdown")); + assert.equal(alwaysProtected!.bypassable, false); + + // Every tier carries a non-empty description. + for (const tier of body.tiers) { + assert.ok(tier.description.length > 0, `tier ${tier.name} missing description`); + } + + assert.ok(body.spawnCapablePrefixes.includes("/api/cli-tools/runtime/")); +}); + +// ─── AC-2 — flags match getSettings() pre- and post-mutation ────────────── + +test("AC-2: bypassEnabled + bypassPrefixes reflect getSettings() (defaults)", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-inventory"; + process.env.INITIAL_PASSWORD = "initial-pass-ac2a"; + await settingsDb.updateSettings({ requireLogin: true }); + + const response = await inventoryRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", { + method: "GET", + }) + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { + bypassEnabled: boolean; + bypassPrefixes: string[]; + }; + // Default snapshot: kill-switch ON, single prefix /api/mcp/. + assert.equal(body.bypassEnabled, true); + assert.deepEqual(body.bypassPrefixes, ["/api/mcp/"]); +}); + +test("AC-2: bypassEnabled flips after settings mutation", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-inventory"; + process.env.INITIAL_PASSWORD = "initial-pass-ac2b"; + await settingsDb.updateSettings({ requireLogin: true }); + + // Mutate directly through the settings DB (bypasses the password gate — + // we are not testing the gate here, only the inventory's reflection of + // the persisted state). + await settingsDb.updateSettings({ localOnlyManageScopeBypassEnabled: false }); + + const response = await inventoryRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", { + method: "GET", + }) + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { bypassEnabled: boolean }; + assert.equal(body.bypassEnabled, false); +}); + +test("AC-2: bypassPrefixes additions land in the inventory", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-inventory"; + process.env.INITIAL_PASSWORD = "initial-pass-ac2c"; + await settingsDb.updateSettings({ requireLogin: true }); + + await settingsDb.updateSettings({ + localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/mcp/v2/"], + }); + + const response = await inventoryRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings/authz-inventory", { + method: "GET", + }) + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { bypassPrefixes: string[] }; + assert.deepEqual(body.bypassPrefixes, ["/api/mcp/", "/api/mcp/v2/"]); +}); + +// ─── AC-12 — anonymous request rejected (no inventory leak) ─────────────── + +test("AC-12: anonymous request (no cookie, no Bearer) → 401", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-inventory"; + process.env.INITIAL_PASSWORD = "initial-pass-ac12"; + // Bootstrap a password so isAuthRequired() returns true even on loopback. + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + + const anonRequest = new Request("https://dashboard.example/api/settings/authz-inventory", { + method: "GET", + }); + const response = await inventoryRoute.GET(anonRequest); + assert.ok( + response.status === 401 || response.status === 403, + `expected 401/403, got ${response.status}` + ); + const body = (await response.json()) as { error?: { message?: string } }; + // Should NOT leak the inventory shape. + assert.ok(!("tiers" in body)); +}); + +test("AC-12: anonymous request with bogus Bearer → 403", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-inventory"; + process.env.INITIAL_PASSWORD = "initial-pass-ac12b"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + + const bogus = new Request("https://dashboard.example/api/settings/authz-inventory", { + method: "GET", + headers: new Headers({ authorization: "Bearer not-a-real-key" }), + }); + const response = await inventoryRoute.GET(bogus); + assert.equal(response.status, 403); +}); + +// ─── OQ-5 — any valid API key (no manage scope required) → 200 ──────────── + +test("OQ-5: any valid API key (read-only scope) → 200 inventory", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-inventory"; + process.env.INITIAL_PASSWORD = "initial-pass-oq5"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + + // Key with NO manage scope — would be rejected by /api/settings PATCH, + // but the inventory read endpoint is intentionally one rung lower (OQ-5). + const created = await apiKeysDb.createApiKey("oq5-read", "machine-oq5", []); + const request = new Request("https://dashboard.example/api/settings/authz-inventory", { + method: "GET", + headers: new Headers({ authorization: `Bearer ${created.key}` }), + }); + const response = await inventoryRoute.GET(request); + assert.equal(response.status, 200); + const body = (await response.json()) as { tiers: unknown[] }; + assert.equal(body.tiers.length, 5); +}); diff --git a/tests/unit/api/settings-audit.test.ts b/tests/unit/api/settings-audit.test.ts new file mode 100644 index 0000000000..e23296713b --- /dev/null +++ b/tests/unit/api/settings-audit.test.ts @@ -0,0 +1,307 @@ +/** + * T-012 — Settings PATCH audit log. + * + * Covers spec AC-9 / AC-10 / AC-11 (plus idempotent no-op case): + * - AC-9 success diff row carries `action=settings.update`, target, + * actor, ip, and per-key {before, after} diff for every changed key. + * - AC-10 each rejection path (PASSWORD_REQUIRED, PASSWORD_MISMATCH, + * BYPASS_PREFIX_NOT_ALLOWED, zod validation failure) writes a + * `settings.update_failed` row with the matching `reason` code and + * NEVER persists settings. + * - AC-11 every changed key shows up in the success diff — not only + * security-impacting keys. + * - Idempotent PATCH (body matches stored state) writes NO row. + * + * Runs through the real PATCH handler + `setupSettingsFixture` mock so the + * production `updateSettings → applyRuntimeSettings → logAuditEvent` pipeline + * fires exactly as it does in deployment. + * + * INSUFFICIENT_SCOPE is intentionally NOT exercised here — per spec AC-13 it + * is rejected by `requireManagementAuth` before the audit-aware handler body + * runs, so no row is written. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupSettingsFixture } from "../_mocks/settings.ts"; +import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; + +// Allocate fixture FIRST so DATA_DIR is set before any DB import resolves. +const fixture = setupSettingsFixture("settings-audit"); +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; + +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; + +const core = await import("../../../src/lib/db/core.ts"); +const settingsDb = await import("../../../src/lib/db/settings.ts"); +const runtime = await import("../../../src/lib/config/runtimeSettings.ts"); +const settingsRoute = await import("../../../src/app/api/settings/route.ts"); +const compliance = await import("../../../src/lib/compliance/index.ts"); +const managementPassword = await import("../../../src/lib/auth/managementPassword.ts"); +const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); + +test.beforeEach(async () => { + await fixture.resetStorage(); + apiKeysDb.resetApiKeyState(); + runtime.resetRuntimeSettingsStateForTests(); +}); + +test.after(() => { + core.resetDbInstance(); + fixture.cleanup(); + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; +}); + +async function bootstrapWithPassword(password: string): Promise { + process.env.JWT_SECRET = "test-jwt-secret-settings-audit"; + process.env.INITIAL_PASSWORD = password; + await settingsDb.updateSettings({ requireLogin: true }); + await managementPassword.ensurePersistentManagementPasswordHash({ + source: "test.bootstrap", + }); +} + +function settingsRows() { + // `getAuditLog`'s `AuditLogEntry[]` return type now exposes `action`, + // `actor`, `target`, `status`, `details`, etc. directly — no local cast + // needed. See src/lib/compliance/index.ts. + return compliance.getAuditLog({ target: "settings", limit: 50 }); +} + +// ─── AC-9 — success diff row written ────────────────────────────────────── + +test("AC-9: successful PATCH writes settings.update with diff of changed keys", async () => { + await bootstrapWithPassword("initial-pass-ac9"); + const before = await settingsDb.getSettings(); + assert.equal(before.localOnlyManageScopeBypassEnabled, true); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassEnabled: false, + currentPassword: "initial-pass-ac9", + }, + }) + ); + + assert.equal(response.status, 200); + + const rows = settingsRows(); + const successRows = rows.filter((r) => r.action === "settings.update"); + assert.equal(successRows.length, 1, `expected 1 success row, got: ${JSON.stringify(rows)}`); + const row = successRows[0]; + assert.equal(row.target, "settings"); + assert.equal(row.status, "success"); + assert.equal(row.resource_type, "settings"); + // Cookie session ⇒ actor=dashboard. + assert.equal(row.actor, "dashboard"); + const details = row.details as { diff: Record }; + assert.ok(details && typeof details === "object", "details must be parsed JSON"); + assert.ok(details.diff, "diff present"); + assert.deepEqual(details.diff.localOnlyManageScopeBypassEnabled, { + before: true, + after: false, + }); +}); + +// ─── AC-10 — failure rows for each rejection path ──────────────────────── + +test("AC-10a: PASSWORD_REQUIRED failure writes settings.update_failed", async () => { + await bootstrapWithPassword("initial-pass-ac10a"); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { localOnlyManageScopeBypassEnabled: false }, + }) + ); + + assert.equal(response.status, 400); + const rows = settingsRows().filter((r) => r.action === "settings.update_failed"); + assert.equal(rows.length, 1); + const details = rows[0].details as { reason: string; attempted_keys: string[] }; + assert.equal(details.reason, "PASSWORD_REQUIRED"); + assert.ok(details.attempted_keys.includes("localOnlyManageScopeBypassEnabled")); + // No raw payload values — only the key NAMES are recorded under + // `attempted_keys`. There must be no `before`/`after` or `diff` block on a + // failure row, and no other fields beyond reason+attempted_keys in details. + assert.deepEqual( + Object.keys(details).sort(), + ["attempted_keys", "reason"], + "failure details must only contain reason + attempted_keys (no payload echo)" + ); + + // Persisted state unchanged. + const after = await settingsDb.getSettings(); + assert.equal(after.localOnlyManageScopeBypassEnabled, true); +}); + +test("AC-10b: PASSWORD_MISMATCH failure writes settings.update_failed", async () => { + await bootstrapWithPassword("initial-pass-ac10b"); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassEnabled: false, + currentPassword: "definitely-wrong", + }, + }) + ); + + assert.equal(response.status, 401); + const rows = settingsRows().filter((r) => r.action === "settings.update_failed"); + assert.equal(rows.length, 1); + const details = rows[0].details as { reason: string; attempted_keys: string[] }; + assert.equal(details.reason, "PASSWORD_MISMATCH"); + // Password attempt MUST NOT leak — only key names. + const serialized = JSON.stringify(rows[0]); + assert.equal( + serialized.includes("definitely-wrong"), + false, + "rejected currentPassword must not appear in audit row" + ); + + const after = await settingsDb.getSettings(); + assert.equal(after.localOnlyManageScopeBypassEnabled, true); +}); + +test("AC-10c: BYPASS_PREFIX_NOT_ALLOWED failure writes settings.update_failed", async () => { + await bootstrapWithPassword("initial-pass-ac10c"); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"], + currentPassword: "initial-pass-ac10c", + }, + }) + ); + + assert.equal(response.status, 400); + const rows = settingsRows().filter((r) => r.action === "settings.update_failed"); + assert.equal(rows.length, 1); + const details = rows[0].details as { reason: string }; + assert.equal(details.reason, "BYPASS_PREFIX_NOT_ALLOWED"); + + // Snapshot untouched. + const after = await settingsDb.getSettings(); + assert.deepEqual(after.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]); +}); + +test("AC-10d: zod validation failure (wrong type) writes settings.update_failed", async () => { + await bootstrapWithPassword("initial-pass-ac10d"); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassEnabled: "definitely-not-a-boolean", + currentPassword: "initial-pass-ac10d", + }, + }) + ); + + assert.equal(response.status, 400); + const rows = settingsRows().filter((r) => r.action === "settings.update_failed"); + assert.equal(rows.length, 1); + const details = rows[0].details as { reason: string }; + assert.equal(details.reason, "VALIDATION_FAILED"); +}); + +// AC-13 sanity: INSUFFICIENT_SCOPE rejection happens upstream in +// requireManagementAuth and never reaches the handler body, so no audit row. +// We cover it implicitly by NOT having an INSUFFICIENT_SCOPE failure test — +// the route-level rejection is already covered by api-auth.test.ts. + +// ─── AC-11 — diff covers every changed key (not only security keys) ────── + +test("AC-11: diff records every changed key, including non-security keys", async () => { + await bootstrapWithPassword("initial-pass-ac11"); + + // Seed an initial value for a non-security key so the diff is meaningful. + await settingsDb.updateSettings({ theme: "light", instanceName: "before" }); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + theme: "dark", + instanceName: "after", + localOnlyManageScopeBypassEnabled: false, + currentPassword: "initial-pass-ac11", + }, + }) + ); + + assert.equal(response.status, 200); + const rows = settingsRows().filter((r) => r.action === "settings.update"); + assert.equal(rows.length, 1); + const details = rows[0].details as { diff: Record }; + // Security key AND multiple non-security keys must all be in diff. + assert.ok(details.diff.localOnlyManageScopeBypassEnabled, "security key in diff"); + assert.ok(details.diff.theme, "theme (non-security) in diff"); + assert.ok(details.diff.instanceName, "instanceName (non-security) in diff"); + assert.deepEqual(details.diff.theme, { before: "light", after: "dark" }); + assert.deepEqual(details.diff.instanceName, { before: "before", after: "after" }); +}); + +// ─── Idempotent no-op writes NO row ────────────────────────────────────── + +test("idempotent PATCH (body matches current state) writes NO audit row", async () => { + await bootstrapWithPassword("initial-pass-noop"); + + // Settings already at default — patch the same value back. + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassEnabled: true, // same as default + currentPassword: "initial-pass-noop", + }, + }) + ); + + assert.equal(response.status, 200); + const rows = settingsRows(); + assert.equal( + rows.length, + 0, + `idempotent PATCH must not emit an audit row, got: ${JSON.stringify(rows)}` + ); +}); + +// ─── Multi-row sanity: success + failure sequence ───────────────────────── + +test("sequence: failure then success produces exactly 1 failure row + 1 success row", async () => { + await bootstrapWithPassword("initial-pass-seq"); + + // 1) failure + await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { localOnlyManageScopeBypassEnabled: false, currentPassword: "wrong" }, + }) + ); + // 2) success + await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassEnabled: false, + currentPassword: "initial-pass-seq", + }, + }) + ); + + const rows = settingsRows(); + const failures = rows.filter((r) => r.action === "settings.update_failed"); + const successes = rows.filter((r) => r.action === "settings.update"); + assert.equal(failures.length, 1); + assert.equal(successes.length, 1); +}); diff --git a/tests/unit/authz/management-policy.test.ts b/tests/unit/authz/management-policy.test.ts index b75be34acf..e5d2961c30 100644 --- a/tests/unit/authz/management-policy.test.ts +++ b/tests/unit/authz/management-policy.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { SignJWT } from "jose"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-mgmt-policy-")); process.env.DATA_DIR = TEST_DATA_DIR; @@ -45,6 +46,24 @@ async function loadPolicy() { return mod.managementPolicy; } +async function dashboardCookieHeader(expiresIn = "1h"): Promise { + // Mirrors tests/unit/authz/pipeline.test.ts: mint a real HS256 auth_token + // JWT against process.env.JWT_SECRET so isDashboardSessionAuthenticated() + // accepts it. The header path is sufficient — the policy reads the cookie + // from `request.headers.get("cookie")` when there's no `request.cookies` + // accessor on the plain ctx() object. + assert.ok( + process.env.JWT_SECRET, + "JWT_SECRET must be set before minting dashboard cookie (otherwise TextEncoder would encode the string 'undefined' and silently mint a wrong-secret JWT)" + ); + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const token = await new SignJWT({ authenticated: true }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(expiresIn) + .sign(secret); + return `auth_token=${token}`; +} + function ctx(headers: Headers, method = "GET", path = "/api/keys") { return { request: { method, headers, url: `http://localhost${path}`, nextUrl: { pathname: path } }, @@ -189,6 +208,155 @@ test("managementPolicy: rejects invalid API keys with 403 when bearer is present } }); +// ─── LOCAL_ONLY manage-scope bypass for /api/mcp/* ─────────────────────────── +// +// `/api/mcp/*` is in LOCAL_ONLY_API_PREFIXES (because it can spawn child +// processes for unauthenticated callers) AND in +// LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES (so a manage-scoped API key +// presented from non-loopback may reach it). `/api/cli-tools/runtime/*` is +// LOCAL_ONLY but NOT bypassable — the carve-out is path-scoped. +// +// `ctx()` uses `new Headers()` without an explicit `host`, so +// `isLoopbackHost(null)` returns false → the policy treats it as non-loopback, +// which is the exact case this block exercises. + +test("LOCAL_ONLY manage-scope bypass: no Bearer + non-loopback → 403 (regression guard)", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + + const policy = await loadPolicy(); + const out = await policy.evaluate(ctx(new Headers(), "GET", "/api/mcp/stream")); + + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 403); + assert.equal(out.code, "LOCAL_ONLY"); + } +}); + +test("LOCAL_ONLY manage-scope bypass: non-manage key + non-loopback → 403", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + const created = await apiKeysDb.createApiKey("chat-only", "machine-chat-only", ["chat"]); + + const policy = await loadPolicy(); + const out = await policy.evaluate( + ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream") + ); + + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 403); + assert.equal(out.code, "LOCAL_ONLY"); + } +}); + +test("LOCAL_ONLY manage-scope bypass: manage-scope key + non-loopback → allow", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + const created = await apiKeysDb.createApiKey("mcp-bypass-key", "machine-mcp-bypass", ["manage"]); + + const policy = await loadPolicy(); + const out = await policy.evaluate( + ctx(new Headers({ authorization: `Bearer ${created.key}` }), "GET", "/api/mcp/stream") + ); + + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "management_key"); + assert.equal(out.subject.id, created.id); + assert.ok( + (out.subject.label ?? "").includes("local-only-bypass"), + `expected label to include 'local-only-bypass', got ${out.subject.label}` + ); + } +}); + +test("LOCAL_ONLY manage-scope bypass: carve-out does not extend to /api/cli-tools/runtime/*", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + const created = await apiKeysDb.createApiKey("cli-runtime-denied", "machine-cli-runtime-denied", [ + "manage", + ]); + + const policy = await loadPolicy(); + const out = await policy.evaluate( + ctx( + new Headers({ authorization: `Bearer ${created.key}` }), + "GET", + "/api/cli-tools/runtime/foo" + ) + ); + + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 403); + assert.equal(out.code, "LOCAL_ONLY"); + } +}); + +test("LOCAL_ONLY manage-scope bypass: loopback + no Bearer → allow (local CLI flow preserved)", async () => { + // Match the fresh-bootstrap pattern used by the "allows when auth not + // required" test above: no password configured + loopback request → + // `isAuthRequired` returns false → anonymous-allow fires once the LOCAL_ONLY + // gate is satisfied via the loopback `host` header. + await settingsDb.updateSettings({ requireLogin: true, password: null }); + + const policy = await loadPolicy(); + const out = await policy.evaluate( + ctx(new Headers({ host: "localhost:20128" }), "GET", "/api/mcp/stream") + ); + + assert.equal(out.allow, true); +}); + +// ─── LOCAL_ONLY dashboard-session bypass ───────────────────────────────────── +// +// Regression cover for commit ca284a91 ("refine LOCAL_ONLY bypass — dashboard +// cookie + admin label + error log"). The dashboard-session bypass mirrors the +// manage-scope bypass: an authenticated `auth_token` cookie reaching a +// bypassable LOCAL_ONLY path (e.g. /api/mcp/status) from a public hostname is +// allowed, but the cli-tools-runtime carve-out is NOT extended to it. + +test("LOCAL_ONLY dashboard-session bypass: authenticated dashboard cookie + non-loopback → allow", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + + const cookie = await dashboardCookieHeader(); + const policy = await loadPolicy(); + const out = await policy.evaluate(ctx(new Headers({ cookie }), "GET", "/api/mcp/stream")); + + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "dashboard_session"); + assert.equal(out.subject.id, "dashboard"); + assert.equal(out.subject.label, "dashboard-session-local-only-bypass"); + } +}); + +test("LOCAL_ONLY dashboard-session bypass: authenticated dashboard cookie + /api/cli-tools/runtime/ → 403 LOCAL_ONLY", async () => { + process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; + process.env.INITIAL_PASSWORD = "initial-pass"; + await settingsDb.updateSettings({ requireLogin: true }); + + const cookie = await dashboardCookieHeader(); + const policy = await loadPolicy(); + const out = await policy.evaluate( + ctx(new Headers({ cookie }), "GET", "/api/cli-tools/runtime/foo") + ); + + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 403); + assert.equal(out.code, "LOCAL_ONLY"); + } +}); + test("managementPolicy: allows internal model sync only on the dedicated provider routes", async () => { process.env.JWT_SECRET = "test-jwt-secret-for-mgmt-policy"; process.env.INITIAL_PASSWORD = "initial-pass"; diff --git a/tests/unit/authz/routeGuard.test.ts b/tests/unit/authz/routeGuard.test.ts index d9af73e666..99e6fec9e3 100644 --- a/tests/unit/authz/routeGuard.test.ts +++ b/tests/unit/authz/routeGuard.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { isLocalOnlyPath, + isLocalOnlyBypassableByManageScope, isAlwaysProtectedPath, isLoopbackHost, } from "../../../src/server/authz/routeGuard.ts"; @@ -25,6 +26,19 @@ test("isLocalOnlyPath: regular management routes are not local-only", () => { assert.equal(isLocalOnlyPath("/api/providers"), 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); }); diff --git a/tests/unit/db/api-keys.test.ts b/tests/unit/db/api-keys.test.ts new file mode 100644 index 0000000000..3c01698b56 --- /dev/null +++ b/tests/unit/db/api-keys.test.ts @@ -0,0 +1,242 @@ +/** + * Unit coverage for the api_keys DB layer — focused on the `scopes` column + * and the audit-event emission contract for privileged scope changes. + * + * The fixtures here intentionally mirror tests/unit/api-auth.test.ts so the + * two suites share the same bootstrap shape (isolated DATA_DIR, fresh DB per + * test, env reset). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-api-keys-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-api-key-secret"; + +const core = await import("../../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); +const compliance = await import("../../../src/lib/compliance/index.ts"); +const { hasManageScope } = await import("../../../src/shared/constants/managementScopes.ts"); + +const MACHINE_ID = "machine1234567890"; + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// createApiKey + scopes round-trip +// ───────────────────────────────────────────────────────────────────────────── + +test("createApiKey persists scopes to the api_keys row", async () => { + const created = await apiKeysDb.createApiKey("with-manage", MACHINE_ID, ["manage"]); + assert.ok(created.id); + assert.ok(created.key); + assert.deepEqual(created.scopes, ["manage"]); + + // Verify the row hit the DB by reading raw column. + const db = core.getDbInstance() as unknown as { + prepare: (sql: string) => { get: (id: string) => { scopes: string | null } | undefined }; + }; + const row = db.prepare("SELECT scopes FROM api_keys WHERE id = ?").get(created.id); + assert.equal(row?.scopes, JSON.stringify(["manage"])); +}); + +test("createApiKey with default scopes writes an empty JSON array", async () => { + const created = await apiKeysDb.createApiKey("no-scope", MACHINE_ID); + const db = core.getDbInstance() as unknown as { + prepare: (sql: string) => { get: (id: string) => { scopes: string | null } | undefined }; + }; + const row = db.prepare("SELECT scopes FROM api_keys WHERE id = ?").get(created.id); + assert.equal(row?.scopes, "[]"); +}); + +test("getApiKeyMetadata returns the scopes for a key created with manage", async () => { + const created = await apiKeysDb.createApiKey("metadata-readback", MACHINE_ID, ["manage"]); + const meta = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.deepEqual(meta!.scopes, ["manage"]); + assert.equal(hasManageScope(meta!.scopes), true); +}); + +test("getApiKeyMetadata returns an empty scopes array for a key created without scopes", async () => { + const created = await apiKeysDb.createApiKey("no-manage", MACHINE_ID); + const meta = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.deepEqual(meta!.scopes, []); + assert.equal(hasManageScope(meta!.scopes), false); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Legacy NULL scopes (pre-migration-032 row simulation) +// ───────────────────────────────────────────────────────────────────────────── + +test("legacy rows with NULL scopes parse to an empty array and never hold manage", async () => { + const created = await apiKeysDb.createApiKey("legacy-null", MACHINE_ID); + // Simulate a pre-migration row by force-NULL on the scopes column. + const db = core.getDbInstance() as unknown as { + prepare: (sql: string) => { run: (...args: unknown[]) => unknown }; + }; + db.prepare("UPDATE api_keys SET scopes = NULL WHERE id = ?").run(created.id); + apiKeysDb.clearApiKeyCaches(); + + const meta = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.deepEqual(meta!.scopes, []); + assert.equal(hasManageScope(meta!.scopes), false); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// updateApiKeyPermissions — audit events for scope changes +// ───────────────────────────────────────────────────────────────────────────── + +test("updateApiKeyPermissions granting manage emits apiKey.scopes.grant", async () => { + const created = await apiKeysDb.createApiKey("for-grant", MACHINE_ID); + + const before = compliance.getAuditLog({ limit: 100 }); + const beforeGrant = before.filter( + (e) => e.action === "apiKey.scopes.grant" && e.target === created.id + ); + assert.equal(beforeGrant.length, 0); + + const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] }); + assert.equal(ok, true); + + const after = compliance.getAuditLog({ limit: 100 }); + const grants = after.filter((e) => e.action === "apiKey.scopes.grant" && e.target === created.id); + assert.equal(grants.length, 1, "expected exactly one grant audit event"); + + // Confirm the round-trip — manage should now be on the key. + const meta = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.equal(hasManageScope(meta!.scopes), true); +}); + +test("updateApiKeyPermissions revoking manage emits apiKey.scopes.revoke", async () => { + const created = await apiKeysDb.createApiKey("for-revoke", MACHINE_ID, ["manage"]); + + const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: [] }); + assert.equal(ok, true); + + const after = compliance.getAuditLog({ limit: 100 }); + const revokes = after.filter( + (e) => e.action === "apiKey.scopes.revoke" && e.target === created.id + ); + assert.equal(revokes.length, 1, "expected exactly one revoke audit event"); + + const meta = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.equal(hasManageScope(meta!.scopes), false); +}); + +test("updateApiKeyPermissions setting same manage scope does not emit duplicate audit events", async () => { + const created = await apiKeysDb.createApiKey("idempotent-manage", MACHINE_ID, ["manage"]); + + const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] }); + assert.equal(ok, true); + + const after = compliance.getAuditLog({ limit: 100 }); + const scopeEvents = after.filter( + (e) => + (e.action === "apiKey.scopes.grant" || + e.action === "apiKey.scopes.revoke" || + e.action === "apiKey.scopes.update") && + e.target === created.id + ); + assert.equal( + scopeEvents.length, + 0, + "no-op scope update should not emit grant/revoke/update events" + ); +}); + +test("updateApiKeyPermissions changing non-manage scopes emits apiKey.scopes.update", async () => { + const created = await apiKeysDb.createApiKey("non-manage-update", MACHINE_ID, []); + + const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["read:logs"] }); + assert.equal(ok, true); + + const after = compliance.getAuditLog({ limit: 100 }); + const updates = after.filter( + (e) => e.action === "apiKey.scopes.update" && e.target === created.id + ); + assert.equal(updates.length, 1, "expected exactly one non-manage scope update event"); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Regression — scopes and ban state are orthogonal (T-008) +// +// The Permissions modal previously had two chips bound to `isBanned` (one +// labelled "Management API Access" with manage-scope copy). A user toggling +// it expected manage-scope grant; instead it flipped the ban flag. These +// tests guard against the inverse cross-wire ever returning: updating scopes +// must not touch isBanned, and toggling isBanned must not touch scopes. +// ───────────────────────────────────────────────────────────────────────────── + +test("updating scopes to manage leaves isBanned untouched", async () => { + const created = await apiKeysDb.createApiKey("banned-then-manage", MACHINE_ID, []); + const banOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: true }); + assert.equal(banOk, true); + + const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { scopes: ["manage"] }); + assert.equal(ok, true); + + const meta = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.deepEqual(meta!.scopes, ["manage"]); + assert.equal(meta!.isBanned, true, "ban flag must survive a scopes-only update"); +}); + +test("toggling isBanned does not touch scopes", async () => { + const created = await apiKeysDb.createApiKey("manage-then-ban", MACHINE_ID, ["manage"]); + + const banOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: true }); + assert.equal(banOk, true); + + const meta = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta); + assert.deepEqual(meta!.scopes, ["manage"], "scopes must survive a ban-only update"); + assert.equal(meta!.isBanned, true); + + const unbanOk = await apiKeysDb.updateApiKeyPermissions(created.id, { isBanned: false }); + assert.equal(unbanOk, true); + + const meta2 = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(meta2); + assert.deepEqual(meta2!.scopes, ["manage"], "scopes must survive an unban update"); + assert.equal(meta2!.isBanned, false); +}); + +test("updateApiKeyPermissions without scopes field does not emit any scope audit event", async () => { + const created = await apiKeysDb.createApiKey("no-scope-change", MACHINE_ID); + + const ok = await apiKeysDb.updateApiKeyPermissions(created.id, { name: "renamed" }); + assert.equal(ok, true); + + const after = compliance.getAuditLog({ limit: 100 }); + const scopeEvents = after.filter( + (e) => + (e.action === "apiKey.scopes.grant" || + e.action === "apiKey.scopes.revoke" || + e.action === "apiKey.scopes.update") && + e.target === created.id + ); + assert.equal(scopeEvents.length, 0); +}); diff --git a/tests/unit/settings-route-password.test.ts b/tests/unit/settings-route-password.test.ts index 3b0fee671a..32f967f2ce 100644 --- a/tests/unit/settings-route-password.test.ts +++ b/tests/unit/settings-route-password.test.ts @@ -79,5 +79,8 @@ test("settings route password update rejects the wrong current password after mi ); assert.equal(response.status, 401); - assert.deepEqual(await response.json(), { error: "Invalid current password" }); + // T-011 unified security-impacting gate: structured error code. + assert.deepEqual(await response.json(), { + error: { code: "PASSWORD_MISMATCH", message: "Invalid current password" }, + }); }); diff --git a/tests/unit/settings/authz-bypass.test.ts b/tests/unit/settings/authz-bypass.test.ts new file mode 100644 index 0000000000..1ba6302764 --- /dev/null +++ b/tests/unit/settings/authz-bypass.test.ts @@ -0,0 +1,286 @@ +/** + * T-011 — DB-stored authz bypass policy + hot-reload. + * + * Covers spec AC-3 through AC-8: + * - AC-3 kill-switch flip → bypass disabled → /api/mcp/ from non-loopback → 403 + * - AC-4 PATCH missing currentPassword → 400 PASSWORD_REQUIRED + * - AC-5 wrong currentPassword → 401 PASSWORD_MISMATCH + * - AC-6 toggle list reflects DB after PATCH + * - AC-7 add a new prefix → persists + applyRuntimeSettings fires + snapshot reflects + * - AC-8 add /api/cli-tools/runtime/ → 400 BYPASS_PREFIX_NOT_ALLOWED, snapshot unchanged + * + * Goes through the production `updateSettings → applyRuntimeSettings` and + * the real PATCH route handler — no direct `getAuthzBypassSnapshot` mocks. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { setupSettingsFixture, mockSettings } from "../_mocks/settings.ts"; +import { makeManagementSessionRequest } from "../../helpers/managementSession.ts"; + +// Allocate fixture FIRST so DATA_DIR is set before any DB import resolves. +const fixture = setupSettingsFixture("authz-bypass"); +// API-key auth check uses a Redis-backed cache otherwise — disable so +// isValidApiKey() does not stall on ETIMEDOUT in the local test loop. +process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1"; + +const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD; +const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET; + +const core = await import("../../../src/lib/db/core.ts"); +const settingsDb = await import("../../../src/lib/db/settings.ts"); +const runtime = await import("../../../src/lib/config/runtimeSettings.ts"); +const routeGuard = await import("../../../src/server/authz/routeGuard.ts"); +const settingsRoute = await import("../../../src/app/api/settings/route.ts"); +const apiKeysDb = await import("../../../src/lib/db/apiKeys.ts"); + +test.beforeEach(async () => { + await fixture.resetStorage(); + apiKeysDb.resetApiKeyState(); + // Force the route guard to start each test from cold-boot default + // (enabled=true, prefixes=["/api/mcp/"]). + runtime.resetRuntimeSettingsStateForTests(); +}); + +test.after(() => { + core.resetDbInstance(); + fixture.cleanup(); + if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD; + else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD; + if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET; + else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET; +}); + +function nonLoopbackCtx(headers: Headers, path = "/api/mcp/stream") { + return { + request: { + method: "GET", + headers, + url: `https://dashboard.example${path}`, + nextUrl: { pathname: path }, + }, + classification: { + routeClass: "MANAGEMENT" as const, + normalizedPath: path, + reason: "management_api" as const, + }, + requestId: "req_authz_bypass_test", + }; +} + +// ─── AC-3 — kill-switch flips bypass off → request 403 ─────────────────── + +test("AC-3: kill-switch off → /api/mcp/* with manage-scope Bearer from non-loopback → 403 LOCAL_ONLY", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass"; + // Seed DB with default snapshot first (kill-switch ON) and confirm the + // bypass works. + await mockSettings({ requireLogin: true }); + const managePolicy = await import("../../../src/server/authz/policies/management.ts"); + const created = await apiKeysDb.createApiKey("ac3-mgmt", "machine-ac3", ["manage"]); + + const headers = new Headers({ authorization: `Bearer ${created.key}` }); + + // Sanity: bypass ENABLED → 200/allow. + const before = await managePolicy.managementPolicy.evaluate(nonLoopbackCtx(headers)); + assert.equal(before.allow, true, "default kill-switch ON should allow manage-scope bypass"); + + // Flip the kill-switch off via the production pipeline. + await mockSettings({ localOnlyManageScopeBypassEnabled: false }); + assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/mcp/stream"), false); + + // After the hot-reload, the policy must reject. + const after = await managePolicy.managementPolicy.evaluate(nonLoopbackCtx(headers)); + assert.equal(after.allow, false); + if (!after.allow) { + assert.equal(after.status, 403); + assert.equal(after.code, "LOCAL_ONLY"); + } +}); + +// ─── AC-4 — missing currentPassword → 400 PASSWORD_REQUIRED ────────────── + +test("AC-4: PATCH missing currentPassword for security-impacting key → 400 PASSWORD_REQUIRED", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass-ac4"; + // Bootstrap a password so the cold-boot exception does NOT fire. + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { localOnlyManageScopeBypassEnabled: false }, + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as { error: { code: string; keys: string[] } }; + assert.equal(body.error.code, "PASSWORD_REQUIRED"); + assert.ok(body.error.keys.includes("localOnlyManageScopeBypassEnabled")); + + // Persisted state unchanged — kill-switch still on by default. + const settings = await settingsDb.getSettings(); + assert.equal(settings.localOnlyManageScopeBypassEnabled, true); +}); + +// ─── AC-5 — wrong currentPassword → 401 PASSWORD_MISMATCH ──────────────── + +test("AC-5: PATCH wrong currentPassword for security-impacting key → 401 PASSWORD_MISMATCH", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass-ac5"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassEnabled: false, + currentPassword: "definitely-wrong", + }, + }) + ); + + assert.equal(response.status, 401); + const body = (await response.json()) as { error: { code: string } }; + assert.equal(body.error.code, "PASSWORD_MISMATCH"); + + const settings = await settingsDb.getSettings(); + assert.equal(settings.localOnlyManageScopeBypassEnabled, true); +}); + +// ─── AC-6 — bypass list reflects DB after PATCH ────────────────────────── + +test("AC-6: PATCH with correct currentPassword + new prefix list → persists to DB", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass-ac6"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassPrefixes: ["/api/mcp/"], + currentPassword: "initial-pass-ac6", + }, + }) + ); + + assert.equal(response.status, 200); + const settings = await settingsDb.getSettings(); + assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]); +}); + +// ─── AC-7 — add prefix → applyRuntimeSettings fires + snapshot reflects ── + +test("AC-7: PATCH adds prefix → applyRuntimeSettings fires + getAuthzBypassSnapshot reflects (hot-reload <50ms)", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass-ac7"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + // Prime the snapshot from the persisted defaults so we measure a real diff. + const seeded = await settingsDb.getSettings(); + await runtime.applyRuntimeSettings(seeded); + + const before = routeGuard.LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES; + assert.deepEqual([...before], ["/api/mcp/"]); + + // Measure the snapshot-read latency (spec SLA: <50 ms). + const t0 = process.hrtime.bigint(); + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/mcp/v2/"], + currentPassword: "initial-pass-ac7", + }, + }) + ); + const snapshotAfterPatch = runtime.getAuthzBypassSnapshot(); + const elapsedNs = process.hrtime.bigint() - t0; + + assert.equal(response.status, 200); + assert.deepEqual(snapshotAfterPatch.prefixes, ["/api/mcp/", "/api/mcp/v2/"]); + assert.equal(snapshotAfterPatch.enabled, true); + // The hot-path accessor itself is O(1) — total PATCH→snapshot covers + // bcrypt + SQLite write, so we only assert the in-memory accessor is fast. + // Microbench: dedicated snapshot read. + const tSnap0 = process.hrtime.bigint(); + for (let i = 0; i < 10_000; i++) runtime.getAuthzBypassSnapshot(); + const snapElapsedNs = process.hrtime.bigint() - tSnap0; + assert.ok( + snapElapsedNs < 50_000_000n, + `getAuthzBypassSnapshot x10k must complete in <50 ms (got ${Number(snapElapsedNs) / 1e6} ms)` + ); + // Whole PATCH < 5 s sanity bound (bcrypt-bounded). + assert.ok(elapsedNs < 5_000_000_000n); + + // Live route-guard predicate reflects the new prefix. + assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/mcp/v2/foo"), true); +}); + +// ─── AC-8 — spawn-capable prefix → 400 BYPASS_PREFIX_NOT_ALLOWED ───────── + +test("AC-8: PATCH with /api/cli-tools/runtime/ in bypass list → 400 BYPASS_PREFIX_NOT_ALLOWED + snapshot unchanged", async () => { + process.env.JWT_SECRET = "test-jwt-secret-authz-bypass"; + process.env.INITIAL_PASSWORD = "initial-pass-ac8"; + await settingsDb.updateSettings({ requireLogin: true }); + const { ensurePersistentManagementPasswordHash } = + await import("../../../src/lib/auth/managementPassword.ts"); + await ensurePersistentManagementPasswordHash({ source: "test.bootstrap" }); + // Prime snapshot from default DB state. + const seeded = await settingsDb.getSettings(); + await runtime.applyRuntimeSettings(seeded); + const snapshotBefore = runtime.getAuthzBypassSnapshot(); + + const response = await settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { + localOnlyManageScopeBypassPrefixes: ["/api/mcp/", "/api/cli-tools/runtime/"], + currentPassword: "initial-pass-ac8", + }, + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as { + error: { details?: Array<{ field: string; message: string }> }; + }; + // zod path is the array path; the message embeds the BYPASS_PREFIX_NOT_ALLOWED code. + const offending = body.error.details?.find((d) => + d.message.includes("BYPASS_PREFIX_NOT_ALLOWED") + ); + assert.ok(offending, `expected BYPASS_PREFIX_NOT_ALLOWED in details: ${JSON.stringify(body)}`); + + // Persisted state untouched. + const settings = await settingsDb.getSettings(); + assert.deepEqual(settings.localOnlyManageScopeBypassPrefixes, ["/api/mcp/"]); + // Runtime snapshot untouched. + const snapshotAfter = runtime.getAuthzBypassSnapshot(); + assert.deepEqual(snapshotAfter.prefixes, snapshotBefore.prefixes); + assert.equal(snapshotAfter.enabled, snapshotBefore.enabled); +}); + +// ─── Defence-in-depth: snapshot mutation alone cannot grant spawn bypass ─ + +test("Defence-in-depth: even if a malformed snapshot lists /api/cli-tools/runtime/, the runtime predicate rejects it", async () => { + // applyRuntimeSettings wires the snapshot through normalizeAuthzBypass, + // which does not filter spawn-capable entries (zod is the gate). The + // routeGuard predicate must still refuse them at runtime. + await runtime.applyRuntimeSettings({ + localOnlyManageScopeBypassEnabled: true, + localOnlyManageScopeBypassPrefixes: ["/api/cli-tools/runtime/"], + }); + + assert.equal(routeGuard.isLocalOnlyBypassableByManageScope("/api/cli-tools/runtime/foo"), false); +});