Server side cross-site scripting In github.com/siyuan-note/siyuan/kernel

Description

SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585) ## 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

Click "Simulate" — if status turns red, the chain works.

<button onclick=" let tip =

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Package
Affected version
FLAT-K3BP7 – Vulnerability | Fluid Attacks Database