mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
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>
This commit is contained in:
@@ -1743,6 +1743,55 @@ test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model
|
||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||
// Replicated verbatim from dashboard.html so tests run without a browser.
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
const KEY_NAME_RE = /^[A-Za-z0-9 ._-]{1,64}$/;
|
||||
|
||||
console.log("\nescapeHtml (issue #114):");
|
||||
|
||||
test("escapeHtml: XSS payload → <img not <img", () => {
|
||||
const out = escapeHtml('<img src=x onerror=alert(1)>');
|
||||
assert.ok(out.includes("<img"), `expected <img in: ${out}`);
|
||||
assert.ok(!out.includes("<img"), `expected no raw <img in: ${out}`);
|
||||
});
|
||||
|
||||
test("escapeHtml: single-quote, double-quote, ampersand all escaped", () => {
|
||||
assert.equal(escapeHtml("a'b\"c&d"), "a'b"c&d");
|
||||
});
|
||||
|
||||
test("escapeHtml: null → empty string", () => {
|
||||
assert.equal(escapeHtml(null), "");
|
||||
});
|
||||
|
||||
console.log("\nKey-name validator (issue #114):");
|
||||
|
||||
test("KEY_NAME_RE: 'wife-laptop' → valid", () => {
|
||||
assert.ok(KEY_NAME_RE.test("wife-laptop"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: 'key-1700000000000' → valid", () => {
|
||||
assert.ok(KEY_NAME_RE.test("key-1700000000000"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: '<script>' → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("<script>"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: \"a'); DROP\" → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("a'); DROP"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: empty string → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test(""));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: 65-char string → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("x".repeat(65)));
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user