feat(authz): manage-scope API keys may reach /api/mcp/* from non-loopback (#2473)

feat(authz): manage-scope API keys may reach /api/mcp/* from non-loopback — integrated into release/v3.8.2
This commit is contained in:
M.M
2026-05-21 23:23:39 +02:00
committed by GitHub
parent f2ce32aa8b
commit fb83be9311
28 changed files with 3015 additions and 318 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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 <api-key>` 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`.

View File

@@ -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 <api-key>` 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,

View File

@@ -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 <api-key>` 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

View File

@@ -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<string | null>(null);
const [editingKey, setEditingKey] = useState<ApiKey | null>(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() {
/>
<p className="text-xs text-text-muted mt-1.5">{t("keyNameDesc")}</p>
</div>
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("managementAccess")}</p>
<p className="text-xs text-text-muted">{t("managementAccessDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={newKeyManageEnabled}
onClick={() => setNewKeyManageEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
newKeyManageEnabled
? "bg-rose-500/15 text-rose-700 dark:text-rose-300 border border-rose-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">admin_panel_settings</span>
{newKeyManageEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{createError && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/30">
<span className="material-symbols-outlined text-red-500 text-sm">error</span>
@@ -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"}
</button>
</div>
{/* Management API Access Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("managementApiAccess")}</p>
<p className="text-xs text-text-muted">
Allow this key to call management routes (providers, combos, settings) via{" "}
<code className="font-mono">Authorization: Bearer</code>. Use for LLM agents only.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={keyIsBanned}
onClick={() => setKeyIsBanned((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-bold transition-colors ${
keyIsBanned
? "bg-red-600 text-white shadow-lg shadow-red-500/20"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">gavel</span>
{keyIsBanned ? "BANNED" : "UNBANNED"}
</button>
</div>
{/* Expiration Date */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">

View File

@@ -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<string>,
bypassPrefixes: ReadonlyArray<string>,
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<InventoryPayload | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// Draft state — only persisted on Save (security-impacting fields require
// a password re-prompt before the PATCH is fired).
const [draftEnabled, setDraftEnabled] = useState<boolean>(true);
const [draftPrefixes, setDraftPrefixes] = useState<string[]>([]);
const [newPrefixInput, setNewPrefixInput] = useState("");
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [status, setStatus] = useState<StatusMessage | null>(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 (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-info/10 text-info">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
shield_lock
</span>
</div>
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
</div>
<p className="text-sm text-text-muted">{t("authz.loading")}</p>
</Card>
);
}
if (loadError || !inventory) {
return (
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-info/10 text-info">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
shield_lock
</span>
</div>
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
</div>
<p className="text-sm text-red-500">{loadError ?? t("authz.loadError")}</p>
</Card>
);
}
return (
<>
{/* Authz header + tier inventory */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-info/10 text-info">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
shield_lock
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
<p className="text-sm text-text-muted">{t("authz.description")}</p>
</div>
</div>
<div className="flex flex-col gap-4">
{inventory.tiers.map((tier) => (
<div
key={tier.name}
className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-4"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h4 className="font-semibold">{t(`authz.tier.${tier.name}`)}</h4>
{tier.bypassable && (
<Badge variant="warning" size="sm">
{t("authz.badge.bypassable")}
</Badge>
)}
</div>
</div>
<p className="text-sm text-text-muted mb-3">{tier.description}</p>
<ul className="flex flex-col gap-2">
{tier.prefixes.map((prefix) => {
const badge = tierBadgeVariant(
tier.name,
prefix,
inventory.spawnCapablePrefixes,
inventory.bypassPrefixes,
inventory.bypassEnabled
);
return (
<li
key={`${tier.name}:${prefix}`}
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
>
<code className="text-xs font-mono">{prefix}</code>
<Badge variant={badge.variant} size="sm">
{t(`authz.badge.${badge.key}`)}
</Badge>
</li>
);
})}
</ul>
</div>
))}
</div>
</Card>
{/* Bypass policy editor */}
<Card>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
tune
</span>
</div>
<h3 className="text-lg font-semibold">{t("authz.bypass.section")}</h3>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<p className="font-medium">{t("authz.bypass.kill_switch.label")}</p>
<p className="text-sm text-text-muted">{t("authz.bypass.kill_switch.desc")}</p>
</div>
<Toggle
checked={draftEnabled}
onChange={() => setDraftEnabled((prev) => !prev)}
disabled={submitting}
/>
</div>
<div className="flex flex-col gap-3">
<div>
<p className="font-medium">{t("authz.bypass.prefix.label")}</p>
<p className="text-sm text-text-muted">{t("authz.bypass.prefix.desc")}</p>
</div>
{draftPrefixes.length === 0 && (
<p className="text-sm text-text-muted italic">{t("authz.bypass.prefix.empty")}</p>
)}
<ul className="flex flex-col gap-2">
{draftPrefixes.map((prefix) => {
const locked = isSpawnCapable(prefix);
return (
<li
key={prefix}
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
>
<div className="flex flex-col">
<code className="text-xs font-mono">{prefix}</code>
{locked && (
<span className="text-[10px] text-red-500 mt-1">
{t("authz.bypass.cli_tools_runtime_note")}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemovePrefix(prefix)}
disabled={locked || submitting}
>
Remove
</Button>
</li>
);
})}
{/* 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) => (
<li
key={`locked:${prefix}`}
className="flex items-center justify-between gap-3 rounded-md border border-red-500/30 bg-red-500/[0.04] px-3 py-2 opacity-80"
>
<div className="flex flex-col">
<code className="text-xs font-mono">{prefix}</code>
<span className="text-[10px] text-red-500 mt-1">
{t("authz.bypass.cli_tools_runtime_note")}
</span>
</div>
<Badge variant="error" size="sm">
{t("authz.badge.spawn_capable")}
</Badge>
</li>
))}
</ul>
<div className="flex gap-2 items-end pt-2">
<Input
type="text"
label={t("authz.bypass.prefix.add")}
placeholder={t("authz.bypass.prefix.placeholder")}
value={newPrefixInput}
onChange={(e) => setNewPrefixInput(e.target.value)}
disabled={submitting}
/>
<Button
variant="secondary"
onClick={handleAddPrefix}
disabled={!newPrefixInput.trim() || submitting}
>
{t("authz.bypass.prefix.add")}
</Button>
</div>
</div>
{/* Save bar */}
<div className="flex items-center justify-between gap-4 pt-4 mt-4 border-t border-border/50">
<div className="text-sm">
{dirty && (
<span className="text-amber-600 dark:text-amber-400">{t("authz.pending")}</span>
)}
{status && (
<span
className={`ml-3 ${status.type === "error" ? "text-red-500" : "text-green-500"}`}
>
{status.message}
</span>
)}
</div>
<Button variant="primary" onClick={handleSaveRequest} disabled={!dirty || submitting}>
{t("authz.save")}
</Button>
</div>
</Card>
{/* Password re-auth modal — fires for every security-impacting PATCH */}
<Modal
isOpen={passwordModalOpen}
onClose={() => {
if (!submitting) {
setPasswordModalOpen(false);
setCurrentPassword("");
}
}}
title={t("authz.password.prompt.label")}
footer={
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setPasswordModalOpen(false);
setCurrentPassword("");
}}
disabled={submitting}
>
{t("authz.password.cancel")}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
loading={submitting}
disabled={!currentPassword || submitting}
>
{t("authz.password.submit")}
</Button>
</div>
}
>
<div className="flex flex-col gap-3">
<p className="text-sm text-text-muted">{t("authz.password.prompt.desc")}</p>
<Input
type="password"
placeholder={t("authz.password.placeholder")}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoFocus
/>
{status?.type === "error" && <p className="text-sm text-red-500">{status.message}</p>}
</div>
</Modal>
</>
);
}

