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); + }); + }); });