← All advisories
CVE-2026-55184Low · CVSS 3.5· CWE-79

Stored XSS via HTML Sanitizer Bypass in EspoCRM Wysiwyg Fields

Vendor
espocrm
Product
espocrm
Status
Published · Jun 17 2026
Researchers
eo420
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:N/A:N
Published
Jun 17 2026

Summary

EspoCRM doesn't sanitize wysiwyg (rich-text) field content on the server side. All XSS protection relies on client-side sanitization. Most rendering paths use DOMPurify (robust), but three template-body field views (Email Template, PDF Template, admin template-manager) disable DOMPurify in edit mode, falling back to a hand-rolled regex sanitizer called moderateSanitizeHtml(). This sanitizer can be bypassed with a crafted HTML tag that combines a quoted > inside an attribute value with a / attribute separator, causing the onerror event handler to survive sanitization and execute when inserted into the DOM via innerHTML.

An authenticated user with Email Template create/edit permission can store a payload that executes JavaScript when another user (including an admin) opens that template in edit mode. Script execution requires clientCspDisabled = true in config (a supported, documented option). On affected deployments, same-origin API calls from injected JavaScript can create admin accounts, exfiltrate data, and modify system settings, bypassing HttpOnly cookie protections entirely.

Technical Details: Root Cause

No server-side sanitization

Only sanitizer registered for wysiwyg fields is EmptyStringToNull (in application/Espo/Resources/metadata/fields/wysiwyg.json), which does nothing security-relevant. All HTML from API clients gets stored verbatim in the database.

DOMPurify disabled for template editing

When entering edit mode, wysiwyg fields call getValueForEdit() to sanitize content before inserting it into Summernote (the rich-text editor):

// client/src/views/fields/wysiwyg.ts:350-357
protected getValueForEdit(): string {
    const value = this.model.get(this.name) || '';
 
    if (this.htmlPurificationForEditDisabled) {
        return this.sanitizeHtmlLight(value); // regex sanitizer
    }
 
    return this.sanitizeHtml(value); // DOMPurify
}

Three template-body views set htmlPurificationForEditDisabled = true, opting out of DOMPurify and falling back to moderateSanitizeHtml().

Regex sanitizer bypass

moderateSanitizeHtml() at client/src/view-helper.js:864 uses two complementary mechanisms to strip event handlers:

Mechanism 1 (line 872) - A regex that matches tags and replaces on*= attributes:

value = value.replace(/<[^><]*([^a-z]on[a-z]+)=[^><]*>/gi, function (match) {
    return match.replace(/[^a-z]on[a-z]+=/gi, ' data-handler-stripped=');
});

This regex is not quote-aware. Character class [^><] stops matching at any >, including one inside a quoted attribute value. A literal > in something like src="x>" terminates its scan before it reaches event handler attributes placed after it.

Mechanism 2 (line 903, stripEventHandlersInHtml) - A character-by-character parser that is quote-aware but only detects event handlers preceded by a literal space:

if (!lastQuote && html[j - 2] === " " && html[j - 1] === "o" && html[j] === "n") {
    strip = j - 2;
}

A / separator between attributes is valid HTML but isn't a space, so mechanism 2 misses it.

Combining both evasions in one tag:

<img src="x>"/onerror=alert(1)>
  • Mechanism 1 scans img src="x, hits > inside quoted value, stops. Never sees /onerror=.
  • Mechanism 2 tracks quotes correctly, but /onerror is preceded by /, not space. Not stripped.

Payload passes through unchanged. Browser parses src="x>" as value x>, / as attribute separator, and onerror=alert(1) as a live event handler. Image fails to load, onerror fires.

After sanitization, content is inserted into Summernote via .html() (innerHTML semantics) at client/src/views/fields/wysiwyg.ts:652, creating a live DOM element with an executable event handler.