fix: HTML escaping covers attribute context

escapeHtml now escapes & < > " ' (shared web/js/html-escape.js, loaded by
admin, gallery and metadata pages) instead of the textContent/innerHTML
round-trip that left quotes intact, so a tag, category, checkpoint or file
name containing a quote can no longer break out of a data-*, value or title
attribute.

Inline onclick handlers that interpolated server data now carry data-action
and data-* attributes and are dispatched through one delegated listener per
page (web/js/data-actions.js); the img onerror fallback uses
data-fallback-src with a capturing error listener. Every remaining template
value written into an attribute or text node (prompts, checkpoints, file
paths, log lines, directory names, categories, suggestions) is escaped.

metadata.html no longer builds an "explorer /select" shell command from the
file name (Electron-only branch); the path is copied to the clipboard.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent f99e76a135
commit edc0ee3d00
9 changed files with 379 additions and 162 deletions
+49
View File
@@ -0,0 +1,49 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { readAction, dispatch } = require("../../web/js/data-actions.js");
test("readAction parses promptId and index as integers", () => {
const parsed = readAction({ action: "open-film", promptId: "42", index: "3" });
assert.equal(parsed.action, "open-film");
assert.equal(parsed.promptId, 42);
assert.equal(parsed.index, 3);
});
test("readAction turns missing or non-numeric ids into null", () => {
assert.equal(readAction({ action: "edit" }).promptId, null);
assert.equal(readAction({ action: "edit", promptId: "abc" }).promptId, null);
assert.equal(readAction({ action: "edit", index: "" }).index, null);
});
test("readAction passes string fields through untouched", () => {
const parsed = readAction({ action: "remove-tag", tag: `a "quoted" tag`, url: "/x?y=1&z=2", copyText: "p" });
assert.equal(parsed.tag, `a "quoted" tag`);
assert.equal(parsed.url, "/x?y=1&z=2");
assert.equal(parsed.copyText, "p");
});
test("readAction tolerates a missing dataset", () => {
assert.equal(readAction(undefined).action, "");
assert.equal(readAction(null).promptId, null);
});
test("dispatch calls the matching handler with the parsed action and extras", () => {
const calls = [];
const handled = dispatch(
{ action: "edit", promptId: "7" },
{ edit: (a, el) => calls.push([a.promptId, el]) },
"element",
);
assert.equal(handled, true);
assert.deepEqual(calls, [[7, "element"]]);
});
test("dispatch returns false and calls nothing for an unknown or empty action", () => {
let called = 0;
const handlers = { edit: () => called++ };
assert.equal(dispatch({ action: "other" }, handlers), false);
assert.equal(dispatch({}, handlers), false);
assert.equal(dispatch({ action: "constructor" }, handlers), false, "no prototype lookups");
assert.equal(called, 0);
});
+56
View File
@@ -0,0 +1,56 @@
// Run with: node --test tests/js/
const test = require("node:test");
const assert = require("node:assert/strict");
const { escapeHtml, escapeAttr } = require("../../web/js/html-escape.js");
test("escapes the five HTML-significant characters", () => {
assert.equal(escapeHtml(`&<>"'`), "&amp;&lt;&gt;&quot;&#39;");
});
test("a double quote cannot break out of an attribute", () => {
const payload = `" onmouseover="alert(1)`;
const escaped = escapeHtml(payload);
assert.ok(!escaped.includes(`"`), escaped);
assert.equal(escaped, `&quot; onmouseover=&quot;alert(1)`);
assert.equal(`<a data-tag="${escaped}">`, `<a data-tag="&quot; onmouseover=&quot;alert(1)">`);
});
test("a single quote cannot break out of a single-quoted attribute", () => {
const escaped = escapeHtml(`' onclick='alert(1)`);
assert.ok(!escaped.includes(`'`), escaped);
assert.equal(escaped, `&#39; onclick=&#39;alert(1)`);
});
test("a script tag is neutralised in text context", () => {
assert.equal(escapeHtml("<script>x</script>"), "&lt;script&gt;x&lt;/script&gt;");
});
test("unicode and ordinary text pass through unchanged", () => {
assert.equal(escapeHtml("héllo wörld 日本語 🙂"), "héllo wörld 日本語 🙂");
assert.equal(escapeHtml("plain text, no specials"), "plain text, no specials");
});
test("already escaped text is escaped again, never double-decoded", () => {
assert.equal(escapeHtml("&amp;"), "&amp;amp;");
});
test("null and undefined become the empty string", () => {
assert.equal(escapeHtml(null), "");
assert.equal(escapeHtml(undefined), "");
});
test("numbers and booleans are coerced to strings", () => {
assert.equal(escapeHtml(42), "42");
assert.equal(escapeHtml(0), "0");
assert.equal(escapeHtml(1.5), "1.5");
assert.equal(escapeHtml(false), "false");
});
test("objects are coerced through String()", () => {
assert.equal(escapeHtml({ toString: () => "<b>" }), "&lt;b&gt;");
});
test("escapeAttr is the same function as escapeHtml", () => {
assert.equal(escapeAttr, escapeHtml);
assert.equal(escapeAttr(`a"b`), "a&quot;b");
});