Follow-up defense-in-depth from #114's review. The status-cards and plan-cards in
dashboard.html rendered string values into innerHTML without escaping. These are
trusted-but-external (p.version/p.uptime are server-local; s.percent/s.resetsIn/
w.percent/w.resetsIn come from Anthropic's upstream plan API), so not the stored-XSS
vector #114 fixed — but wrapping them in the existing escapeHtml() helper gives uniform
defense-in-depth across all innerHTML sinks. Numeric/computed fields (request counts,
sPct/wPct, barColor) are left unescaped (not injectable).
dashboard.html is a client-side static asset, not server.mjs → cli.js citation N/A.
Independent fresh-context reviewer (opus): APPROVE (Iron Rule 10) — confirmed every
string sink wrapped, numbers correctly untouched, escapeHtml in scope, template literals
intact, 181 tests pass (no regression).
Closes#124.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The dashboard built table rows via template-literal innerHTML, interpolating
DB-sourced strings (key names, usage rows) with no HTML escaping, and an
onclick="revokeKeyUI('${k.name}')" sink a single quote could break out of. Key
names were unvalidated at creation. Admin-gated (self-XSS today), but a real
unescaped-sink gap that becomes cross-user if key creation is ever delegated.
- dashboard.html: added escapeHtml() and applied it to every DB/string-sourced
interpolation in refreshUsage and refreshKeys (key_name, name, keyPreview,
created_at, last_request, model). Replaced the inline-onclick revoke button with
a data-revoke attribute + addEventListener, so a name can never break out into an
event-handler string. (model is attacker-pickable via the request body, so its
escaping is the load-bearing one.)
- server.mjs: POST /api/keys now rejects names not matching /^[A-Za-z0-9 ._-]{1,64}$/
before createKey() — defense-in-depth so a <script>/quote name can never reach the
DB. Creation-only; existing keys unaffected; the default key-${Date.now()} passes.
Noted (out of scope, optional follow-up): the status/plan summary cards render
trusted server/Anthropic-upstream values unescaped — non-user-controlled, so not part
of this stored-XSS fix.
ALIGNMENT.md: the server.mjs change is proxy-policy input validation with no Anthropic
operation forwarded, so a cli.js citation is N/A under Rule 2. No blacklisted tokens or
port literals introduced; alignment.yml passes.
Independent fresh-context reviewer (opus): APPROVE WITH MINOR (Iron Rule 10) — verified
escapeHtml correctness, full sink coverage (incl. the attacker-pickable model field and
the data-revoke attribute), revoke round-trip via getAttribute, the anchored/bounded
key-name regex running before createKey, and that createKey has no other unvalidated
caller. Both minors non-blocking (trusted status-card escaping; this commit-body note).
Closes#114.
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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>
- Support ?token=xxx query parameter for dashboard auth (enables
headless browser screenshots and direct links)
- Token is saved to localStorage and URL is cleaned up
- Fix pathname matching to handle query parameters in URL
- Replace html2canvas screenshot with Playwright (accurate colors)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>