GitHub Advisoryadvisory · Go
GHSA-25rp-h46x-2hjm SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585)
CRITICAL
Official page ↗
- Aliases
- CVE-2026-44588
- 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:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H
- Weaknesses
- CWE-116, CWE-1188, CWE-79
- Reviewed
- 2026-05-08
Affected packages
• Go github.com/siyuan-note/siyuan/kernel: <= 0.0.0-20260421031503-96dfe0bea474 (no fix yet)
Description
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 (`"`, `'`, `` 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 `&lt;` (the encoder output) decodes once at attribute-parse time to `<`, then a second time at `innerHTML` time to ` {
if (!html) { return html; }
return html.replace(/"/g, """).replace(/'/g, "'")
.replace(/${escapeGreat(title)}
```
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 `` `'`, 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="alert(%27SiYuan%27)"%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)` | `` (real `` chars) |
| `messageElement.innerHTML = …` | HTML parser tokenizes raw ``, 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
Click "Simulate" — if status turns red, the chain works.
<span id="src" class="ariaLabel"
aria-label="%3Cimg src=x onerro…
Related
Alias · CVE (1)
- CVE-2026-44588 SiYuan is an open-source personal knowledge management system. vulnerability