From 21ae7bc73d39aff476c1fcd95eb78d003698fa76 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Thu, 28 May 2026 00:06:24 +0200 Subject: [PATCH 1/3] feat(pat-inject): Use dompurify. --- src/pat/inject/inject.js | 66 +++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 0e75b6873..817c8009c 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -472,7 +472,9 @@ const inject = { const method = cfg.sourceMod === "content" ? "innerHTML" : "outerHTML"; // There might be multiple sources, so we need to loop over them. // Access them with "innerHTML" or "outerHTML" depending on the sourceMod. - const sources_string = [...$sources].map(source => source[method]).join("\n"); + const sources_string = [...$sources] + .map((source) => source[method]) + .join("\n"); wrapper.innerHTML = sources_string; for (const img of wrapper.content.querySelectorAll("img")) { @@ -832,25 +834,32 @@ const inject = { return true; }, - _sourcesFromHtml(html, url, sources) { - const $html = this._parseRawHtml(html, url); - return sources.map((source) => { - if (source === "body") { - source = "#__original_body"; + async _sourcesFromHtml(html, url, sources) { + const source_dom = await this._parseRawHtml(html, url); + const $html = $(source_dom); + + return sources.map((source_selector) => { + if (source_selector === "body") { + source_selector = "body"; } - if (source === "none") { + if (source_selector === "none") { return $(""); } - const $source = $html.find(source); - if ($source.length === 0) { - if (source != "title") { - log.warn("No source elements for selector:", source, $html); + const source_result = dom.querySelectorAllAndMe(source_dom, source_selector); + const $source = $html.find(source_selector); + + if (source_result.length === 0) { + if (source_selector != "title") { + log.warn("No source elements for selector:", source_selector, $html); } } - $source.find('a[href^="#"]').each((idx, el_) => { - const href = el_.getAttribute("href"); + for (const anchor in dom.querySelectorAllAndMe( + source_result, + 'a[href^="#"]' + )) { + const href = anchor.getAttribute("href"); if (href.indexOf("#{1}") !== -1) { // We ignore hrefs containing #{1} because they're not // valid and only applicable in the context of @@ -861,11 +870,11 @@ const inject = { // this fragment. if (href.length === 1) { // Special case for top-of-page links - el_.href = url; + anchor.href = url; } else if (!$source.find(href).length) { - el_.href = url + href; + anchor.href = url + href; } - }); + } return $source; }); }, @@ -895,15 +904,15 @@ const inject = { return base.slice(0, base.lastIndexOf("/") + 1) + url; }, - _rebaseHTML(base, html) { - if (html === "") { + _rebaseHTML(base, page) { + if (!page) { // Special case, source is none return ""; } // Parse the html string into a DOM tree. - const page = document.createElement("div"); - page.innerHTML = html; + //const page = document.createElement("div"); + //page.innerHTML = html; // Create efficient selector for any tag with it's corresponding // attribute from _rebaseAttrs. Note: the current data structure allows @@ -968,10 +977,19 @@ const inject = { } } - return page.innerHTML.trim(); + return page; + //return page.innerHTML.trim(); }, - _parseRawHtml(html, url = "") { + async _parseRawHtml(html, url = "") { + const dompurify = (await import("dompurify")).default; + const sanitized = dompurify.sanitize(html, { + WHOLE_DOCUMENT: true, + RETURN_DOM: true, + }); + const rebased = this._rebaseHTML(sanitized); + return rebased; + // remove script tags and head and replace body by a div const title = html.match(/\(.*)\<\/title\>/); let clean_html = html @@ -1119,10 +1137,10 @@ const inject = { handlers: { html: { - sources(cfgs, data) { + async sources(cfgs, data) { const sources = cfgs.map((cfg) => cfg.source); sources.push("title"); - const result = this._sourcesFromHtml(data, cfgs[0].url, sources); + const result = await this._sourcesFromHtml(data, cfgs[0].url, sources); return result; }, }, From 416e3cea7db6a611b81f9c6099df67d4a31c42d2 Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Thu, 28 May 2026 03:12:15 +0200 Subject: [PATCH 2/3] fixes. --- src/pat/inject/inject.js | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index 817c8009c..a2f83d2a6 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -987,32 +987,8 @@ const inject = { WHOLE_DOCUMENT: true, RETURN_DOM: true, }); - const rebased = this._rebaseHTML(sanitized); + const rebased = this._rebaseHTML(url, sanitized); return rebased; - - // remove script tags and head and replace body by a div - const title = html.match(/\(.*)\<\/title\>/); - let clean_html = html - .replace(/)<[^<]*)*<\/script>/gi, "") - .replace(/)<[^<]*)*<\/head>/gi, "") - .replace(/]*?)>/gi, "") - .replace(/<\/html([^>]*?)>/gi, "") - .replace(/]*?)>/gi, '
') - .replace(/<\/body([^>]*?)>/gi, "
"); - - if (title && title.length == 2) { - clean_html = title[0] + clean_html; - } - try { - clean_html = this._rebaseHTML(url, clean_html); - } catch (e) { - log.error("Error rebasing urls", e); - } - const $html = $("
").html(clean_html); - if ($html.children().length === 0) { - log.warn("Parsing html resulted in empty jquery object:", clean_html); - } - return $html; }, _initAutoloadVisible($el, cfgs) { From b53b51acfda83bf0e1d5a1077a05888420d8ea0d Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 14 Oct 2026 04:40:26 +0200 Subject: [PATCH 3/3] fixes. Co-authored-by: Codex --- src/pat/inject/inject.js | 89 ++++++++-------- src/pat/inject/inject.test.js | 191 ++++++++++++++++++++++++++++++---- 2 files changed, 214 insertions(+), 66 deletions(-) diff --git a/src/pat/inject/inject.js b/src/pat/inject/inject.js index a2f83d2a6..211670721 100644 --- a/src/pat/inject/inject.js +++ b/src/pat/inject/inject.js @@ -467,31 +467,32 @@ const inject = { /* Called after the XHR has succeeded and we have a new $sources * element to inject. */ - const wrapper = document.createElement("template"); - if ($sources.length > 0) { - const method = cfg.sourceMod === "content" ? "innerHTML" : "outerHTML"; - // There might be multiple sources, so we need to loop over them. - // Access them with "innerHTML" or "outerHTML" depending on the sourceMod. - const sources_string = [...$sources] - .map((source) => source[method]) - .join("\n"); - wrapper.innerHTML = sources_string; - - for (const img of wrapper.content.querySelectorAll("img")) { - events.add_event_listener( - img, - "load", - "inject_img_load", - (e) => { - $(e.currentTarget).trigger("pat-inject-content-loaded"); - }, - { once: true } - ); + const fragment = document.createDocumentFragment(); + for (const [index, source] of [...$sources].entries()) { + if (index > 0) { + fragment.append(document.createTextNode("\n")); + } + const content = source.nodeName === "TEMPLATE" ? source.content : source; + const nodes = cfg.sourceMod === "content" ? content.childNodes : [source]; + for (const node of nodes) { + fragment.append(node.cloneNode(true)); } } - // Copy, because after insertion wrapper.children is empty. - const source_nodes = [...wrapper.content.childNodes]; + for (const img of fragment.querySelectorAll("img")) { + events.add_event_listener( + img, + "load", + "inject_img_load", + (e) => { + $(e.currentTarget).trigger("pat-inject-content-loaded"); + }, + { once: true } + ); + } + + // Keep references before insertion empties the fragment. + const source_nodes = [...fragment.childNodes]; // Now the injection actually happens. if (this._inject(trigger, source_nodes, target, cfg)) { @@ -666,8 +667,7 @@ const inject = { error_page_fragment = error_page_fragment ? `#${error_page_fragment}` : null; if (error_page_fragment) { - error_page = document.createElement("html"); - error_page.innerHTML = event.jqxhr.responseText; + error_page = await this._sanitizeHtml(event.jqxhr.responseText || ""); error_page = error_page.querySelector(error_page_fragment); } @@ -676,8 +676,9 @@ const inject = { const error_page_response = await fetch(error_page_url, { method: "GET", }); - error_page = document.createElement("html"); - error_page.innerHTML = await error_page_response.text(); + error_page = await this._sanitizeHtml( + await error_page_response.text() + ); error_page = error_page.querySelector(error_page_fragment || "body"); } catch { // fall back to standard error message and ignore. @@ -698,7 +699,7 @@ const inject = { if (error_page) { const error_zone = document.querySelector(error_page_fragment || "body"); - error_zone.innerHTML = error_page.innerHTML; + error_zone.replaceChildren(...error_page.childNodes); registry.scan(error_zone); // initialize any patterns in error page } else { const msg_attr = `${explanation} Status is ${status} ${event.jqxhr.statusText}, time was ${timestamp}. You can click to close this.`; @@ -836,26 +837,26 @@ const inject = { async _sourcesFromHtml(html, url, sources) { const source_dom = await this._parseRawHtml(html, url); - const $html = $(source_dom); return sources.map((source_selector) => { - if (source_selector === "body") { - source_selector = "body"; - } if (source_selector === "none") { return $(""); } const source_result = dom.querySelectorAllAndMe(source_dom, source_selector); - const $source = $html.find(source_selector); + const $source = $(source_result); if (source_result.length === 0) { if (source_selector != "title") { - log.warn("No source elements for selector:", source_selector, $html); + log.warn( + "No source elements for selector:", + source_selector, + source_dom + ); } } - for (const anchor in dom.querySelectorAllAndMe( + for (const anchor of dom.querySelectorAllAndMe( source_result, 'a[href^="#"]' )) { @@ -864,14 +865,14 @@ const inject = { // We ignore hrefs containing #{1} because they're not // valid and only applicable in the context of // pat-clone. - return; + continue; } // Skip in-document links pointing to an id that is inside // this fragment. if (href.length === 1) { // Special case for top-of-page links anchor.href = url; - } else if (!$source.find(href).length) { + } else if (!dom.querySelectorAllAndMe(source_result, href).length) { anchor.href = url + href; } } @@ -910,10 +911,6 @@ const inject = { return ""; } - // Parse the html string into a DOM tree. - //const page = document.createElement("div"); - //page.innerHTML = html; - // Create efficient selector for any tag with it's corresponding // attribute from _rebaseAttrs. Note: the current data structure allows // only for one attribute to be rebased per tag. @@ -978,17 +975,19 @@ const inject = { } return page; - //return page.innerHTML.trim(); }, - async _parseRawHtml(html, url = "") { + async _sanitizeHtml(html) { const dompurify = (await import("dompurify")).default; - const sanitized = dompurify.sanitize(html, { + return dompurify.sanitize(html, { WHOLE_DOCUMENT: true, RETURN_DOM: true, }); - const rebased = this._rebaseHTML(url, sanitized); - return rebased; + }, + + async _parseRawHtml(html, url = "") { + const sanitized = await this._sanitizeHtml(html); + return this._rebaseHTML(url, sanitized); }, _initAutoloadVisible($el, cfgs) { diff --git a/src/pat/inject/inject.test.js b/src/pat/inject/inject.test.js index 9850a8821..abf971555 100644 --- a/src/pat/inject/inject.test.js +++ b/src/pat/inject/inject.test.js @@ -338,9 +338,16 @@ describe("pat-inject", function () { }); describe("5 - rebaseHTML", function () { + const rebaseHtml = (url, html) => { + const page = new DOMParser().parseFromString(html, "text/html"); + const root = page.documentElement; + expect(pattern._rebaseHTML(url, root)).toBe(root); + return page.body.innerHTML.trim(); + }; + it("5.1 - Basic markup with DOCTYPE", function () { expect( - pattern._rebaseHTML( + rebaseHtml( "base", "\n

This is a simple test

" ) @@ -348,20 +355,20 @@ describe("pat-inject", function () { }); it("5.2 - Basic markup", function () { - expect( - pattern._rebaseHTML("base", "

This is a simple test

") - ).toBe("

This is a simple test

"); + expect(rebaseHtml("base", "

This is a simple test

")).toBe( + "

This is a simple test

" + ); }); it("5.3 - Recover from unclosed tags", function () { - expect(pattern._rebaseHTML("base", "

This is a simple test

")).toBe( + expect(rebaseHtml("base", "

This is a simple test

")).toBe( "

This is a simple test

" ); }); it("5.4 - Anchor element without href attribute", function () { var spy_rebaseURL = jest.spyOn(pattern, "_rebaseURL"); - expect(pattern._rebaseHTML("base", "This is a test")).toBe( + expect(rebaseHtml("base", "This is a test")).toBe( "This is a test" ); expect(spy_rebaseURL).not.toHaveBeenCalled(); @@ -371,7 +378,7 @@ describe("pat-inject", function () { it("5.5 - Anchor element with href attribute", function () { expect( - pattern._rebaseHTML( + rebaseHtml( "http://example.com/test/", 'This is a test' ) @@ -382,7 +389,7 @@ describe("pat-inject", function () { it("5.6 - Automatically fix casing of attribute", function () { expect( - pattern._rebaseHTML( + rebaseHtml( "http://example.com/test/", 'This is a test' ) @@ -392,14 +399,14 @@ describe("pat-inject", function () { }); it("5.7 - Check if image is rebased correctly", function () { - expect( - pattern._rebaseHTML("http://example.com/test/", '') - ).toBe(''); + expect(rebaseHtml("http://example.com/test/", '')).toBe( + '' + ); }); it("5.8 - Leave non attribute occurences of src intact", function () { expect( - pattern._rebaseHTML( + rebaseHtml( "base", "

This string has src = \"foo\" , src= bar , and SrC='foo'

" ) @@ -412,7 +419,7 @@ describe("pat-inject", function () { await import("../date-picker/date-picker"); await import("../datetime-picker/datetime-picker"); - const res = pattern._rebaseHTML( + const res = rebaseHtml( "https://example.com/test/", `
@@ -449,7 +456,7 @@ describe("pat-inject", function () { // tests for an error where a semicolon at the end of a pattern // config as well as multiple configs were not correctly parsed. - const res = pattern._rebaseHTML( + const res = rebaseHtml( "https://example.com/test/", `
{ await import("../calendar/calendar"); - const res = pattern._rebaseHTML( + const res = rebaseHtml( "https://example.com/test/", '
' ); @@ -511,23 +518,128 @@ describe("pat-inject", function () { }); describe("6 - parseRawHtml", function () { - it("6.1 - Roundtrip attributes with double quotes", function () { + it("6.1 - Roundtrip attributes with double quotes", async function () { var value = '{"plugins": "paste", "content_css": "/_themes/Style/tiny-body.css"}', input = "Test", - $output = pattern._parseRawHtml(input, null); - expect($output.find("a").attr("data-tinymce-json")).toBe(value); + output = await pattern._parseRawHtml(input, null); + expect(output.querySelector("a").getAttribute("data-tinymce-json")).toBe( + value + ); }); - it("6.2 - Roundtrip attributes with single quotes", function () { + it("6.2 - Roundtrip attributes with single quotes", async function () { var value = "{'plugins': 'paste', 'content_css': '/_themes/Style/tiny-body.css'}", input = 'Test', - $output = pattern._parseRawHtml(input, null); - expect($output.find("a").attr("data-tinymce-json")).toBe(value); + output = await pattern._parseRawHtml(input, null); + expect(output.querySelector("a").getAttribute("data-tinymce-json")).toBe( + value + ); }); }); + describe("Sanitized response sources", () => { + it("removes unsafe markup before rebasing and preserves pattern attributes", async () => { + const rebase = pattern._rebaseHTML.bind(pattern); + jest.spyOn(pattern, "_rebaseHTML").mockImplementation((url, root) => { + expect(root.querySelector("script")).toBeNull(); + expect(root.querySelector("img").hasAttribute("onerror")).toBe(false); + expect(root.querySelector("a").hasAttribute("href")).toBe(false); + return rebase(url, root); + }); + const root = await pattern._parseRawHtml( + ` + Safe title +
+ + + Link +
+ `, + "https://example.com/page" + ); + expect(root.querySelector("title").textContent).toBe("Safe title"); + expect( + root.querySelector(".pat-example").getAttribute("data-pat-example") + ).toBe("value: test"); + expect(root.querySelector(".pat-example").getAttribute("aria-label")).toBe( + "Example" + ); + expect(root.querySelector("img").getAttribute("src")).toBe( + "https://example.com/image.png" + ); + }); + + it("rebases hash links while preserving local targets and clone placeholders", async () => { + const [source] = await pattern._sourcesFromHtml( + ` +
+ Clone + Top + Outside + Inside + Source root + +
+ `, + "https://example.com/page", + ["#source"] + ); + expect( + [...source[0].querySelectorAll("a")].map((a) => a.getAttribute("href")) + ).toEqual([ + "#{1}", + "https://example.com/page", + "https://example.com/page#outside", + "#inside", + "#source", + ]); + }); + + it("rebases an anchor selected as the source itself", async () => { + const [source] = await pattern._sourcesFromHtml( + 'Link', + "https://example.com/page", + ["#source"] + ); + expect(source[0].getAttribute("href")).toBe( + "https://example.com/page#outside" + ); + }); + + it.each(["content", "element"])( + "clones %s independently for multiple targets", + async (sourceMod) => { + const [source] = await pattern._sourcesFromHtml( + '
TextSafe
', + "/page", + ["#source"] + ); + jest.spyOn(pattern, "_afterInjection").mockImplementation(() => {}); + jest.spyOn(pattern, "_update_history").mockImplementation(() => {}); + const targets = [ + document.createElement("div"), + document.createElement("div"), + ]; + for (const target of targets) { + pattern._performInjection(target, $(), source, { + sourceMod, + action: "content", + }); + } + const expected = + sourceMod === "content" ? source[0].innerHTML : source[0].outerHTML; + expect(targets[0].innerHTML).toBe(expected); + expect(targets[1].innerHTML).toBe(expected); + expect(targets[0].firstChild).not.toBe(targets[1].firstChild); + targets[0].querySelector("strong").textContent = "Changed"; + expect(targets[1].querySelector("strong").textContent).toBe("Safe"); + expect(source[0].querySelector("strong").textContent).toBe("Safe"); + } + ); + }); + describe("7 - Functional tests", function () { describe("7.1 - extract/verifyConfig", function () { var $a, $target; @@ -2346,6 +2458,43 @@ describe("pat-inject", function () { }); }); + describe("Sanitized error pages", () => { + it.each(["response", "fallback"])( + "sanitizes the %s before inserting its error fragment", + async (kind) => { + const html = + '
ErrorLink
'; + document.head.innerHTML = + ''; + document.body.innerHTML = + '
'; + const originalFetch = global.fetch; + global.fetch = jest.fn().mockImplementation(mockFetch(html)); + try { + await pattern._onInjectError($("#trigger"), [], { + jqxhr: { + status: 404, + responseText: kind === "response" ? html : "", + }, + }); + const zone = document.querySelector("#error-zone"); + expect(zone.querySelector("strong").textContent).toBe("Error"); + expect(zone.querySelector("script")).toBeNull(); + expect(zone.querySelector("img").hasAttribute("onerror")).toBe( + false + ); + expect(zone.querySelector("a").hasAttribute("href")).toBe(false); + expect(global.fetch).toHaveBeenCalledTimes( + kind === "fallback" ? 1 : 0 + ); + } finally { + global.fetch = originalFetch; + document.head.innerHTML = ""; + } + } + ); + }); + describe("11 - Test triggers", function () { afterEach(function () { document.body.innerHTML = "";