Write-up: Stored XSS in the Jodit editor via a cleanHTML sanitizer bypass (GHSA-jhhp-r3r7-v2cg)

Summary. Jodit, a widely used open-source WYSIWYG editor, shipped a clean-html sanitizer whose asynchronous background pass did not honour the cleanHTML.removeEventAttributes setting. On that path the sanitizer explicitly stripped only onerror. Because onerror is the handler most researchers reach for first when they probe an editor, the sanitizer looks effective at a glance, while every other event-handler attribute such as onmouseover or onclick slips through untouched. A surviving handler runs in the editing user's context and persists once the content is saved, which makes this a stored cross-site scripting issue (CWE-79). It affects Jodit <= 4.17.0 and is fixed in 4.17.1. Tracked as GHSA-jhhp-r3r7-v2cg (Moderate).

window.name User B I U Click and type … Dave EDITOR DOM (WHAT IS ACTUALLY STORED) <p></p> <p onmouseover="alert(document.domain)">Dave</p> site-b.example Researcher Another site in a second window. It carries a prepared snippet with an event-handler attribute. <p onmouseover="alert(document.domain)">Dave</p> <p onmouseover="alert(document.domain)">Dave</p> window.name says document.domain window.name OK A snippet is dragged from another window Native drop: no dragstart in window.name, so it skips the insert sanitizer onmouseover survived the background clean-html pass and runs as window.name
Cross-window drag-and-drop: the snippet never fires dragstart in window.name, so the browser drops it straight into the editor DOM, past the insert sanitizer. The background clean-html pass leaves onmouseover in place, and it executes in window.name's origin.

How Jodit sanitizes content

Jodit cleans HTML on two different routes. The first is the synchronous safeHTML() path that runs on insertHTML(), setValue() and the paste plugin. It strips event-handler attributes when event removal is enabled. The second is the clean-html plugin, which runs an asynchronous background pass over the editable DOM, walking the tree in idle chunks (a lazy DOM walker) to normalise content that is already in the document.

That background pass sanitizes each element through sanitizeAttributes(), which delegates to the shared helper sanitizeHTMLElement(). The relevant default here is cleanHTML.removeEventAttributes: true, which is meant to strip every on* handler.

Root cause

The background pass forwarded removeEventAttributes to the helper, but the helper never read it. In src/core/helpers/html/safe-html.ts (Jodit <= 4.17.0), sanitizeHTMLElement() destructured only two of the four options it is handed, and the only event-handler attribute it removed was onerror.

src/core/helpers/html/safe-html.ts (<= 4.17.0)
export function sanitizeHTMLElement(
    elm: Element | DocumentFragment,
    { safeJavaScriptLink, removeOnError }: safeOptions = {
        safeJavaScriptLink: true,
        removeOnError: true
    }
): boolean {
    // ...
    if (removeOnError && elm.hasAttribute('onerror')) {
        attr(elm, 'onerror', null);   // the only on* attribute removed here
    }
    // removeEventAttributes and safeLinksTarget are declared on safeOptions,
    // but never read in this function
    // ...
}

The clean-html filter, on the other hand, forwarded all four options in good faith.

src/plugins/clean-html/helpers/visitor/filters/sanitize-attributes.ts
sanitizeHTMLElement(nodeElm, {
    safeJavaScriptLink:    opts.safeJavaScriptLink,
    removeOnError:         opts.removeOnError,
    removeEventAttributes: opts.removeEventAttributes, // passed in...
    safeLinksTarget:       opts.safeLinksTarget         // ...but dropped
});

So on this path removeEventAttributes and safeLinksTarget were discarded in silence. A helper that strips every on* attribute already existed in the same file, removeAllEventAttributes(), but nothing on the background path ever called it.

The practical consequence is what makes the bug easy to miss. Since onerror was removed, the single payload most people try first behaves exactly as a working sanitizer would, which quietly signals "all good". Every other on* handler, including onmouseover, onclick and onfocus, survived the background clean-html pass unchanged and kept surviving across repeated passes.

Precondition and attack surface

