CVE-2026-54070: SiYuan Stored XSS via Bazaar README Event Handler Bypass
A malicious SiYuan marketplace package can embed modern HTML event handlers in its README that bypass the Lute sanitizer and run JavaScript in an Administrator's browser when they view the package…
The problem
SiYuan's renderPackageREADME (kernel/bazaar/readme.go) converts package READMEs from Markdown to HTML using the lute engine with SetSanitize(true). The lute sanitizer's allowAttr function uses a denylist approach: it only rejects attribute names that appear in a hardcoded eventAttrs map copied from the w3schools legacy handler list.
That map does not include modern Pointer Events, Focus Events, Animation, or Transition handlers. Attributes like onpointerover, onpointerdown, onauxclick, onbeforetoggle, onfocusin, onanimationstart, and ontransitionend pass through untouched. The frontend then assigns the rendered HTML directly to mdElement.innerHTML with no DOMPurify pass, inside a plain div in the main document.
No CSP header is sent, so the injected handler fires on the first matching user interaction.
Proof of concept
A working proof-of-concept for CVE-2026-54070 in github.com/siyuan-note/siyuan/kernel, with the exact payload below.
<!-- README.md for a malicious Bazaar package -->
<div onpointerover="alert(document.domain)">plugin description</div>
<!-- Kernel response for getInstalledPlugin / getBazaarPackageREADME
returns the handler verbatim in data.packages[].preferredReadme:
<div onpointerover="alert(document.domain)">plugin description</div>
Classic onerror is blocked; onpointerover is NOT in eventAttrs and passes through.
Moving the pointer over the README div triggers JS in the SiYuan origin. -->
<!-- Minimal curl to confirm the raw output -->
<!-- POST /api/bazaar/getInstalledPlugin
Authorization: Token <API-TOKEN>
{"frontend":"all","keyword":""} -->The root cause is CWE-184 (Incomplete List of Disallowed Inputs). Lute's allowAttr in render/sanitizer.go returns false only when the attribute name exists in eventAttrs, a fixed map derived from the legacy w3schools handler list. That list predates the Pointer Events, FocusEvent bubbling, Web Animations, and CSS Transitions APIs, so onpointerover and its siblings are simply absent.
The fix in 3.7.0 replaces the denylist approach. Rather than trying to enumerate every current and future event attribute, the patched sanitizer switches to an allowlist model: only explicitly permitted, non-executable attributes survive. A strings.HasPrefix(name, "on") guard is the minimal additional check that would have caught this class of bypass entirely.
The missing Content-Security-Policy on kernel responses is a contributing factor: even a default-src CSP would have blocked the inline handler without any sanitizer changes.
The fix
Upgrade SiYuan to 3.7.0 or later (Go module pseudo-version >= 0.0.0-20260628153353-2d5d72223df4). The patched release replaces the lute event-attribute denylist with an allowlist-based approach so unlisted on* attributes are rejected by default. As a defense-in-depth measure, add a Content-Security-Policy: default-src 'self' header on all kernel HTTP responses to neutralize any future inline-handler bypasses.
Reported by Jan Kahmen (turingpoint).
Related research
- critical · 9.9CVE-2026-50551CVE-2026-50551: SiYuan Stored XSS to RCE via Attribute View Asset Cell
- critical · 9.9CVE-2026-54067CVE-2026-54067: SiYuan Stored XSS to RCE via CSS Snippet Style Tag Breakout
- critical · 9.9CVE-2026-54158CVE-2026-54158: SiYuan Stored XSS to RCE via Unescaped Attribute-View Cell Rendering
- high · 7.5CVE-2026-54066CVE-2026-54066: SiYuan Unauthenticated Path Traversal via Double URL Encoding in /assets/ (Publish Mode)