high · 7.1CVE-2026-54070Jul 10, 2026

CVE-2026-54070: SiYuan Stored XSS via Bazaar README Event Handler Bypass

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

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 lis

Packagegithub.com/siyuan-note/siyuan/kernel
Ecosystemgo
Affected< 0.0.0-20260628153353-2d5d72223df4
Fixed in0.0.0-20260628153353-2d5d72223df4

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.

html
<!-- 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).

References: [1][2][3]

Related research