Skip to content

Reader/anonymous SQL injection via unescaped tag in graph.go query2Stmt exfiltrates cross-notebook private data

High
88250 published GHSA-5rwv-4j4c-f954 Sep 4, 2026

Package

gomod github.com/siyuan-note/siyuan/kernel (Go)

Affected versions

<= 3.8.2

Patched versions

v3.8.3

Description

Summary

kernel/model/graph.go query2Stmt builds SQL for the graph/global-graph feature. The tag branch concatenates lute-parsed tag text raw into two SQL string literals (lines 708 and 715) with no ''' escaping, while the sibling keyword branch escapes correctly at line 694. A publish-mode RoleReader (or an anonymous visitor when Publish.Auth.Enable=false) reaches this via POST /api/graph/getGraph, and the injected SQL executes on the read-write siyuan.db handle with no single-statement/read-only validation, enabling cross-notebook private-data exfiltration past the publish-access boundary.

Root Cause

query2Stmt collects tags via n.IsTextMarkType("tag"). For matched tags, lines 708/715 do:

buf.WriteString("(content LIKE '%#" + tag + "#%')")   // line 708 — RAW, no escape
...
buf.WriteString("ial LIKE '%tags="%" + tag + "%"%'") // line 715 — RAW, no escape

The keyword branch at line 694 does part = strings.ReplaceAll(part, "'", "''") — the tag branch omits this. A tag value containing a single quote breaks out of the string literal.

Correct trigger vector (important): The tag must be supplied as inline HTML <span data-type="tag">…</span>, NOT the #tag# shorthand. parse.Inline turns #tag# into a NodeTag for which IsTextMarkType("tag") returns false (so #tag# payloads route through the escaped keyword branch and are harmless — this is dead code). <span data-type="tag">…</span> is parsed into a real tag-typed NodeTextMark, populating tags[] and reaching the unescaped lines 708/715. Empirically reproduced against the pinned kernel lute (github.com/88250/lute v1.7.8-0.20260827055215-8928f1866da3).

Impact

Confidentiality: a UNION SELECT injected via the span payload executes in sql.DefRefs (block_ref_query.go) and sql.GetAllChildBlocks (block_query.go) on the read-write main DB, reading any blocks/refs row across all (non-encrypted) notebooks. GetAllChildBlocks results become graph nodes (genTreeNodes sets node.Box/node.Path/node.Title/node.Label from row columns). By projecting an allowed (published) box+path while placing secret data in the content columns, the injected row survives FilterGraphByPublishAccess (a post-sink OUTPUT filter that trusts the projected box/path) and the private content is returned to the reader in Title/Label. A publish reader/anonymous visitor thus exfiltrates cross-notebook private data.

Proof of Concept

1. Reach a published SiYuan kernel (default publish port 6808).
2. Proxy issues a RoleReader JWT (anonymous "" account if Publish.Auth.Enable=false).
3. POST /api/graph/getGraph
   {"k":"<span data-type=\"tag\">z') UNION SELECT id,parent_id,root_id,hash,
        box,path,hpath,name,alias,memo,tag,markdown,fcontent,markdown,length,
        type,subtype,ial,sort,created,updated FROM blocks
        WHERE root_id='<PRIVATE_DOC_ID>'-- </span>", "conf":{}}
4. CheckAuth admits RoleReader; getGraph calls BuildGraph("k") unconditionally.
5. query2Stmt hits tag branch 708/715 -> raw quote -> string-literal breakout.
6. DefRefs/GetAllChildBlocks run the UNION on the read-write handle (no stmt guard).
7. Project box/path of a PUBLISHED doc; put private content in name/markdown col.
8. FilterGraphByPublishAccess passes the row (allowed box/path) -> node survives.
9. Response nodes[].title / .label carry the private blocks' content.

Attack Chain

  1. Entry: publish RoleReader (or anonymous visitor if Publish.Auth.Enable=false) sends POST /api/graph/getGraph, body {"k":"<span data-type=\"tag\">z') UNION SELECT … FROM blocks WHERE root_id='<private>'-- </span>","conf":{}}.
    • Guard: CheckAuth. Bypass proof: api/router.go:490 registers getGraph with only model.CheckAuth — no CheckAdminRole/CheckReadonly; CheckAuth (session.go) admits RoleReader; publish JWT issues role: RoleReader (auth.go:262). resetGraph/resetLocalGraph at 487–488 carry the extra guards; getGraph does not.
  2. Processing: getGraphBuildGraph(query) runs for all roles. Guard: IsReadOnlyRoleContext. Bypass proof: that check only gates Conf.Save and the output filter; BuildGraph is called unconditionally (api/graph.go).
  3. Injection: query2Stmt extracts the tag via IsTextMarkType("tag") (fires for the <span data-type="tag"> textmark) and concatenates it unescaped at graph.go:708/715. Guard: ''' escape at line 694. Bypass proof: line 694 is in the keyword branch; the tag branch (708/715) has no ReplaceAll("'","''") — empirically the output SQL contains a bare ').
  4. Sink: stmt executes via raw query() in sql.DefRefs (block_ref_query.go) and sql.GetAllChildBlocks (block_query.go). Guard: single-statement/read-only validation. Bypass proof: neither the model path nor query() (database.go:1472 → db.Query) calls CheckSingleStatement/CheckReadonlyStatement; DB handle opened read-write (no mode=ro/_query_only). SQLite-verified execution.
  5. Impact: reader/anonymous exfiltrates arbitrary blocks/refs/attributes rows across notebooks; the output-only FilterGraphByPublishAccess is bypassed by projecting an allowed box/path.