View File

@@ -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() {
<SessionInfoCard />
<IPFilterSection />
<AuthzSection />
</div>
);
}

View File

@@ -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<string> = [
"/api/settings",
"/api/providers/",
"/api/api-keys",
];
const CLIENT_API_TIER_PREFIXES: ReadonlyArray<string> = ["/v1/", "/api/v1/"];
const PUBLIC_TIER_PREFIXES: ReadonlyArray<string> = ["/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<Response | null> {
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 });
}
}

View File

@@ -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:<id>"` 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<string> {
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<string, unknown>,
after: Record<string, unknown>,
candidateKeys: string[]
): Record<string, { before: unknown; after: unknown }> {
const diff: Record<string, { before: unknown; after: unknown }> = {};
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<string, unknown> | 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<string, unknown> = {};
try {
const rawBody = await request.json();
rawBody = (await request.json()) as Record<string, unknown>;
} 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<string, unknown>;
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<string, unknown>;
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) {

View File

@@ -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",

View File

@@ -57,6 +57,11 @@ type AuditLogFilter = {
};
type AuditLogRow = Record<string, unknown> & {
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<string, unknown> & {
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<string, unknown>` 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<string, unknown> & {
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<string, string> = {
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 [];

View File

@@ -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<string, string>;
@@ -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<string, unknown>): 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<string, unknown>
): 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;
}

View File

@@ -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<string, unknown>).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",

View File

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

View File

@@ -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)) {

View File

@@ -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<string> = [
@@ -19,6 +30,34 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/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<string> = ["/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<string> = ["/api/mcp/"];
export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
"/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));
}

View File

@@ -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[] = [

View File

@@ -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(
{

View File

@@ -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 {

View File

@@ -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<void>;
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<Settings>): Promise<Record<string, unknown>> {
const settingsDb = await import("../../../src/lib/db/settings.ts");
return settingsDb.updateSettings(partial as Record<string, unknown>);
}
/**
* 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<void> {
const runtime = await import("../../../src/lib/config/runtimeSettings.ts");
runtime.resetRuntimeSettingsStateForTests();
if (activeFixture) {
await activeFixture.resetStorage();
}
}

View File

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

View File

@@ -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<void> {
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<string, { before: unknown; after: unknown }> };
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<string, { before: unknown; after: unknown }> };
// 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);
});

View File

@@ -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<string> {
// 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";

View File

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

View File

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

View File

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

View File

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