Write-up: XSS in SCEditor via unsanitized toBBCode() HTML parsing (GHSA-hmw9-qvxc-c9hr)

In short. This is a cross-site scripting bug in SCEditor (GHSA-hmw9-qvxc-c9hr). The public toBBCode() method assigns its raw HTML argument to innerHTML on an element that is attached to the live document, and it never runs the editor sanitiser on that path, so a script in the input executes in the page origin during the conversion. Every other HTML sink in the library sanitises first, which is why this one is easy to miss. Fixed in 3.2.3, rated Moderate.

Every HTML sink sanitises its input. The conversion path does not. paste, setValue, range sanitize() innerHTML safe toBBCode(html) skips the sanitiser innerHTML, live DOM onerror runs 1. Paste, setValue and range all clean their HTML first 2. toBBCode reaches innerHTML raw, so the script runs
Only the conversion path reaches innerHTML without the sanitiser, so a handler in the input fires.

Background

SCEditor is a small WYSIWYG and BBCode editor. Its toBBCode() method turns an HTML string into BBCode. An application calls it when it wants the BBCode form of some HTML. An internal toSource() function does the work, exposed publicly as editor.toBBCode().

A per-instance DOMPurify guards the WYSIWYG body, the paste handler and the range helper, so every value that reaches the DOM has already passed the sanitiser. The conversion path is the single place that does not.

Where it bites

toBBCode() is public and accepts raw HTML. The library's own callers, the WYSIWYG to source round trip, paste and insert, hand it HTML sanitised earlier, so the editor itself is not exposed. An application is exposed when it feeds untrusted HTML straight into toBBCode(), for example stored content, clipboard HTML, or markup that came from another user.

Root cause

toSource() builds a temporary container, assigns the raw argument to container.innerHTML, and appends the container to the live document.body to walk the DOM. Nothing sanitises that argument.

src/formats/bbcode.js
function toSource(asFragment, html, context, parent) {   // :2515
  context = context || document;
  // ...
  container.innerHTML = html;                 // :2524  raw, no sanitize()
  // ...
  context.body.appendChild(containerParent);  // :2527  the live document
}

base.toBBCode = base.toSource;                // :2485  public method
base.toSource = toSource.bind(null, false);   // :2567  editor.toBBCode(html) reaches :2524

The container belongs to the live document, so assigning the raw HTML to its innerHTML parses an <img onerror=...> whose handler runs in the page origin. The appendChild only walks the DOM. src/formats/xhtml.js:556 and :559 carry the same pattern.

the sinks that do sanitise
div.innerHTML = sanitize(html);          // src/lib/RangeHelper.js:111
wysiwygBody.innerHTML = sanitize(value); // src/lib/SCEditor.js:2131
pasteArea.innerHTML = sanitize(data.html); // src/lib/SCEditor.js:1645
The raw HTML is parsed inside the live document. container.innerHTML = html no sanitize() document.body.appendChild(...) live document <img onerror> runs in the page origin Fix: container.innerHTML = sanitize(html), or parse in document.implementation.createHTMLDocument('')
The handler fires because the parse happens in the real page, not in an inert document.

Proof of concept

SCEditor 3.2.2 with its default configuration. Open the page and click the button.

self-contained, loads SCEditor unchanged from a CDN
<!doctype html>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sceditor@3.2.2/minified/themes/default.min.css">
<script src="https://cdn.jsdelivr.net/npm/sceditor@3.2.2/minified/sceditor.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sceditor@3.2.2/minified/formats/bbcode.js"></script>

<button id="go">Convert HTML to BBCode</button>
<textarea id="ed"></textarea>
<script>
  sceditor.create(document.getElementById('ed'), { format: 'bbcode', emoticonsEnabled: false });
  var editor = sceditor.instance(document.getElementById('ed'));
  document.getElementById('go').addEventListener('click', function () {
    var untrusted = 'da<img src=dave onerror="alert(/secalert/)">ve';
    editor.toBBCode(untrusted);
  });
</script>

The handler runs when toBBCode() parses the input. The call also returns the expected BBCode, da[img]dave[/img]ve. I verified it with a real click in Chromium, WebKit and Firefox.

The call runs the handler and still returns valid BBCode. editor.toBBCode('da<img src=dave onerror=alert(/secalert/)>ve') page says /secalert/ OK returns: da[img]dave[/img]ve 1. You convert a piece of untrusted HTML 2. The img onerror runs during the parse 3. The call returns the BBCode you expected
The conversion does its job and hands back the BBCode, so a caller sees a normal result with no sign that a handler ran.

Affected versions

npm sceditor up to and including 3.2.2 is affected. Across the whole 3.x line the toSource() path has carried no sanitiser. The bundled DOMPurify version does not matter here, because this path never calls it. The issue is fixed in 3.2.3.

Impact and severity

An application that converts untrusted HTML with editor.toBBCode() runs attacker script in its own origin. Where that application stores the result and shows it to other people, the script becomes a stored XSS that fires on a normal interaction in another user's browser.

I reported a conservative score. It assumes the attacker needs an account to place the HTML, that a user triggers the call, and that the reach is limited. The score rises when the conversion runs without interaction, or when the origin holds a session worth taking.

rating
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:L/A:N  ->  4.6 (Moderate)
CWE-79

The fix

Sanitise the argument in toSource() the way the other sinks do, container.innerHTML = sanitize(html), or parse the HTML in an inert document made with document.implementation.createHTMLDocument('') rather than appending to the live document.body. The same change applies to xhtml.js. The issue is fixed in 3.2.3 and tracked as GHSA-hmw9-qvxc-c9hr.

Disclosure

This was my second report to the maintainer. On 30 September 2026 I reported a separate issue, that SCEditor 3.2.1 bundled a DOMPurify build affected by CVE-2026-41238 and called sanitize() without a CUSTOM_ELEMENT_HANDLING option, which is the condition that CVE needs. That report was credited in 3.2.2. The reply was warm and precise, and it made me want to find a flaw in the editor's own code rather than a bundled dependency. That search led to the toBBCode() path. I reported it on 10 October 2026, the maintainer confirmed it three days later, and the fix shipped as 3.2.3. The advisory GHSA-hmw9-qvxc-c9hr credits me as the finder, and a CVE is pending.

Acknowledgements

My sincere thanks to the maintainer. Across both reports the replies were fast, friendly and exact, and the maintainer requested the CVE on my behalf. Maintainers who make disclosure feel like a shared effort are rare, and this was one.

Timeline

timeline
2026-09-30: reported a separate issue, a bundled DOMPurify affected by CVE-2026-41238, credited in 3.2.2
2026-10-10: reported the toBBCode() XSS
2026-10-13: confirmed by the maintainer
2026-10-22: GHSA-hmw9-qvxc-c9hr published, fixed in 3.2.3
2026-10-29: this write-up published

References