Summary
POST /api/block/getDocInfo and POST /api/block/getDocsInfo are registered with model.CheckAuth only (no CheckReadonly/CheckAdminRole), so a publish-mode reader (RoleReader JWT, or an anonymous visitor on a passwordless publish site) can reach them. Both handlers gate only the requested document, then return the backlink RefIDs and RefCount of that document. The publish filter FilterBlockInfoByPublishAccess clears RefIDs/RefCount only when the requested document itself is inaccessible. For a normally-published document (which the attacker deliberately requests), that branch is never taken, so the block IDs and total count of every block that backlinks into the published document are returned verbatim — including blocks that live inside password-protected and publish-disabled documents, which the publish confidentiality boundary is supposed to withhold.
Root Cause
FilterBlockInfoByPublishAccess (kernel/model/publish_access.go ~1246-1290) clears RefIDs/RefCount (lines ~1285-1286) inside the conditional if (password != "" && !authed) || !accessible evaluated against the requested doc's info.RootID (line ~1279). This only fires when the requested doc is itself inaccessible. RefIDs are populated unconditionally (kernel/model/blockinfo.go ~119-129 / ~231-243) from the workspace-wide refs table (SELECT block_id ... FROM refs WHERE def_block_root_id = ?) over the global siyuan.db, which indexes all non-encrypted notebooks regardless of publish config. disable/password are publish-config attributes (publishAccess.json), NOT index-level exclusions, so blocks inside disabled/password documents are present in the refs table and their IDs leak. The correctly-gated sibling getRefIDs runs FilterRefDefsByPublishAccess (per-element drop of refDefs in inaccessible docs), demonstrating maintainer intent that backlink source IDs are publish-sensitive; getDocInfo/getDocsInfo omit that per-element filter.
Impact
A publish reader learns the existence, block IDs (each block ID encodes a creation timestamp), and total count of blocks — located in password-protected and publish-disabled documents — that reference a published document. This is a metadata confidentiality breach of the same disclosure class as the accepted advisories GHSA-ccxp-q3vg-jvgj (getNotebookInfo doc count/size/mtime) and GHSA-2mrf-mx66-vv6g (save-path box IDs). No document content is returned (C:L).
Proof of Concept
- Operator publishes notebook A (contains public reference doc
D_pub) and keeps notebook B password-protected or publish-disabled; a block in B backlinks into D_pub.
- As a publish reader (or anonymous on a passwordless site):
POST /api/block/getDocInfo with body {"id":"<D_pub RootID>"} (or POST /api/block/getDocsInfo with {"ids":["<D_pub RootID>"],"refCount":true}).
- Response includes
refIDs (the block IDs of the referencing blocks in B) and refCount, disclosing hidden referencing content that the publish password/disable level should have withheld.
Attack Chain
- Entry: Publish reader (RoleReader JWT via the publish reverse proxy, or anonymous on a passwordless publish site) sends
POST /api/block/getDocInfo {"id":"<publishedDocID>"} (or getDocsInfo). Guard: CheckAuth route middleware (kernel/api/router.go:282-283). Bypass proof: CheckAuth admits RoleReader (kernel/model/session.go ~196-201); the route carries NO CheckReadonly and NO CheckAdminRole (verified on tag v3.8.4). Contrast the /api/av/* routes which carry CheckReadonly and abort RoleReader before the handler.
- Requested-doc gate:
checkBlockPublishAccessInBox / CheckBlockIdAccessableByPublishAccess (kernel/api/block.go). Guard: rejects if the requested doc is disabled/password/invisible. Bypass proof: not bypassed — the attacker deliberately requests a legitimately published doc, which passes. This is the ONLY thing gated on this path.
- Filter:
FilterBlockInfoByPublishAccess (kernel/model/publish_access.go ~1246-1290). Guard: clears RefIDs/RefCount at ~1285-1286. Bypass proof: that clear is inside if (password != "" && !authed) || !accessible on the requested doc's info.RootID (line ~1279). For an accessible requested doc the condition is false → RefIDs/RefCount are never touched. getRefIDs instead runs FilterRefDefsByPublishAccess which drops each refDef individually — the filter this path omits.
- Sink:
docInfoContract returns refIDs + refCount (kernel/api/block.go ~1240; apicontract DocInfo.RefIDs/RefCount, block_document.go:7/9).
- Impact: Reader obtains block IDs + count of blocks inside password/disabled documents that reference the published doc.
Bypass Evidence
FilterBlockInfoByPublishAccess clears RefIDs/RefCount ONLY within the info.RootID-inaccessible branch (publish_access.go ~1276-1288); for the accessible requested doc it is a no-op on those fields. RefIDs are populated unconditionally from the workspace-wide refs SQL query (def_block_root_id = ?) over siyuan.db, which indexes password/disabled notebooks (publish config is not an index exclusion). The gated sibling getRefIDs → FilterRefDefsByPublishAccess → filterBlockTreesByPublishAccess → CheckBlockTreeDiscoverableByPublishAccess (checks disable) + password check drops every refDef in an inaccessible doc — proving the RefIDs/RefCount data is treated as publish-sensitive elsewhere in the same codebase.
Affected Versions
<= 3.8.4. Vulnerable code confirmed present on the latest release tag v3.8.4 (HEAD 9f775e8 is the v3.8.4 tag commit); no fix commit exists between the tag and HEAD.
Suggested Fix
On the getDocInfo/getDocsInfo path, filter RefIDs/RefCount per-element through the same publish-access check used by getRefIDs (FilterRefDefsByPublishAccess / per-refDef CheckBlockTreeDiscoverableByPublishAccess + password check), dropping any referencing block whose containing document is publish-disabled or password-protected — rather than only clearing the fields when the requested document itself is inaccessible.
Reported by zx (Jace) — GitHub: @manus-use
Summary
POST /api/block/getDocInfoandPOST /api/block/getDocsInfoare registered withmodel.CheckAuthonly (noCheckReadonly/CheckAdminRole), so a publish-mode reader (RoleReader JWT, or an anonymous visitor on a passwordless publish site) can reach them. Both handlers gate only the requested document, then return the backlinkRefIDsandRefCountof that document. The publish filterFilterBlockInfoByPublishAccessclearsRefIDs/RefCountonly when the requested document itself is inaccessible. For a normally-published document (which the attacker deliberately requests), that branch is never taken, so the block IDs and total count of every block that backlinks into the published document are returned verbatim — including blocks that live inside password-protected and publish-disabled documents, which the publish confidentiality boundary is supposed to withhold.Root Cause
FilterBlockInfoByPublishAccess(kernel/model/publish_access.go ~1246-1290) clearsRefIDs/RefCount(lines ~1285-1286) inside the conditionalif (password != "" && !authed) || !accessibleevaluated against the requested doc'sinfo.RootID(line ~1279). This only fires when the requested doc is itself inaccessible.RefIDsare populated unconditionally (kernel/model/blockinfo.go ~119-129 / ~231-243) from the workspace-widerefstable (SELECT block_id ... FROM refs WHERE def_block_root_id = ?) over the globalsiyuan.db, which indexes all non-encrypted notebooks regardless of publish config.disable/passwordare publish-config attributes (publishAccess.json), NOT index-level exclusions, so blocks inside disabled/password documents are present in therefstable and their IDs leak. The correctly-gated siblinggetRefIDsrunsFilterRefDefsByPublishAccess(per-element drop of refDefs in inaccessible docs), demonstrating maintainer intent that backlink source IDs are publish-sensitive;getDocInfo/getDocsInfoomit that per-element filter.Impact
A publish reader learns the existence, block IDs (each block ID encodes a creation timestamp), and total count of blocks — located in password-protected and publish-disabled documents — that reference a published document. This is a metadata confidentiality breach of the same disclosure class as the accepted advisories GHSA-ccxp-q3vg-jvgj (
getNotebookInfodoc count/size/mtime) and GHSA-2mrf-mx66-vv6g (save-path box IDs). No document content is returned (C:L).Proof of Concept
D_pub) and keeps notebook B password-protected or publish-disabled; a block in B backlinks intoD_pub.POST /api/block/getDocInfowith body{"id":"<D_pub RootID>"}(orPOST /api/block/getDocsInfowith{"ids":["<D_pub RootID>"],"refCount":true}).refIDs(the block IDs of the referencing blocks in B) andrefCount, disclosing hidden referencing content that the publish password/disable level should have withheld.Attack Chain
POST /api/block/getDocInfo {"id":"<publishedDocID>"}(orgetDocsInfo). Guard:CheckAuthroute middleware (kernel/api/router.go:282-283). Bypass proof:CheckAuthadmitsRoleReader(kernel/model/session.go ~196-201); the route carries NOCheckReadonlyand NOCheckAdminRole(verified on tag v3.8.4). Contrast the/api/av/*routes which carryCheckReadonlyand abort RoleReader before the handler.checkBlockPublishAccessInBox/CheckBlockIdAccessableByPublishAccess(kernel/api/block.go). Guard: rejects if the requested doc is disabled/password/invisible. Bypass proof: not bypassed — the attacker deliberately requests a legitimately published doc, which passes. This is the ONLY thing gated on this path.FilterBlockInfoByPublishAccess(kernel/model/publish_access.go ~1246-1290). Guard: clearsRefIDs/RefCountat ~1285-1286. Bypass proof: that clear is insideif (password != "" && !authed) || !accessibleon the requested doc'sinfo.RootID(line ~1279). For an accessible requested doc the condition is false →RefIDs/RefCountare never touched.getRefIDsinstead runsFilterRefDefsByPublishAccesswhich drops each refDef individually — the filter this path omits.docInfoContractreturnsrefIDs+refCount(kernel/api/block.go ~1240; apicontractDocInfo.RefIDs/RefCount, block_document.go:7/9).Bypass Evidence
FilterBlockInfoByPublishAccessclearsRefIDs/RefCountONLY within theinfo.RootID-inaccessible branch (publish_access.go ~1276-1288); for the accessible requested doc it is a no-op on those fields.RefIDsare populated unconditionally from the workspace-widerefsSQL query (def_block_root_id = ?) oversiyuan.db, which indexes password/disabled notebooks (publish config is not an index exclusion). The gated siblinggetRefIDs→FilterRefDefsByPublishAccess→filterBlockTreesByPublishAccess→CheckBlockTreeDiscoverableByPublishAccess(checks disable) + password check drops every refDef in an inaccessible doc — proving the RefIDs/RefCount data is treated as publish-sensitive elsewhere in the same codebase.Affected Versions
<= 3.8.4. Vulnerable code confirmed present on the latest release tag v3.8.4 (HEAD 9f775e8 is the v3.8.4 tag commit); no fix commit exists between the tag and HEAD.Suggested Fix
On the
getDocInfo/getDocsInfopath, filterRefIDs/RefCountper-element through the same publish-access check used bygetRefIDs(FilterRefDefsByPublishAccess/ per-refDefCheckBlockTreeDiscoverableByPublishAccess+ password check), dropping any referencing block whose containing document is publish-disabled or password-protected — rather than only clearing the fields when the requested document itself is inaccessible.Reported by zx (Jace) — GitHub: @manus-use