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/""}}', + ); + }); +});