diff --git a/dashboard.html b/dashboard.html index 98750d9..1e5d320 100644 --- a/dashboard.html +++ b/dashboard.html @@ -132,6 +132,10 @@ function fmtChars(n) { return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n); } +function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + function barColor(pct) { if (pct >= 80) return "bar-red"; if (pct >= 50) return "bar-amber"; @@ -181,21 +185,21 @@ async function refreshUsage() { const tbody = document.querySelector("#key-usage-table tbody"); tbody.innerHTML = (data.byKey || []).map(k => ` - ${k.key_name} + ${escapeHtml(k.key_name)} ${k.requests} ${k.successes} ${k.errors} ${fmtTime(k.avg_elapsed_ms)} - ${k.last_request || '-'} + ${escapeHtml(k.last_request || '-')} `).join("") || 'No usage data yet'; const rtbody = document.querySelector("#recent-table tbody"); rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => ` - ${r.created_at?.slice(11, 19) || '?'} - ${r.key_name} - ${r.model} + ${escapeHtml(r.created_at?.slice(11, 19) || '?')} + ${escapeHtml(r.key_name)} + ${escapeHtml(r.model)} ${fmtChars(r.prompt_chars)} ${fmtChars(r.response_chars)} ${fmtTime(r.elapsed_ms)} @@ -216,13 +220,16 @@ async function refreshKeys() { const tbody = document.querySelector("#keys-table tbody"); tbody.innerHTML = (data.keys || []).map(k => ` - ${k.name} - ${k.keyPreview} - ${k.created_at} + ${escapeHtml(k.name)} + ${escapeHtml(k.keyPreview)} + ${escapeHtml(k.created_at)} ${k.revoked ? 'revoked' : 'active'} - ${k.revoked ? '' : ``} + ${k.revoked ? '' : ``} `).join(""); + tbody.querySelectorAll("button[data-revoke]").forEach(btn => + btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke"))) + ); } catch(e) { /* not admin */ } } diff --git a/server.mjs b/server.mjs index cdb732c..5b91bd2 100644 --- a/server.mjs +++ b/server.mjs @@ -2048,6 +2048,9 @@ const server = createServer(async (req, res) => { let parsed; try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } const name = parsed.name || `key-${Date.now()}`; + if (!/^[A-Za-z0-9 ._-]{1,64}$/.test(name)) { + return jsonResponse(res, 400, { error: { message: "Invalid key name: 1-64 chars of letters, digits, space, dot, underscore, hyphen", type: "invalid_request_error" } }); + } const newKey = createKey(name); return jsonResponse(res, 201, newKey); } diff --git a/test-features.mjs b/test-features.mjs index c057897..9066227 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -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 { + const out = escapeHtml(''); + assert.ok(out.includes("<img"), `expected <img in: ${out}`); + assert.ok(!out.includes(" { + 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: '