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: '