Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions src/pat/validation/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}
}

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down Expand Up @@ -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 });
}
}
}
Expand Down
242 changes: 242 additions & 0 deletions src/pat/validation/validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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 = `
<form class="pat-validation">
<input type="text" name="malicious" required>
</form>
`;
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: <script>alert(33)</script>";
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("<script>");
expect(warning_text).not.toContain("</script>");
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 = `
<form class="pat-validation">
<input type="text" name="malicious" required>
</form>
`;
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: <img src="x" onerror="alert(33)">';
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 <img> 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 = `
<form class="pat-validation">
<input type="text" name="safe" required>
</form>
`;
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 <strong>required</strong> 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 <strong> by default)
expect(warning_html).toContain("<strong>required</strong>");
});

it("8.4 - handles validation messages from browser's built-in validation", async function () {
document.body.innerHTML = `
<form class="pat-validation">
<input type="email" name="email" required>
</form>
`;
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 = '<script>alert("xss")</script>@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("<script>");
expect(warning_text).not.toContain("</script>");
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 = `
<form class="pat-validation">
<input type="text" name="example" required>
</form>
`;
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: <script>alert(33)</script>";
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("<script>");
expect(warning_text).not.toContain("</script>");
expect(warning_text).not.toContain("alert(33)");

// HTML should also be clean
expect(warning_html).not.toContain("<script>");
expect(warning_html).not.toContain("</script>");

// 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 = `
<form class="pat-validation">
<input type="text" name="browser" required>
</form>
`;
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: <script>alert("malicious")</script>',
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("<script>");
expect(warning_text).not.toContain("</script>");
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);
});
});
});
Loading