mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
fix(security): /api/usage default scope = self; admin all-keys requires ?all=true (#88)
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 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+21
-2
@@ -55,7 +55,13 @@
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Usage by Key</h2>
|
||||
<div class="flex" style="justify-content: space-between; align-items: center;">
|
||||
<h2 style="margin: 0;">Usage by Key</h2>
|
||||
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
|
||||
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
|
||||
<span>Show all keys</span>
|
||||
</label>
|
||||
</div>
|
||||
<table id="key-usage-table">
|
||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
@@ -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 => `
|
||||
<tr>
|
||||
@@ -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 => `
|
||||
<tr>
|
||||
@@ -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);
|
||||
</script>
|
||||
|
||||
+35
-4
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user