diff --git a/static/xref/script.js b/static/xref/script.js
index 2c2c3bf6..6c143ace 100644
--- a/static/xref/script.js
+++ b/static/xref/script.js
@@ -174,33 +174,36 @@ function renderResults(entries, query) {
function howToCiteIDL(term, entry) {
const { type, for: forList } = entry;
+ const safeTerm = escapeHTML(term);
if (forList) {
return forList
.map(f => {
- const termPart = type === 'enum-value' ? `"${term}"` : term;
- return `{{${f}/${term ? termPart : '""'}}}`;
+ const safeF = escapeHTML(f);
+ const termPart = type === 'enum-value' ? `"${safeTerm}"` : safeTerm;
+ return `{{${safeF}/${safeTerm ? termPart : '""'}}}`;
})
.join('
');
}
switch (type) {
case 'exception':
if (!exceptionExceptions.has(term)) {
- return `{{"${term}"}}`;
+ return `{{"${safeTerm}"}}`;
}
default:
- return `{{${term}}}`;
+ return `{{${safeTerm}}}`;
}
}
function howToCiteMarkup(term, entry) {
const { type, for: forList, shortname } = entry;
+ const safeTerm = escapeHTML(term);
if (forList) {
- return forList.map(f => `[^${f}/${term}^]`).join('
');
+ return forList.map(f => `[^${escapeHTML(f)}/${safeTerm}^]`).join('
');
}
if (type === 'element-attr') {
- return `[^/${term}^]`;
+ return `[^/${safeTerm}^]`;
}
- return `[^${term}^]`;
+ return `[^${safeTerm}^]`;
}
function howToCiteAnchor(term, entry) {
@@ -220,9 +223,9 @@ function howToCiteAnchor(term, entry) {
function howToCiteTerm(term, entry) {
const { type, for: forList, shortname } = entry;
- term = term.replace('/', '\\/');
+ term = escapeHTML(term.replace('/', '\\/'));
if (forList) {
- return forList.map(f => `[=${f}/${term}=]`).join('
');
+ return forList.map(f => `[=${escapeHTML(f)}/${term}=]`).join('
');
}
return `[=${term}=]`;
}
diff --git a/tests/static/xref/script.test.js b/tests/static/xref/script.test.js
new file mode 100644
index 00000000..af13e283
--- /dev/null
+++ b/tests/static/xref/script.test.js
@@ -0,0 +1,77 @@
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import vm from "node:vm";
+
+// static/xref/script.js is a plain browser script (not a module) that touches
+// DOM globals at load time, so it can't be imported directly. Instead, slice
+// out the pure citation helpers and the exceptionExceptions set, then evaluate
+// them in an isolated context. This exercises the real source, not a copy.
+const SRC = fileURLToPath(
+ new URL("../../../static/xref/script.js", import.meta.url),
+);
+const text = readFileSync(SRC, "utf8");
+
+const fnBlock = text.slice(
+ text.indexOf("function howToCiteIDL"),
+ text.indexOf("async function ready"),
+);
+const excStart = text.indexOf("const exceptionExceptions");
+const excBlock = text.slice(excStart, text.indexOf("]);", excStart) + 3);
+
+const sandbox = {};
+vm.createContext(sandbox);
+vm.runInContext(
+ `${excBlock}\n${fnBlock}\n` +
+ "this.howToCiteIDL = howToCiteIDL;" +
+ "this.howToCiteMarkup = howToCiteMarkup;" +
+ "this.howToCiteTerm = howToCiteTerm;",
+ sandbox,
+);
+const { howToCiteIDL, howToCiteMarkup, howToCiteTerm } = sandbox;
+
+const XSS = "
";
+
+describe("xref/script - citation HTML escaping", () => {
+ it("escapes the term and for-context in IDL citations", () => {
+ expect(howToCiteIDL(XSS, { type: "attribute", for: ["Window"] })).not.toContain(
+ "
"] }),
+ ).toContain("<f>");
+ expect(howToCiteIDL(XSS, { type: "interface" })).not.toContain("
{
+ expect(
+ howToCiteMarkup(XSS, { type: "element", for: [""] }),
+ ).not.toContain("
{
+ expect(howToCiteTerm(XSS, { type: "dfn" })).not.toContain("
"] })).toContain(
+ "<f>",
+ );
+ });
+
+ it("leaves ordinary citations unchanged", () => {
+ expect(
+ howToCiteIDL("postMessage", { type: "method", for: ["Window"] }),
+ ).toBe("{{Window/postMessage}}");
+ expect(
+ howToCiteIDL("classic", { type: "enum-value", for: ["WorkerType"] }),
+ ).toBe('{{WorkerType/"classic"}}');
+ expect(howToCiteTerm("a/b", { type: "dfn" })).toBe("[=a\\/b=]");
+ });
+
+ it("renders the empty-term fallback (the touched truthiness branch)", () => {
+ // `escapeHTML("")` is still falsy, so the `safeTerm ? termPart : '""'`
+ // branch must keep producing the empty-string placeholder.
+ expect(howToCiteIDL("", { type: "method", for: ["Window"] })).toBe(
+ '{{Window/""}}',
+ );
+ });
+});