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