From 87db3009d038121e9635a5dc6b754a259b58464a Mon Sep 17 00:00:00 2001 From: Johannes Raggam Date: Wed, 15 Apr 2026 17:23:05 +0200 Subject: [PATCH] feat(pat-validation): Sanitize the validation message. Sanitize the validation message to keep malicious input from being executed. Chrome includes the input value in it's browser validation message. When placing that into the DOM, malicious input could get executed within the web page context. --- src/pat/validation/validation.js | 36 ++-- src/pat/validation/validation.test.js | 242 ++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 10 deletions(-) diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index cd7be6005..37a1f5ed2 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -42,18 +42,26 @@ class Pattern extends BasePattern { // validation fails (e.g. pat-inject). static order = 100; + _dompurify = null; + async get_dompurify() { + if (!this._dompurify) { + this._dompurify = (await import("dompurify")).default; + } + return this._dompurify; + } + init() { events.add_event_listener( this.el, "submit", `pat-validation--submit--validator`, - (e) => { + async (e) => { // On submit, check all. // Immediate, non-debounced check with submit. Otherwise submit // is not cancelable. for (const input of this.inputs) { logger.debug("Checking input for submit", input, e); - this.check_input({ input: input, event: e }); + await this.check_input({ input: input, event: e }); } }, // Make sure this event handler is run early, in the capturing @@ -108,7 +116,7 @@ class Pattern extends BasePattern { } } - check_input({ input, event, stop = false }) { + async check_input({ input, event, stop = false }) { if (input.disabled) { // No need to check disabled inputs. return; @@ -239,11 +247,11 @@ class Pattern extends BasePattern { // do not re-check when stop is set to avoid infinite loops if (!stop && not_after_el) { logger.debug("Check `not-after` input.", not_after_el); - this.check_input({ input: not_after_el, stop: true }); + await this.check_input({ input: not_after_el, stop: true }); } if (!stop && not_before_el) { logger.debug("Check `no-before` input.", not_after_el); - this.check_input({ input: not_before_el, stop: true }); + await this.check_input({ input: not_before_el, stop: true }); } } @@ -318,7 +326,7 @@ class Pattern extends BasePattern { event.stopPropagation(); event.stopImmediatePropagation(); } - this.set_error_message(input); + await this.set_error_message(input); } set_error({ @@ -381,7 +389,7 @@ class Pattern extends BasePattern { } } - set_error_message(input) { + async set_error_message(input) { // First, remove the old error message. this.remove_error(input, false, true); @@ -393,8 +401,16 @@ class Pattern extends BasePattern { return; } - // Create the validation error DOM node from the template - const validation_message = input.validationMessage || input[KEY_ERROR_MSG]; + // Create the validation error DOM node from the template. + // Sanitize the validation message to keep malicious input from being + // executed. Chrome includes the input value in it's browser validation + // message. When placing that into the DOM, malicious input could get + // executed within the web page context. + const dompurify = await this.get_dompurify(); + const validation_message = dompurify.sanitize( + input.validationMessage || input[KEY_ERROR_MSG] + ); + const error_node = dom.create_from_string( this.error_template(validation_message) ).firstChild; @@ -431,7 +447,7 @@ class Pattern extends BasePattern { if (did_disable) { logger.debug("Checking whole form after element was disabled."); for (const _input of this.inputs.filter((it) => it !== input)) { - this.check_input({ input: _input, stop: true }); + await this.check_input({ input: _input, stop: true }); } } } diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index f9a0eaa62..4f34fe9d1 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -458,6 +458,7 @@ describe("pat-validation", function () { await events.await_pattern_init(instance); document.querySelector("button").click(); + await utils.timeout(1); // wait a tick for async to settle. expect(el.querySelectorAll("em.warning").length).toBe(2); }); @@ -1509,4 +1510,245 @@ describe("pat-validation", function () { expect(el.querySelector("#form-buttons-create").disabled).toBe(false); }); }); + + describe("8 - security tests", function () { + it("8.1 - sanitizes malicious script content in validation messages", async function () { + document.body.innerHTML = ` +
+ +
+ `; + const el = document.querySelector(".pat-validation"); + const inp = el.querySelector("[name=malicious]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + // Use the pattern's set_error method which would eventually call set_error_message + // This simulates setting a custom error message with malicious content + const malicious_message = + "Please fill out this field: "; + instance.set_error({ input: inp, msg: malicious_message }); + + // Manually trigger set_error_message to test the sanitization + await instance.set_error_message(inp); + + // Check that an error message is shown + expect(el.querySelectorAll("em.warning").length).toBe(1); + + // Verify the script tag has been sanitized/removed + const warning_text = el.querySelector("em.warning").textContent; + expect(warning_text).not.toContain(""); + expect(warning_text).not.toContain("alert(33)"); + + // The sanitized message should still contain the safe text + expect(warning_text).toContain("Please fill out this field"); + + // Ensure no script elements were actually added to the DOM + expect(el.querySelectorAll("script").length).toBe(0); + }); + + it("8.2 - sanitizes malicious HTML content in validation messages", async function () { + document.body.innerHTML = ` +
+ +
+ `; + const el = document.querySelector(".pat-validation"); + const inp = el.querySelector("[name=malicious]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + // Set malicious HTML content that could execute JavaScript + const malicious_message = + 'Please fill out this field: '; + instance.set_error({ input: inp, msg: malicious_message }); + + // Manually trigger set_error_message to test the sanitization + await instance.set_error_message(inp); + + // Check that an error message is shown + expect(el.querySelectorAll("em.warning").length).toBe(1); + + // Verify the malicious HTML has been sanitized + const warning_element = el.querySelector("em.warning"); + const warning_text = warning_element.textContent; + const warning_html = warning_element.innerHTML; + + // The text should not contain the malicious content + expect(warning_text).not.toContain("onerror"); + expect(warning_text).not.toContain("alert(33)"); + + // The HTML should also be sanitized - the dangerous onerror attribute should be removed + // DOMPurify keeps safe tags but removes dangerous event handlers + expect(warning_html).not.toContain("onerror"); + expect(warning_html).not.toContain("alert(33)"); + + // The safe text should remain + expect(warning_text).toContain("Please fill out this field"); + + // Ensure no img elements with onerror were added + const imgs_with_onerror = document.querySelectorAll("img[onerror]"); + expect(imgs_with_onerror.length).toBe(0); + }); + + it("8.3 - preserves safe HTML content in validation messages", async function () { + document.body.innerHTML = ` +
+ +
+ `; + const el = document.querySelector(".pat-validation"); + const inp = el.querySelector("[name=safe]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + // Set a validation message with safe HTML content (like emphasis) + const safe_message = + "This field is required and must be filled out."; + instance.set_error({ input: inp, msg: safe_message }); + + // Trigger set_error_message + await instance.set_error_message(inp); + + // Check that an error message is shown + expect(el.querySelectorAll("em.warning").length).toBe(1); + + const warning_element = el.querySelector("em.warning"); + const warning_text = warning_element.textContent; + const warning_html = warning_element.innerHTML; + + // The text content should contain the message without HTML tags + expect(warning_text).toContain( + "This field is required and must be filled out." + ); + + // The innerHTML should contain the safe HTML (DOMPurify allows by default) + expect(warning_html).toContain("required"); + }); + + it("8.4 - handles validation messages from browser's built-in validation", async function () { + document.body.innerHTML = ` +
+ +
+ `; + const el = document.querySelector(".pat-validation"); + const inp = el.querySelector("[name=email]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + // Set an invalid email that would trigger browser validation + // and potentially include the malicious input in the validation message + inp.value = '@example.com'; + + // Trigger validation + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + // Check that an error message is shown + expect(el.querySelectorAll("em.warning").length).toBe(1); + + const warning_text = el.querySelector("em.warning").textContent; + + // Verify the script content has been sanitized from the validation message + expect(warning_text).not.toContain(""); + expect(warning_text).not.toContain('alert("xss")'); + + // Ensure no script elements were added to the DOM + expect(el.querySelectorAll("script").length).toBe(0); + }); + + it("8.5 - sanitizes example malicious script that would execute", async function () { + document.body.innerHTML = ` +
+ +
+ `; + const el = document.querySelector(".pat-validation"); + const inp = el.querySelector("[name=example]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + // Set the exact example from the user's request + const malicious_message = "Value is invalid: "; + instance.set_error({ input: inp, msg: malicious_message }); + + // Trigger set_error_message to test the sanitization + await instance.set_error_message(inp); + + // Check that an error message is shown + expect(el.querySelectorAll("em.warning").length).toBe(1); + + // Verify the script content has been completely sanitized + const warning_element = el.querySelector("em.warning"); + const warning_text = warning_element.textContent; + const warning_html = warning_element.innerHTML; + + // Verify no script execution + expect(warning_text).not.toContain(""); + expect(warning_text).not.toContain("alert(33)"); + + // HTML should also be clean + expect(warning_html).not.toContain(""); + + // The safe part of the message should remain + expect(warning_text).toContain("Value is invalid"); + + // Ensure no script elements were added to the DOM + expect(document.querySelectorAll("script").length).toBe(0); + }); + + it("8.6 - sanitizes malicious content in browser validationMessage property", async function () { + document.body.innerHTML = ` +
+ +
+ `; + const el = document.querySelector(".pat-validation"); + const inp = el.querySelector("[name=browser]"); + + const instance = new Pattern(el); + await events.await_pattern_init(instance); + + // Simulate browser validation message with malicious content + // This could happen if browser includes user input in validation message + inp.value = ""; + + // Mock the validationMessage property to contain malicious content + // This simulates what Chrome might do + Object.defineProperty(inp, "validationMessage", { + value: 'Please fill out this field. Input: ', + configurable: true, + }); + + // Trigger validation through the pattern + instance.check_input({ input: inp }); + await utils.timeout(1); // wait for async to settle. + + // Check that an error message is shown + expect(el.querySelectorAll("em.warning").length).toBe(1); + + const warning_text = el.querySelector("em.warning").textContent; + + // Verify the script has been sanitized + expect(warning_text).not.toContain(""); + expect(warning_text).not.toContain('alert("malicious")'); + + // Safe content should remain + expect(warning_text).toContain("Please fill out this field"); + + // No scripts should be in the DOM + expect(document.querySelectorAll("script").length).toBe(0); + }); + }); });