From fd6e875bd75352bbb95c2355433b7e7f39e7d849 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Sat, 9 May 2026 23:56:16 +1000 Subject: [PATCH] fix(security): /api/usage default scope = self; admin all-keys requires ?all=true (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Identified during privacy/security audit (.claude/research/ocp-security-audit.md §3 "/api/usage 全量泄露所有 key 时序"). Previously, any caller authenticated as admin received the full per-key usage byKey + recent + timeline block, which means a brief admin-token compromise exposes every household member's request volume, timing, and which-model — metadata, but sensitive metadata. This is a deliberate breaking change for least-privilege. Behavior matrix (post-change): Caller | Default scope | ?all=true ----------------------------------------|-------------------|--------------------- anonymous (PROXY_ANONYMOUS_KEY) | own ("anonymous") | ignored (still own) authenticated non-admin key | own (key.name) | ignored (still own) admin (no flag) | own ("admin") | n/a admin with ?all=true | n/a | full byKey/recent localhost-no-token / "local" | own ("local") | full (isAdmin=true) Response shape is unchanged except for an additional advisory `scope` object (`{self, all}`); existing fields keep their structure so dashboards and scripts continue to parse cleanly — they just see less data unless they opt in. Audit-friendly addition: when admin opts into `?all=true`, the server now emits `logEvent("info", "admin_usage_full_scope", { caller, ip })` so every privilege-escalation moment is recorded. Backward-compat warning for admin scripts: existing cron jobs / scripts that call `/api/usage` to inventory all keys must add `?all=true` after this change. Default-self is the new safe behavior. Dashboard (dashboard.html): adds a "Show all keys" checkbox in the Usage by Key section. Hidden by default; revealed only when refreshKeys() succeeds (same admin gate as the keys-management section). Toggle state persists in localStorage as `ocp_usage_show_all`. cli.js citation --------------- This is OCP-internal admin API behavior — there is no `cli.js` operation to cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations that exist in `cli.js`) does not constrain access-control rules for the OCP server's own admin endpoints. Smoke test (isolated test server on :3489, prod :3478 untouched): - node --check server.mjs SYNTAX_OK - npm test 43/43 passed - alignment.yml blacklist grep BLACKLIST_CLEAR - LAN scope matrix alice/bob own only; alice ?all=true denied; anon ?all=true denied; admin all=true full + audit log emitted Iron Rule 10 ------------ Author cannot self-approve. A separate fresh-context reviewer must read the diff and confirm the cli.js-not-applicable statement is correct before merge. Co-authored-by: dtzp555 Co-authored-by: Claude Opus 4.7 --- dashboard.html | 23 +++++++++++++++++++++-- server.mjs | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/dashboard.html b/dashboard.html index 45ef72c..98750d9 100644 --- a/dashboard.html +++ b/dashboard.html @@ -55,7 +55,13 @@
-

Usage by Key

+
+

Usage by Key

+ +
@@ -170,7 +176,8 @@ async function refreshStatus() { async function refreshUsage() { try { - const data = await api("/api/usage"); + const showAll = localStorage.getItem("ocp_usage_show_all") === "1"; + const data = await api(showAll ? "/api/usage?all=true" : "/api/usage"); const tbody = document.querySelector("#key-usage-table tbody"); tbody.innerHTML = (data.byKey || []).map(k => ` @@ -204,6 +211,8 @@ async function refreshKeys() { try { const data = await api("/api/keys"); document.getElementById("key-mgmt-section").style.display = ""; + // Admin-only "Show all keys" toggle for /api/usage scope. + document.getElementById("usage-scope-toggle").style.display = "flex"; const tbody = document.querySelector("#keys-table tbody"); tbody.innerHTML = (data.keys || []).map(k => ` @@ -240,6 +249,16 @@ async function refreshAll() { document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`; } +// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()). +(function setupUsageScopeToggle() { + const cb = document.getElementById("usage-show-all"); + cb.checked = localStorage.getItem("ocp_usage_show_all") === "1"; + cb.addEventListener("change", () => { + localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0"); + refreshUsage(); + }); +})(); + refreshAll(); setInterval(refreshAll, 30000); diff --git a/server.mjs b/server.mjs index 635c323..6e48a47 100644 --- a/server.mjs +++ b/server.mjs @@ -1672,14 +1672,45 @@ const server = createServer(async (req, res) => { } if (req.url?.startsWith("/api/usage") && req.method === "GET") { - if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); + // Least-privilege scope rules (security audit follow-up): + // - non-admin authenticated key → only own rows + // - anonymous (PROXY_ANONYMOUS_KEY) → only "anonymous" rows; ?all=true ignored + // - admin without ?all=true → only own ("admin") rows + // - admin with ?all=true → full byKey/recent (legacy behavior); audited + // Authenticated callers are required (anyone reaching here passed the auth gate above); + // remote+no-auth requests would have been rejected before this point. const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`); const since = url.searchParams.get("since"); const until = url.searchParams.get("until"); + const wantAll = url.searchParams.get("all") === "true"; + const callerName = req._authKeyName; + + // Anonymous callers may never opt into all-keys view, even if they pass ?all=true. + const isAnonCaller = callerName === "anonymous"; + const fullScope = isAdmin && wantAll && !isAnonCaller; + + // scopeName === null when fullScope is true (no filter); otherwise the key_name to filter by. + const scopeName = fullScope ? null : callerName; + + if (fullScope) { + logEvent("info", "admin_usage_full_scope", { caller: callerName, ip: req.socket.remoteAddress || null }); + } + + const byKeyAll = getUsageByKey({ since, until }); + const recentAll = getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)); + const timeline = getUsageTimeline({ + keyName: scopeName || undefined, + hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720), + }); + + const byKey = scopeName ? byKeyAll.filter((row) => row.key_name === scopeName) : byKeyAll; + const recent = scopeName ? recentAll.filter((row) => row.key_name === scopeName) : recentAll; + return jsonResponse(res, 200, { - byKey: getUsageByKey({ since, until }), - timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }), - recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)), + byKey, + timeline, + recent, + scope: { self: scopeName, all: fullScope }, }); }
KeyRequestsOKErrAvg TimeLast Request