Bypass Evidence

Standalone harness against the exact pinned kernel lute reproducing query2Stmt (parse.Inline + ast.Walk(IsTextMarkType("tag")) + SQL build) with util.NewLute() options:

Input tag branch reached? Quote handling
#hello# false dead code (routes to escaped keyword branch)
#a' OR '1'='1# false quotes doubled ('') — harmless
<span data-type="tag">hello</span> true populates tags[]
<span data-type="tag">z') UNION SELECT null-- </span> true single quote passes UNESCAPED; output contains (content LIKE '%#z') UNION SELECT null-- #%')

Against real SQLite the resulting DefRefs statement executes the attacker UNION and returns rows a publish reader must not see (cross-notebook content). Confirms the <span data-type="tag"> vector reaches the unescaped sink and the #tag# shorthand does not.

Affected Versions

<= 3.8.2 (latest release == HEAD). Vulnerable code present on the latest tag; git log -S "query2Stmt" -- kernel/model/graph.go shows no fix ever applied to this function.

Suggested Fix

Escape the tag like the keyword branch — tag = strings.ReplaceAll(tag, "'", "''") (plus LIKE-pattern escaping) before graph.go:708/715 — or parameterize. Additionally gate the raw graph query() sinks behind CheckSingleStatement+CheckReadonlyStatement.

Notes

  • Distinct sink from GHSA-336w-67gx-gx2h (search.go fullTextSearchByFTSInBox) and GHSA-q2vg-7qgx-x5fc / CVE-2026-72811 (backlink.go). The q2vg advisory's scoping note explicitly declared graph.go safe (true for the keyword branch, but it missed the tag branch) — this is a genuinely un-surveyed, unpatched sink, not a duplicate or fix bypass.
  • Encrypted notebooks out of scope; no load_extension in default build (no direct RCE).

Reported by zx (Jace) — GitHub: @manus-use

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
Low
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

The product constructs all or part of an SQL command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended SQL command when it is sent to a downstream component. Without sufficient removal or quoting of SQL syntax in user-controllable inputs, the generated SQL query can cause those inputs to be interpreted as SQL instead of ordinary user data. Learn more on MITRE.