The synchronous paste and insert path already strips on* handlers, so a payload has to reach the contentEditable DOM without going through Jodit's insertHTML() or setValue() sanitizer, and then be left to the background pass. The realistic ways in are a native browser paste that lands before the paste plugin intercepts it, a third-party script or browser extension writing markup straight into the editable element, and, most realistically, drag-and-drop.

Why a cross-window drop slips through but a same-window drop does not

The drop case deserves its own section, because a drop within the same window was handled correctly while a drop from another window was not. Jodit only treated a drop as editor input, and routed it through the sanitizing paste path, once it had already observed a matching dragstart event in the same window.

Credit for this angle goes to the Jodit maintainer. When confirming the report, they pointed out that a drag originating in another window takes the native drop path and never reaches the paste sanitizer. That observation is what turned a same-window curiosity into the realistic cross-window delivery described here, and it is reflected in the fix. The maintainer responded quickly and handled the whole disclosure graciously.

When you drag a selection inside the same page, that dragstart fires in the editor's window first. The plugin now knows a drag is in progress, so on drop it takes over and runs the dropped markup through the same sanitizer that handles pasted and assigned content. The onmouseover handler is removed there, and nothing dangerous reaches the document.

A drag that starts in another browser window, another tab or another application never fires dragstart in the editor's window. With no dragstart observed, the plugin's drop handling was never armed, so the browser performed the drop natively and inserted the dragged HTML directly into the contentEditable area. That content bypassed the paste sanitizer entirely. The only pass that then touched it was the asynchronous background clean-html pass, which is exactly the path that ignored removeEventAttributes, so the handler survived.

Proof of concept

The following, run in the browser DevTools console against an initialized editor, shows that an injected onmouseover handler is still present after a clean-html pass. The payload is a harmless alert().

DevTools console, editor already initialized
(() => {
  const editor_dave = document.querySelector('.jodit-wysiwyg');
  editor_dave.innerHTML +=
    '<p id="payload" onmouseover="alert(\'XSS via cleanHTML.removeEventAttributes bypass\')">Dave</p>';
  editor_dave.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  setTimeout(() => console.log(
    'onmouseover survived clean-html pass:',
    document.getElementById('payload').getAttribute('onmouseover')
  ), 500);
})();

After running it, hovering over "Dave" fires the handler, and the logged value confirms the attribute outlived the sanitize pass. Tested in current Google Chrome against Jodit 4.16.0. The console is only a convenient way to show the surviving attribute. The realistic delivery is the cross-window drop described above.

Impact

Moderate. The handler executes in the context of the page that hosts the editor, first for the user editing the document, and then for anyone later served that content, because the attribute persists in the stored HTML if the hosting application saves and re-renders it without its own output encoding. That persistence is what makes this a stored issue rather than a purely self-inflicted one.

The fix (4.17.1)

The fix makes sanitizeHTMLElement() honour removeEventAttributes and strip every on* handler through the existing helper. It also routes drops through the sanitizing paste path, so a cross-window drop is treated like pasted or assigned content instead of being inserted natively.

src/core/helpers/html/safe-html.ts (4.17.1)
export function sanitizeHTMLElement(
    elm: Element | DocumentFragment,
    { safeJavaScriptLink, removeOnError, removeEventAttributes }: safeOptions = {
        safeJavaScriptLink: true,
        removeOnError: true
    }
): boolean {
    // ...
    // Every on* handler, not only onerror: the clean-html background
    // pass calls this directly, and markup that reached the editable
    // area without safeHTML (a drop, a script) kept its handlers otherwise.
    if (removeEventAttributes && removeAllEventAttributes(elm)) {
        effected = true;
    }
    // ...
}

Regression tests cover the background pass, the helper and both drop paths.

Remediation

Upgrade to Jodit 4.17.1 or later. If you embed Jodit, also apply server-side output encoding to any editor content you store and re-render. Client-side sanitization in a rich-text editor is a usability feature, not a substitute for encoding untrusted HTML on output.

Timeline

timeline
2026-09-29: issue identified in Jodit 4.16.0 and reported to the maintainer
2026-10-10: maintainer confirmed the issue, advisory GHSA-jhhp-r3r7-v2cg published
2026-10-10: fix released in Jodit 4.17.1
2026-10-10: this write-up published

References