SiYuan before 3.8.2 trusts the attacker-writable text/siyuan clipboard MIME type and skips sanitization in the paste handler, allowing code execution in the Node-enabled desktop renderer. Attackers can craft malicious web pages that write to the clipboard, and when pasted into SiYuan, injected scripts execute with full Node.js access through the Electron main process.
SiYuan versions before v3.8.2 fail to properly filter private attribute-view cell values in the getAttributeViewKeys endpoint. Publish readers can retrieve hidden KeyValues payloads from rows bound to inaccessible documents, exposing private database contents without authorization.
SiYuan before v3.8.2 contains a stored cross-site scripting vulnerability in the Search Assets result list where asset filenames are interpolated into HTML without escaping. Authenticated attackers can craft asset filenames containing malicious markup that executes JavaScript in the victim's browser when searching assets, enabling same-origin API requests and application state manipulation.
SiYuan before v3.8.2 inserts persisted notebook template paths into HTML input value attributes without proper attribute encoding. Attackers can craft malicious template paths that break out of the attribute context and execute JavaScript when a victim opens notebook configuration, enabling same-origin API requests and application state manipulation.
SiYuan before v3.8.2 contains a stored cross-site scripting vulnerability in the search asset preview feature that fails to escape indexed asset content before inserting it into the DOM using innerHTML. Attackers who can place crafted text assets in a workspace can execute JavaScript in the SiYuan origin when victims preview the assets, enabling authenticated API requests and workspace manipulation.
SiYuan before v3.8.2 contains a stored cross-site scripting vulnerability in Bazaar package cards where the iconURL metadata is inserted directly into HTML img src attributes without escaping. Attackers can inject malicious URLs with event handlers that execute JavaScript in the authenticated SiYuan origin when users view Bazaar listings, enabling API requests and application state manipulation.
Siyuan before v3.8.2 fails to apply publish-access filtering to embedded blocks before rendering in the /api/export/preview and /api/lute/copyStdMarkdown endpoints. Attackers with reader access can retrieve the full rendered content of private, hidden, or publish-disabled blocks by accessing public documents containing embed queries that select those blocks.
SiYuan versions <= 3.8.1 (fixed in v3.8.2) contain an incomplete blocklist in the IsForbiddenAbsPath() function (kernel/util/pathguard.go), which only blocks conf/conf.json by exact match and does not restrict the TLS private key (conf/key.pem) or CA private key (conf/ca.key) stored in the same conf/ directory. Because the getFile handler skips the blocklist for RoleAdministrator and all authenticated users receive RoleAdministrator in v3.8.1, any user (or any client on a default no-auth-code instance) can retrieve these private keys via POST /api/file/getFile. On deployments with TLS enabled, this allows decryption of captured HTTPS traffic (key.pem) and forging of certificates trusted by clients that imported SiYuan's CA (ca.key).
SiYuan versions before v3.8.2 contain a path traversal vulnerability in the reader-accessible file-read endpoint that follows symlinks when opening authorized asset paths. Attackers with reader role can request a logical asset under data/assets/ that is a symlink to a file outside the workspace and receive the target file bytes, bypassing workspace boundary restrictions.
SiYuan versions before v3.8.2 contain a path guard bypass vulnerability in the MCP file-access handler that uses case-sensitive matching on Linux filesystems. Attackers can read the protected publishAccess.json file by requesting case-variant paths like PublishAccess.json to disclose sensitive publish-access configuration and metadata.
CVE: This vulnerability corresponds to CVE-2026-68584.
Summary
SiYuan's publish mode defines a "protected" access level: a document that is publicly listed but requires a password to read (per the product's own UI help text, protected = "Publicly visible, requires password to access"). The password is enforced on the primary content path (getDoc, via FilterContentByPublishAccess).
Several other content-returning endpoints getHeadingChildrenDOM, getHeadingDeleteTransaction/getHeadingLevelTransaction/ getHeadingInsertTransaction, and getBacklinkDoc/getBackmentionDoc return rendered block DOM with no password check at all. Combined with reader-reachable endpoints that leak a protected document's internal block IDs, an anonymous reader can retrieve the full body of a password-protected document without the password. This has been reproduced end-to-end on a live instance.
Details
The password control and where it is enforced. Publish access has five levels encoded in visible/password/disable: public, protected (password), hidden, private (password), forbidden. getDoc correctly enforces the password for protected/private documents via FilterContentByPublishAccess. The bug is that other content endpoints do not.
Content endpoints with no password check (all CheckAuth-only): - getHeadingChildrenDOM returns rendered DOM of a heading subtree. - getHeadingDeleteTransaction/getHeadingLevelTransaction/getHeadingInsertTransaction return rendered heading DOM in the computed transaction payload (no mutation occurs on this path). - getBacklinkDoc/getBackmentionDoc return rendered DOM of referencing blocks.
None of these invokes the publish-password check that getDoc applies. Each converts a block ID into full rendered content regardless of the containing document's protected/password status.
The ID-leak that removes the precondition. A protected document is, by design, publicly listed (listDocsByPath filters on visible, and protected documents are visible), so an anonymous reader obtains the document's root ID. The document's internal block/heading IDs are then obtainable from reader-reachable endpoints notably the searchEmbedBlock endpoint (reported separately), whose post-query filter FilterEmbedBlocksByPublishAccess replaces the content string but retains the block ID. So the "filtered" search still yields the protected document's internal heading IDs. (Other reader-reachable endpoints also leak block IDs, the vulnerability does not depend on any single ID source.)
The chain, reproduced on a live instance. Against a real protected document (password set), an anonymous reader on port 6808 with no token and no password:
1. getDoc(protectedDoc) → returns the password-required placeholder (correctly blocked). 2. searchEmbedBlock with a statement selecting heading blocks for the document's root ID → returns the heading IDs (content filtered, IDs retained). 3. getHeadingChildrenDOM(headingId) → returns the full rendered body of the protected document, including its protected content.
The password gate that step 1 enforces is entirely bypassed by step 3.
Proof of Concept
Reproduced on a local instance (SiYuan running locally, publish mode enabled on port 6808, publish Basic Auth disabled). Setup: a document marked "protected" with password, whose body contains the unique marker TOPSECRETCRITICAL123.
1. Confirm the password gate blocks the primary path (anonymous, port 6808): POST http://127.0.0.1:6808/api/filetree/getDoc {"id":"PROTECTEDDOC"} Returns the password-required placeholder correctly blocked.
2. Leak the protected document's heading ID (anonymous, port 6808): POST http://127.0.0.1:6808/api/search/searchEmbedBlock {"stmt":"SELECT FROM blocks WHERE rootid='PROTECTEDDOC' AND type='h'"} Returns heading blocks with their IDs; the content field is filtered but the block ID is retained.
3. Retrieve the protected content without the password (anonymous, port 6808): POST http://127.0.0.1:6808/api/block/getHeadingChildrenDOM {"id":"HEADINGID"} Returns HTTP 200 with the rendered body of the protected document, including TOPSECRETCRITICAL123 retrieved with no token and no password.
getHeadingDeleteTransaction/getHeadingLevelTransaction/getHeadingInsertTransaction and getBacklinkDoc/ getBackmentionDoc provide the same password-free content retrieval given a block ID from the protected document.
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader can read the full content of a password-protected published document without the password, defeating the "protected" access control the product documents as a password gate. The core defect is that these content-returning endpoints perform no publish-password check; the ID-leak endpoints (multiple sources) supply the block IDs that make the bypass reachable anonymously and untargeted. Impact is confidentiality-only (content disclosure); no modification occurs on these paths. Encrypted notebooks are out of scope.
Suggested fix
Apply the publish-password/publish-access check that getDoc uses (FilterContentByPublishAccess/IsReadOnlyRoleContext plus the password-cookie check) to every content-returning endpoint: getHeadingChildrenDOM, the three getHeadingTransaction handlers, and getBacklinkDoc/getBackmentionDoc. Separately, FilterEmbedBlocksByPublishAccess should omit filtered blocks entirely rather than blanking the content while retaining the ID, so that filtered results cannot be used to enumerate a protected document's internal block IDs. The durable fix is to enforce the publish boundary in the shared render/DOM path rather than per-handler, since any content endpoint that omits the check reintroduces this class.
CVE: This vulnerability corresponds to CVE-2026-72790.
Relationship to GHSA-8x84-r2ff-h8pq (please read first)
getNotebookInfo is named in the earlier advisory GHSA-8x84-r2ff-h8pq, in its remediation section, which asked for reader filtering on both getNotebookConf and getNotebookInfo. Commit 3bc014c7d applied that filtering to getNotebookConf only.
That advisory grouped getNotebookInfo under BoxCrypt, and getNotebookInfo never returns BoxCrypt. Anyone checking the handler for key material would correctly find none and move on. This report corrects that characterisation and states what the handler actually discloses, which is notebook metadata rather than key material. If you would prefer this tracked as a follow-up on the original advisory rather than separately, that is entirely reasonable.
Summary
/api/notebook/getNotebookInfo is registered with CheckAuth only and applies no authorization check of any kind. Given a notebook identifier, it returns that notebook's author-written name, document count, total size and creation and modification timestamps. It does so for every notebook the server has loaded, including notebooks that lsNotebooks deliberately withholds from readers because they are closed or not publish-visible.
Details
| Item | Detail | |---|---| | Route | kernel/api/router.go:123 POST /api/notebook/getNotebookInfo → model.CheckAuth → getNotebookInfo | | Middleware | CheckAuth only, no CheckReadonly, no CheckAdminRole | | Guard in handler | None. IsReadOnlyRoleContext, publishAccess, Visible and Closed all return zero matches | | Exposed | BoxInfo{ID, Name, DocCount, Size, HSize, Mtime, CTime, HMtime, HCtime} | | Affected refs | eef105683 (v3.7.3, master HEAD) and v3.7.4-alpha.1, handler identical on both |
The handler. It validates boxID against the ID pattern, calls box := model.Conf.Box(boxID), returns a not-found response if that is nil, and otherwise returns box.GetInfo() unmodified. GetInfo() (kernel/model/box.go:470) walks the notebook's files and returns the metadata listed above. There is no ownership check and no publish-access check anywhere on the path.
The nil branch is also a minor existence oracle: a valid-format identifier that names no loaded notebook is distinguishable from one that does.
Guarded sibling. lsNotebooks, the notebook lister, is also reader-reachable and does filter. For read-only roles it skips any notebook where notebook.Closed holds, and skips any notebook whose publish-access entry has !item.Visible. The codebase therefore intends closed and non-published notebooks to be invisible to readers. getNotebookInfo returns their name and statistics to any reader who supplies the identifier.
Precedent. GHSA-f2rw-w22v-54vh restricted getEncryptedNotebookStatus, which disclosed encrypted-notebook names and lock state, to administrators. getNotebookInfo is the non-encrypted analogue, disclosing regular notebook names and statistics including for notebooks outside the reader's publish scope, and was not restricted.
Reachability, stated precisely, because it differs by branch.
On v3.7.3 a reader can obtain the identifier of a notebook they cannot see, by chaining two endpoints that are currently ungated on that branch. getBlockDefIDsByRefText returns block and document identifiers workspace-wide, including from hidden notebooks. getPathByID then returns the owning notebook identifier for any such block, in its notebook field. getFullHPathByID additionally discloses the notebook name directly. The chain requires no special conditions beyond a reference text that occurs somewhere in the workspace.
On the development branch those sources are already closed. getPathByID now gates through CheckBlockIdMetadataAccessableByPublishAccessInBox, getIDsByHPath through filterFileTreeBlockIDsByPublishDiscoverability, and getBlockDefIDsByRefText and getFullHPathByID are likewise gated. Notebook identifiers are a fourteen-digit timestamp plus seven random characters, and Conf.Box(boxID) returns non-nil only for loaded notebooks, so guessing is not practical. A reader-reachable route on the development branch that yields the identifier of a hidden notebook was not found.
So on the development branch this is defence in depth rather than a live path. It is reported because the handler itself remains unguarded on both branches, and because closing it removes any dependence on those identifier sources staying gated in future.
Proof of Concept
Precondition: publish mode enabled (default port 6808), anonymous when Publish.Auth.Enable is false, otherwise any publish reader account. A notebook that is closed or not publish-visible. Steps 1 and 2 apply to v3.7.3; on the development branch, start at step 3 with a known identifier.
Step 1, obtain block identifiers workspace-wide:
POST http://127.0.0.1:6808/api/block/getBlockDefIDsByRefText {"anchor":"<a word likely to occur anywhere in the workspace>"}
→ 200, block and document identifiers including ones inside hidden notebooks
Step 2, resolve one to its owning notebook:
POST http://127.0.0.1:6808/api/filetree/getPathByID {"id":"<identifier from step 1>"}
→ 200, the notebook field carries the hidden notebook's boxID
Step 3, read the notebook's metadata:
POST http://127.0.0.1:6808/api/notebook/getNotebookInfo {"notebook":"<boxID from step 2>"}
→ 200, returns name, docCount, size, hSize, mtime, ctime, hMtime, hCtime for a notebook that lsNotebooks does not return to this same session
The differential is the point: call lsNotebooks with the same session and confirm the notebook is absent from the list, then call getNotebookInfo and receive its name and statistics.
Impact
An anonymous reader in publish mode, or any publish RoleReader, can read the author-written name, document count, total size and activity timestamps of notebooks that are closed or not published, which are exactly the notebooks the notebook lister withholds from them. Notebook names are free text chosen by the author and routinely describe their contents. Document count and size indicate scale, and the modification timestamp reveals recent activity in a workspace area the reader has no access to. Confidentiality only, with no integrity or availability impact, and no document content is disclosed.
Suggested fix
Apply the same filtering lsNotebooks already performs. When IsReadOnlyRoleContext(c) holds, return the not-found response unless the notebook is publish-visible and not closed, before calling GetInfo(). This is the reader filtering the earlier advisory's remediation section requested for this handler.
CVE: This vulnerability corresponds to CVE-2026-72793.
Summary
/api/system/getConf is registered with CheckAuth only and is reachable by the publish RoleReader token, and anonymously when Publish.Auth.Enable is false. Its non-administrator masking chain is a blocklist that enumerates fields individually. Three fields that the configuration-export endpoint in the same file deliberately clears are absent from that blocklist and are returned to readers:
| Field | JSON | What it is | Cleared by exportConf at | |---|---|---|---| | Conf.CookieKey | cookieKey | The session-cookie signing key | kernel/api/system.go:360 | | Conf.Export.PandocBin | export.pandocBin | Absolute path embedding the OS username | kernel/api/system.go:338 | | Conf.NotebookCrypto | notebookCrypto | Encrypted-notebook key material | kernel/api/system.go:360 |
The project has already classified all three as values that must not leave the server. The reader-facing path returns them.
Details
Route. kernel/api/router.go:70 : POST /api/system/getConf → model.CheckAuth → getConf. No CheckReadonly, no CheckAdminRole.
The masking chain, and what each stage covers. getConf masks through GetMaskedConf() → HideConfSecret() (non-administrators) → FilterConfByPublishIgnore() (readers) → a browser-request path strip.
- GetMaskedConf: UserData, MCPOAuth, AccessAuthCode. - HideConfSecret: AI, Api, Flashcard, ServerAddrs, Publish, Repo, Sync, Secrets, Variables, and the System paths. No reference to CookieKey or Export.PandocBin. - FilterConfByPublishIgnore: UILayout only. - Browser strip (kernel/api/system.go:630-631): System paths only.
Each stage names fields explicitly, so any field nobody thought to add is returned by default.
---
1. CookieKey: the live session-signing key.
The value is passed straight into the session store at startup:
cli/cmd/serve.go:67 go server.Serve(false, model.Conf.CookieKey) kernel/server/serve.go:152 sessionStore = cookie.NewStore([]byte(cookieKey)) kernel/server/serve.go:159 ginServer.Use(sessions.Sessions("siyuan", sessionStore))
gin-contrib/sessions/cookie.NewStore constructed with a single key uses that key as the gorilla/securecookie HMAC key. The siyuan session cookie is signed with the value this endpoint hands out, so an attacker holding it can mint and modify session cookies the server accepts as authentic.
Escalating a forged session to administrator additionally requires the forged SessionData to carry the matching AccessAuthCode which is masked or the instance to have no access-auth code configured, which is a common deployment. The unconditional impact, present on every instance, is disclosure of a persistent cryptographic secret to an unauthenticated party. Rotating it invalidates every active session, so it cannot be quietly refreshed.
---
2. Export.PandocBin: bypasses a shipped privacy control.
The field is an absolute path that embeds the OS username by construction:
conf/export.go:35 PandocBin string json:"pandocBin" model/conf.go:430-431 if "" == Conf.Export.PandocBin { Conf.Export.PandocBin = util.PandocBinPath } util/pandoc.go:154 PandocBinPath = filepath.Join(tempPandocDir, "bin", "pandoc.exe") util/pandoc.go:134 tempPandocDir = filepath.Join(TempDir, "pandoc") util/working.go:359 TempDir = filepath.Join(WorkspaceDir, "temp") util/working.go:312 defaultWorkspaceDir = filepath.Join(userProfile, "SiYuan")
→ C:\Users\<username>\SiYuan\temp\pandoc\bin\pandoc.exe
This one is notable beyond the disclosure itself, because a control was shipped specifically to prevent it. kernel/api/system.go:630 adds, for browser requests:
go if util.IsBrowserRequest(c) { maskedConf.System.WorkspaceDir = "" maskedConf.System.AppDir = "" maskedConf.System.ConfDir = "" maskedConf.System.DataDir = "" maskedConf.System.HomeDir = "" } // 避免泄露用户名等敏感信息
The comment states the goal plainly: avoid leaking the username and other sensitive information. The block enumerates only System. and misses Export.PandocBin, which carries the same username through the same response. A publish reader is a browser request, so the System paths are blanked while export.pandocBin passes through intact. Where an administrator has configured a custom pandoc location, that path is disclosed instead still a filesystem-layout disclosure.
---
3. NotebookCrypto. Encrypted-notebook key material is likewise absent from HideConfSecret while exportConf sets it to nil. Reported previously and included here only because it is the third instance of the same root cause; the fix below closes all three together.
---
The root cause is the blocklist itself. exportConf (kernel/api/system.go:299) clones the configuration and clears each secret before returning it CookieKey, NotebookCrypto, Export.PandocBin, Account, Stat, System.ID, the AI keys. That cloner is the project's own working inventory of what must not leave the server. getConf's non-administrator path maintains a separate, shorter list that has now diverged from it in three places. Any future secret added to the config will default to exposed on the reader path unless someone remembers to extend the blocklist.
Proof of Concept
Precondition: publish mode enabled (default port 6808); anonymous when Publish.Auth.Enable is false, otherwise any publish reader account.
POST http://127.0.0.1:6808/api/system/getConf {}
→ 200. The conf object contains: cookieKey — the session-signing key, cleartext export.pandocBin — absolute path containing the OS username notebookCrypto — encrypted-notebook key material
Differential check against the endpoint that strips them, same instance:
POST http://127.0.0.1:6808/api/system/exportConf
→ cookieKey is empty, export.pandocBin is empty, notebookCrypto is null
The same three values are withheld by one endpoint and returned by the other.
Impact
An anonymous reader in publish mode or any publish RoleReader obtains the server's session-cookie signing key, permitting forgery and tampering of session cookies the server validates as authentic, with administrator authentication reachable on instances that have no access-auth code configured. The same response discloses the operating-system username and workspace layout, defeating a control added specifically to prevent that disclosure, and encrypted-notebook key material.
Suggested fix
Route non-administrator getConf responses through the exportConf cloner rather than extending HideConfSecret field by field. The cloner already handles every field named here and is the list the project actually maintains; keeping two divergent inventories of the same secrets is what produced all three gaps. If a targeted patch is preferred in the interim, clear CookieKey and NotebookCrypto in HideConfSecret and add Export.PandocBin to the IsBrowserRequest block, then audit the config struct for any remaining absolute-path or secret-bearing field.
CVE: This vulnerability corresponds to CVE-2026-72797.
Summary
POST /api/notebook/getEncryptedNotebookStatus returns the identifier, name, and current lock state of every encrypted notebook, with no publish-access filtering. The route is registered CheckAuth only, no CheckReadonly, no CheckAdminRole so it is reachable by the publish RoleReader token and by the anonymous account when Publish.Auth.Enable is false. Encrypted notebooks are private by design; their names frequently reveal the sensitive topic that motivated encrypting them.
Details
Route registration (kernel/api/router.go:131): go ginServer.Handle("POST", "/api/notebook/getEncryptedNotebookStatus", model.CheckAuth, getEncryptedNotebookStatus)
The handler (kernel/api/notebook.go) accepts no arguments and contains no IsReadOnlyRoleContext branch and no publish-access filter of any kind. It returns, for every encrypted notebook, {id, name, unlocked}, the notebook's box.Name and its live lock state together with enabled, count, migrationPending, migrationBoxes, and hasHistoryDependency.
Guarded-sibling asymmetry. The primary notebook listing lsNotebooks, in the same file and also reader-reachable, does filter: it skips notebooks that are Closed and any notebook whose publishAccess entry is not Visible. The project therefore publish-scopes notebook listings but getEncryptedNotebookStatus enumerates all encrypted notebooks unconditionally.
Still unfixed at HEAD. The encrypted-notebook hardening series (issue #18034 idle auto-lock, lock-on-background, key handling, atomic unlock) is local-threat work. Commit f2d966659 ("Expose encrypted notebook unlock status to plugins") introduced this exposure, and no subsequent commit gates it for the publish/reader boundary.
Secondary effect. unlocked: true is a live indicator of exactly when an encrypted notebook's plaintext is resident in memory, the window during which reader-reachable code paths that accept a notebook argument can read its decrypted content.
Proof of Concept
Precondition: publish mode enabled (default port 6808); anonymous when Publish.Auth.Enable is false, otherwise any publish reader account. At least one encrypted notebook exists.
POST http://127.0.0.1:6808/api/notebook/getEncryptedNotebookStatus {}
Returns the full set of encrypted notebooks with their IDs, names, and current unlocked state including notebooks that lsNotebooks withholds from the same reader session. No arguments and no privileged access are required.
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader learns which encrypted notebooks exist, what they are called, and whether each is currently unlocked. Notebook names are themselves sensitive: a user encrypts a notebook precisely because its subject matter is private, and the name commonly states that subject. Disclosing existence and naming to unauthenticated parties defeats that expectation, and the live lock state additionally reveals when the notebook's contents are decrypted in memory. Confidentiality-only.
Suggested fix
For IsReadOnlyRoleContext sessions, either reject the request or restrict the response to notebooks that are publish-visible, mirroring the filtering already applied in lsNotebooks. Encrypted notebooks arguably should never be enumerated to a reader at all.
CVE: This vulnerability corresponds to CVE-2026-72799.
Summary
Five filetree endpoints resolve arbitrary document IDs and paths with no publish-access check of any kind. All are CheckAuth-only, so they are reachable by the publish RoleReader token and by the anonymous account when Publish.Auth.Enable is false. An anonymous reader can map the complete private document tree every notebook, folder, and document title, and which notebook holds each document for documents marked hidden, password-protected, or publish-forbidden, and can resolve titles to document IDs.
Details
None of the following handlers invokes IsReadOnlyRoleContext, CheckBlockIdAccessableByPublishAccess, or CheckPathAccessableByPublishIgnore:
| Endpoint | Returns | Discloses | |---|---|---| | getFullHPathByID (router 159) | GetFullHPathByID(id) | full title path including notebook, e.g. /MySecretNotebook/Confidential/Q3 Layoffs Plan | | getHPathByID (router 157) | GetHPathByID(id) | document-relative title path | | getPathByID (router 158) | {path, notebook} | which notebook a document lives in, plus its .sy storage path | | getIDsByHPath (router 160) | GetIDsByHPath(path, notebook) | title-path → document-ID enumeration | | getHPathByPath (router 155) | HPath from a storage path | title path from a storage path |
Each accepts an arbitrary ID or HPath and resolves it identically for hidden, publish-forbidden, and password-protected documents.
This enables two operations for an unauthenticated caller: 1. Map the private document tree: getFullHPathByID and getPathByID yield every notebook/folder/document title and its containing notebook. 2. Resolve titles to IDs: getIDsByHPath converts a known or guessed title path into document IDs, which are the required input for other block-read endpoints.
Document titles and HPaths are precisely the block metadata that the project's block-metadata restriction (commit ffde3b21e) set out to protect; that change gated getBlockInfo, getDocInfo, and getDocsInfo but left this entire path-resolution family open.
Guarded-sibling asymmetry. getRecentDocs (FilterRecentDocsByPublishAccess), getCriteria (FilterCriteriaByPublishAccess), and getLocalStorage (FilterLocalStorageByPublishAccess) all filter document references for reader sessions. The codebase clearly publish-scopes reader-visible metadata elsewhere; these five endpoints do not.
Verified at origin/master (eef105683): all five handler bodies contain no publish-access call; the storage-family siblings contain their filters; all five routes are registered CheckAuth without CheckAdminRole.
Proof of Concept
Precondition: publish mode enabled (default port 6808); anonymous when Publish.Auth.Enable is false, otherwise any publish reader account. A document exists in a notebook marked publish-forbidden or password-protected.
Resolve a private document's full title path: POST http://127.0.0.1:6808/api/filetree/getFullHPathByID {"id":"<DOCID>"} → /MySecretNotebook/Confidential/Q3 Layoffs Plan
Identify its notebook and storage path: POST http://127.0.0.1:6808/api/filetree/getPathByID {"id":"<DOCID>"} → {"path":"/....sy","notebook":"<BOXID>"}
Enumerate IDs from a title path: POST http://127.0.0.1:6808/api/filetree/getIDsByHPath {"path":"/Confidential","notebook":"<BOXID>"} → document IDs under a folder the reader cannot otherwise access
Each returns data for documents excluded from publishing; no password or membership is required.
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader can enumerate the complete private document structure: notebook names, folder hierarchy, and document titles for content the administrator marked hidden, password-protected, or excluded from publishing. Document titles alone are frequently sensitive (project names, personnel actions, client identifiers). The ID-resolution direction additionally supplies valid document IDs, removing the "attacker must know an ID" precondition for other block-read endpoints. Confidentiality-only.
Suggested fix
For IsReadOnlyRoleContext sessions, gate each of the five handlers with CheckBlockIdAccessableByPublishAccess (or restrict resolution to publish-visible documents), mirroring the treatment already applied in getRecentDocs, getCriteria, and getLocalStorage.
CVE: This vulnerability corresponds to CVE-2026-72800.
Summary
POST /api/av/getAttributeViewKeysByID returns a database's full column schema with no publish-access filtering, while its sibling getAttributeViewKeys applies the filter for reader sessions. Two further endpoints, getBlockDefIDsByRefText and getBlockRelevantIDs return workspace-wide block IDs with no publish scoping. All three are CheckAuth-only, so they are reachable by the publish RoleReader token and by the anonymous account when Publish.Auth.Enable is false.
Details
(a) getAttributeViewKeysByID: ungated column-schema disclosure
The entire handler (kernel/api/av.go, router line 548): go ret.Data = model.GetAttributeViewKeysByID(avID, keyIDs...) // no publish gate
With an empty keyIDs, GetAttributeViewKeysByID calls av.ParseAttributeView(avID) and returns every column's av.Key:
| Field | Discloses | |---|---| | Name, Desc | column title and user-authored description | | Options | the full single/multi-select vocabulary and colours (e.g. status labels such as "Fired", "Confidential") | | Template | the column's Sprig template expression — logic and field references | | Relation | the target avID, allowing pivot to another database | | Rollup, NumberFormat, date config | further schema |
Guarded-sibling asymmetry, same file: getAttributeViewKeys (router line 526) runs FilterBlockAttributeViewKeysByPublishAccess(...) when IsReadOnlyRoleContext. getAttributeViewKeysByID applies nothing. A reader who knows an avID — trivially harvested from the data-av-id attribute of any published document embedding a database obtains the schema of any database in the workspace, including those whose host documents are hidden, password-protected, or publish-forbidden. Relation targets allow walking to sibling databases.
(b) Two unscoped block-ID enumeration oracles
Both CheckAuth-only with no publish gate. Neither returns content of its own, but both yield valid block IDs that other endpoints turn into content:
- getBlockDefIDsByRefText (router line 237) → GetBlockDefIDsByRefText(anchor) returns the block IDs, workspace-wide and including private documents, whose reference/anchor text equals a caller-supplied string: a ref-text → ID oracle. - getBlockRelevantIDs (router line 274) → GetBlockRelevantIDsInBox(id, <notebook from request>) returns parent/previous/next block IDs and traverses decrypted encrypted-notebook structure while the notebook is unlocked, a tree-walk oracle.
Verified at origin/master (eef105683): all three handler bodies contain no publish-access, publish-ignore, or readonly-role check; all three routes are registered CheckAuth without CheckAdminRole.
Proof of Concept
Precondition: publish mode enabled (default port 6808); anonymous when Publish.Auth.Enable is false, otherwise any publish reader account. A database exists whose host document is publish-forbidden or password-protected.
(a) Column schema of any database: POST http://127.0.0.1:6808/api/av/getAttributeViewKeysByID {"avID":"<AVID>"} Returns every column's key object names, descriptions, select vocabularies, template expressions, and Relation target avIDs for a database whose host document the reader may not access.
Control: the sibling getAttributeViewKeys with the same avID returns filtered results for the same reader session, confirming the boundary is enforced there and omitted here.
(b) ID oracles: POST http://127.0.0.1:6808/api/block/getBlockDefIDsByRefText {"anchor":"<known ref text>"} → block IDs workspace-wide, including blocks in private documents
POST http://127.0.0.1:6808/api/block/getBlockRelevantIDs {"id":"<BLOCKID>","notebook":"<BOXID>"} → parent/previous/next block IDs; traverses encrypted-notebook structure when unlocked
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader can read the complete column schema of any database in the workspace, including column descriptions, select-option vocabularies (which frequently encode sensitive category labels), template logic, and relation targets that permit pivoting to further databases regardless of whether the host document is hidden, password-protected, or excluded from publishing.
The two enumeration endpoints additionally supply valid block IDs across the publish boundary, including from encrypted notebooks while unlocked. This removes the "attacker must already know a valid ID" precondition from other block-read endpoints, converting ID knowledge into content disclosure. Confidentiality-only.
Note
getAttributeViewKeysByID is a distinct handler from getAttributeView; although column definitions also appear within the latter's response payload, a fix applied to one handler does not remediate the other, and getAttributeViewKeysByID has its own filtered sibling (getAttributeViewKeys) demonstrating the intended treatment.
Suggested fix
- Gate getAttributeViewKeysByID with FilterBlockAttributeViewKeysByPublishAccess, mirroring getAttributeViewKeys. - Scope getBlockDefIDsByRefText and getBlockRelevantIDs to publish-accessible blocks for reader sessions, and ensure the InBox traversal path applies the same check before walking encrypted-notebook structure.
CVE: This vulnerability corresponds to CVE-2026-72801.
Summary
Two CheckAuth-only endpoints disclose the complete offline attack material for the encrypted-notebook master password, plus the wrapped per-notebook key needed to use it. Both are reachable by the publish RoleReader token and by the anonymous account when Publish.Auth.Enable is false. An unauthenticated remote client can retrieve the Argon2id salt and cost parameters, a verifier that confirms a correct password offline, and the encrypted per-notebook data key reducing the security of every encrypted notebook to the master password's resistance to offline GPU cracking.
Details
(1) POST /api/system/getConf leaks NotebookCrypto.
getConf → GetMaskedConf() marshals the full configuration including NotebookCrypto conf.NotebookCrypto (JSON tag notebookCrypto, not -, so it survives the deep copy). For non-administrators HideConfSecret() is applied, which nulls a dozen secret-bearing fields like AI, MCPOAuth, Api, Flashcard, Publish, Repo, Sync, Secrets, Variables, System paths but contains no reference to NotebookCrypto. FilterConfByPublishIgnore() for readers only touches UILayout.
The reader therefore receives:
| Field | What it is | |---|---| | MasterSalt | global Argon2id salt | | KDFParams | Argon2id memory/time/parallelism cost | | KEKVerifier + VerifierNonce | AES-GCM-encrypted fixed magic, the in-code comment states it exists for offline master-password verification | | KEKMAC | HMAC-SHA256 of the KEK |
Either KEKVerifier or KEKMAC is a self-contained offline oracle:
KEK = Argon2id(guess, MasterSalt, KDFParams) correct if AES-GCM-decrypt(KEKVerifier, VerifierNonce) == magic or HMAC(KEK) == KEKMAC
No server round-trips are required, so there is no rate limiting, lockout, or logging on guesses, and the work is fully GPU-parallelisable.
(2) POST /api/notebook/getNotebookConf leaks the wrapped data key.
box.GetConf() returns the full BoxConf including BoxCrypt.WrappedDEK, the per-notebook data-encryption key wrapped under the KEK via AES-GCM together with WrapNonce. getNotebookInfo is the same class. Once (1) yields the master password, the attacker derives the KEK, decrypts WrappedDEK to recover the real data-encryption key, and decrypts every .sy file in that notebook.
Why this matters beyond the at-rest threat model. Storing verifier and KDF material alongside the ciphertext is reasonable against a local attacker who already has filesystem access. Serving MasterSalt + KDFParams + KEKVerifier + WrappedDEK to an anonymous remote reader converts that at-rest assumption into a remote pre-authentication cracking opportunity.
Guarded-sibling asymmetry. HideConfSecret nulls a dozen secret fields but omits NotebookCrypto. lsNotebooks filters notebook visibility for readers, while getNotebookConf and getNotebookInfo apply no reader filter at all.
Verified at origin/master (eef105683): handler bodies as described; HideConfSecret contains zero NotebookCrypto matches; FilterConfByPublishIgnore touches only UILayout; all relevant struct JSON tags are non--; all three routes are registered CheckAuth without CheckAdminRole.
Proof of Concept
Precondition: publish mode enabled (default port 6808) with at least one encrypted notebook configured; anonymous when Publish.Auth.Enable is false, otherwise any publish reader account.
1. Retrieve the key-derivation material as an anonymous reader: POST http://127.0.0.1:6808/api/system/getConf {} The response's notebookCrypto object contains MasterSalt, KDFParams, KEKVerifier, VerifierNonce, and KEKMAC while the same response has the other secret fields (Api, Repo, Sync, Publish, System paths) correctly blanked, demonstrating the omission.
2. Retrieve the wrapped notebook key: POST http://127.0.0.1:6808/api/notebook/getNotebookConf {"notebook":"<NOTEBOOKID>"} The response contains BoxCrypt.WrappedDEK and WrapNonce.
3. Offline: candidate passwords are verified locally against KEKVerifier/KEKMAC using MasterSalt and KDFParams, with no further server interaction. A recovered password yields the KEK, which unwraps WrappedDEK to the notebook's data-encryption key.
Verification status: the leak paths are confirmed by code inspection at origin/master. A live end-to-end demonstration requires a build from HEAD with an encrypted notebook enabled; the test instance available predates the encrypted-notebook feature, so no runtime reproduction is claimed here.
Impact
An unauthenticated remote client (publish mode with auth disabled) or any publish RoleReader obtains everything needed to mount an unlimited, unthrottled, GPU-parallel offline attack on the encrypted-notebook master password, plus the wrapped data key to decrypt notebook contents once the password is recovered. The confidentiality of every encrypted notebook then rests solely on master-password entropy against an offline attacker, rather than on the password remaining unknown to remote parties. No rate limiting or detection applies, because guessing occurs entirely off-server.
Suggested fix
- In HideConfSecret, replace NotebookCrypto with a minimal {enabled: bool} for non-administrators the frontend only needs the enabled flag for the lock UI stripping MasterSalt, KDFParams, KEKVerifier, VerifierNonce, and KEKMAC. - Apply reader filtering to getNotebookConf and getNotebookInfo so BoxCrypt (including WrappedDEK and WrapNonce) is omitted for non-administrator roles.
CVE: This vulnerability corresponds to CVE-2026-72805.
Summary
Three block endpoints return document content snippets and metadata without any publish-access check, while their sibling getBlockInfo which returns comparable data does enforce one. All three are CheckAuth-only, so they are reachable by the publish RoleReader token and by the anonymous account when Publish.Auth.Enable is false. An anonymous reader supplying a block ID receives content and metadata belonging to publish-forbidden and password-protected documents.
Details
getBlockInfo (kernel/api/block.go) gates on the publish boundary: go if !checkBlockPublishAccess(c, id, ret) { return }
The following siblings in the same file perform no equivalent check:
| Endpoint | Returns | Publish check | |---|---|---| | getBlockInfo | root/title/path metadata | checkBlockPublishAccess: present | | getBlockBreadcrumb | BlockPath.Name : root document title plus every ancestor block's content snippet | none | | getRefText | the block's reference/anchor text (document content) | none | | getBlockTreeInfos | root/title/path metadata for arbitrary block IDs | none |
getBlockBreadcrumb returns the full ancestor chain including each ancestor block's content snippet, and getRefText returns block anchor text both are document content, not just metadata. getBlockTreeInfos returns root/title/path for any caller-supplied ID set via model.GetBlockTreeInfosInBox(...) with no gate.
getBlockBreadcrumb and getRefText additionally accept a client-supplied notebook argument that routes to the InBox variants, so the same unguarded path applies to encrypted-notebook reads while the notebook is unlocked.
The correct primitive already exists in the codebase and is used by getBlockInfo; these three handlers simply do not call it.
Proof of Concept
Precondition: publish mode enabled (default port 6808); anonymous when Publish.Auth.Enable is false, otherwise any publish reader account. A document D is marked publish-forbidden (or password-protected) and contains a block BLOCKID under a heading with distinctive content.
Control: the gated sibling correctly refuses: POST http://127.0.0.1:6808/api/block/getBlockInfo {"id":"BLOCKID"} Blocked by checkBlockPublishAccess.
Disclosure: the ungated siblings return the data anyway: POST http://127.0.0.1:6808/api/block/getBlockBreadcrumb {"id":"BLOCKID"} → ancestor chain including the forbidden document's title and ancestor block content snippets
POST http://127.0.0.1:6808/api/block/getRefText {"id":"BLOCKID"} → the block's reference/anchor text (content of the forbidden document)
POST http://127.0.0.1:6808/api/block/getBlockTreeInfos {"ids":["BLOCKID"]} → root ID, title, and path for the forbidden document
Verified by code inspection at origin/master (eef10568): getBlockInfo contains the checkBlockPublishAccess call; getBlockBreadcrumb, getRefText, and getBlockTreeInfos contain no publish-access, publish-ignore, or readonly-role check in their bodies.
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader can read, for documents explicitly excluded from publishing or protected by a publish password: - the document title and full ancestor chain, including ancestor block content snippets (getBlockBreadcrumb); - block reference/anchor text, i.e. document content (getRefText); - root ID, title, and path metadata for arbitrary block IDs (getBlockTreeInfos).
Because getBlockBreadcrumb and getRefText accept a notebook argument routing to the InBox variants, the same disclosure applies to encrypted notebooks while unlocked. Confidentiality-only; the precondition is a block ID, obtainable from other reader-reachable endpoints.
Suggested fix
Call checkBlockPublishAccess (as getBlockInfo does) in getBlockBreadcrumb, getRefText, and getBlockTreeInfos before returning data for getBlockTreeInfos, apply it per ID and drop unauthorized entries. Confirm the InBox variants (GetBlockRefTextInBox, BuildBlockBreadcrumbInBox) enforce the same boundary so the notebook-argument path is covered.
CVE: This vulnerability corresponds to CVE-2026-72808.
Summary
The /api/asset/getFileAnnotation endpoint returns the content of .sya PDF-annotation files with no publish-access check. It is gated by CheckAuth only, so it is reachable by the publish RoleReader token and by the anonymous account when Publish.Auth.Enable is false. Its sibling, the /assets/ asset route does enforce publish access, including the publish password. An anonymous reader who knows an asset path can therefore read the private PDF annotations (highlights, notes) attached to assets in publish-forbidden, password-protected, or unpublished documents.
Details
getFileAnnotation resolves the annotation file via GetAssetAbsPathInBox (no path traversal) and returns the .sya content. Unlike the /assets/ route, which applies the publish-access filter including password enforcement before serving asset bytes, getFileAnnotation applies no publish-access, publish-ignore, or password check. The guarded-sibling asymmetry indicates the boundary is meant to apply to this data and was omitted here.
.sya files for encrypted-box assets are fail-closed and not exposed. The gap is limited to non-encrypted assets.
Route / auth tier. getFileAnnotation is registered CheckAuth-only. CheckAuth admits RoleReader; the publish proxy forwards port-6808 traffic with a Reader JWT (anonymous when publish auth is disabled).
Proof of Concept
Reproduced on a local instance (publish mode on 6808, Basic Auth off).
Setup (admin, 6806): 1. createNotebook{name:"AnnotPoc"} → box 2. createDocWithMd{notebook, path:"/annot-victim", markdown:"doc with a pdf"} → doc 3. POST /api/asset/upload (multipart id=<doc>, file[]=@secret.pdf) → assets/secret-...pdf 4. setFileAnnotation{path:"<asset>.sya", data:"{annotSecret:ANNOTSECRET4471,note:private highlight}"} 5. setPublishAccess{id:<doc>, visible:false, password:"", disable:true} → doc forbidden
Exploit (anonymous reader, 6808, no token): POST http://127.0.0.1:6808/api/asset/getFileAnnotation {"path":"assets/secret-...pdf.sya"} Returns: {"code":0,"data":{"data":"{\"annotSecret\":\"ANNOTSECRET4471\",\"note\":\"private highlight\"}"}} The annotation content of a publish-forbidden document is returned to an anonymous reader with no publish-access check, while the /assets/ route serving the same asset class enforces publish access and password.
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader can read the private PDF annotations (highlights and notes) of assets belonging to publish-forbidden, password-protected, or unpublished documents, given the asset path. This defeats the publish-access/password boundary for annotation data. Scope is limited to annotated PDFs in non-encrypted notebooks; encrypted-box annotations are not exposed. Confidentiality-only.
Suggested fix
Apply the same publish-access check the /assets/ route uses to getFileAnnotation resolve the asset's owning document and enforce the publish-access/publish-ignore / password check before returning .sya content.
CVE: This vulnerability corresponds to CVE-2026-72809.
Summary
The kernel's CheckAuth grants RoleAdministrator to any request whose RemoteAddr is loopback (127.0.0.1), for a specific set of endpoints, and these localhost bypasses sit outside the accessAuthCode gate so they apply even when an access auth code is configured. This is demonstrated live (Part A below).
Separately, the fixed-port reverse proxy (fixedport.go) forwards requests to the kernel over loopback and injects no authentication token, and no SetTrustedProxies is configured, so gin does not derive the client address from forwarding headers. By code inspection, a request forwarded through this proxy would reach the kernel with RemoteAddr = 127.0.0.1. If the fixed-port proxy is bound to a network interface (via NetworkServe) and forwards remote requests to the kernel this way, the localhost bypass would grant a remote unauthenticated caller admin on those endpoints. This second step is established by reading the code but was not reproduced end-to-end, and I'm asking the maintainer to confirm the proxy's runtime forwarding behavior (Part B below).
Details
Localhost-trust admin bypass (proven). CheckAuth (session.go:298-321) contains localhost-only bypasses that key off RemoteAddr and grant RoleAdministrator. They sit outside the accessAuthCode gate i.e. they apply even when an access auth code is set and cover /api/system/exit, getNetwork, getWorkspaceInfo, /assets/, and /export/.
Fixed-port proxy behavior (code inspection). fixedport.go is a plain reverse proxy that dials the kernel at 127.0.0.1 and injects no token unlike the publish proxy, which injects a RoleReader JWT. There is no SetTrustedProxies call, so gin does not rewrite RemoteAddr from X-Forwarded-For. On this reading, a request forwarded through the fixed-port proxy reaches the kernel with RemoteAddr = 127.0.0.1.
The composition (conditional). If both hold at runtime, then: remote request → fixed-port proxy on a network interface → forwarded to kernel at 127.0.0.1 (no token) → kernel sees RemoteAddr = 127.0.0.1 → localhost bypass grants admin for the endpoints above. I have proven the final link (localhost → admin) and read the code for the forwarding link, but have not confirmed at runtime that the fixed-port proxy is instantiated and forwards remote requests as loopback in a shipped configuration.
Distinct from the previously-dismissed localhost→admin observation. That observation concerned the publish proxy path, where the injected RoleReader JWT causes CheckAuth to early-return before reaching the localhost bypasses. The fixed-port proxy injects no token, so a request through it would fall through to the RemoteAddr-keyed bypass instead. Different proxy, different code path.
Proof of Concept
Part A: the localhost bypass grants admin without auth, outside the auth-code gate (demonstrated live). On a local instance with an access auth code configured, the same no-token getWorkspaceInfo request returns different results depending on the source address the kernel sees: - From a non-loopback source (kernel sees a non-127.0.0.1 address): HTTP 401. - From 127.0.0.1 (kernel sees loopback): {"code":0,"data":{"workspaceDir":"/siyuan/workspace",…}} admin data, no auth, despite accessAuthCode being set.
This confirms the localhost-trust bypass grants admin for these endpoints and is not gated by the access auth code.
Part B: remote → proxy → loopback (code inspection only; NOT reproduced). By reading fixedport.go, the proxy dials 127.0.0.1, injects no token, and no SetTrustedProxies is set. I was not able to reproduce this end-to-end: the serve CLI in the container image tested exposes only --port and --accessAuthCode, not a flag that instantiates the fixed-port proxy in the NetworkServe-on-a-non-default-port shape, so the remote→proxy→loopback chain was not exercised at runtime. I did not invoke the destructive /api/system/exit endpoint. I'm asking the maintainer to confirm: under what conditions is the fixed-port proxy instantiated, does it bind a non-loopback interface under NetworkServe, and does it forward to the kernel preserving the client address or as loopback? That determines whether Part A's bypass is remotely reachable.
Impact
Confirmed (Part A): on any deployment where a caller can cause the kernel to see a loopback RemoteAddr, the endpoints /api/system/exit, getNetwork, getWorkspaceInfo, /assets/, and /export/ are reachable with admin rights without the access auth code i.e. the auth code does not protect these endpoints against a loopback-sourced caller. On its own this is a local/adjacent bypass of the access-code control for those endpoints. If the fixed-port proxy forwards remote requests to the kernel as loopback (to be confirmed by the maintainer), a remote unauthenticated attacker obtains those admin capabilities, remote kernel shutdown (DoS), network/workspace-path disclosure, and admin-level asset/export reads that bypass the publish-access filter. This would be a remote authentication bypass. It does not grant the full admin API only the localhost-trusted endpoints.
Suggested fix
Do not derive admin trust from RemoteAddr when a proxy forwards over loopback. Options: have the fixed-port proxy inject an explicit role/token (as the publish proxy does) so the kernel authorizes on the claim rather than the source address; or configure SetTrustedProxies and derive the real client address before applying any localhost bypass; or require the access auth code for these endpoints regardless of source address. The localhost bypass assumes loopback implies a local trusted caller, any loopback-dialing proxy breaks that assumption.
CVE: This vulnerability corresponds to CVE-2026-72812.
Summary
The /api/ref/refreshBacklink endpoint is gated by CheckAuth only. Unlike its mutating siblings, it carries no CheckAdminRole, no CheckReadonly, and no inline reader-role guard so it falls through all three authorization mechanisms the codebase uses to protect write operations. A publish RoleReader or the anonymous account when Publish.Auth.Enable is false can invoke it, forcing the server to flush its pending write-transaction queue, scan all references globally, load and parse referencing trees from disk, and enqueue database writes. This violates the read-only invariant (it writes even in a globally read-only workspace), provides an unauthenticated resource-amplification/DoS primitive, and applies no per-object access check to the caller-supplied ID.
Details
Route / auth tier. router.go: Handle("POST", "/api/ref/refreshBacklink", model.CheckAuth, refreshBacklink), CheckAuth only. CheckAuth admits RoleReader; the publish proxy forwards port-6808 traffic with a Reader JWT (anonymous account when publish auth is disabled). Anonymous/reader reachable.
Guard fall-through. SiYuan protects write handlers with one of three mechanisms: route-level CheckAdminRole (AV/riff/repo/sync/setting/snippet/notebook mutations), route-level CheckReadonly (filetree/block/attr/tag mutations), or an inline IsReadOnlyRoleContext check (e.g. updateEmbedBlock, updateRecentDocTime). refreshBacklink has none of the three.
Write path reached. refreshBacklink calls model.RefreshBacklink(id): - FlushTxQueue() — forces the pending write-transaction queue to disk. - refreshRefsByDefID(defID) → QueryRefsByDefID(defID) (global scan with encrypted-box fallback loop) → filesys.LoadTrees(rootIDs) (disk read and Lute parse of every referencing tree) → sql.UpdateRefsTreeQueue(tree) (enqueues DB writes) → ref-count task update.
The handler also does not consult util.ReadOnly, so it executes its writes even when the workspace is configured globally read-only.
No per-object authorization. defID is attacker-controlled and receives no publish-access or ownership check, so a reader can force a reference reindex of any document, including publish-forbidden/unpublished ones (cross-scope).
Proof of Concept
Reproduced on a local instance (SiYuan running locally, publish mode enabled on port 6808, publish Basic Auth disabled), as an anonymous reader (no token):
Target endpoint executes the full write path: POST http://127.0.0.1:6808/api/ref/refreshBacklink {"id":"<any block id>"} Returns {"code":0,"msg":"","data":null} HTTP 200 the handler ran to completion, flushing the transaction queue and enqueuing ref writes.
Controls: the guarded mutating siblings correctly reject the same anonymous session: POST http://127.0.0.1:6808/api/tag/renameTag → 403 (CheckAdminRole and CheckReadonly) POST http://127.0.0.1:6808/api/block/foldBlock → 403 POST http://127.0.0.1:6808/api/block/updateEmbedBlock → code 0 no-op (inline IsReadOnlyRoleContext blocks the write) The 200-vs-403 contrast confirms refreshBacklink is reachable and executes where its siblings are blocked.
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader can:
- Bypass the read-only invariant: trigger persistent server-side writes (transaction-queue flush and reference reindex), including in a workspace configured globally read-only. - Amplify resource use without authentication: each call forces a transaction flush, a global reference scan, and disk read and parse of every referencing tree, with an attacker-controlled id and no rate limiting, a DoS primitive. - Act cross-scope: defID receives no publish-access check, so a reader can force reindexing of documents outside their publish scope. This is an integrity-invariant violation and a resource-amplification vector, not data corruption or injection, the caller cannot control the content of the writes, only trigger them. Impact is integrity-low and availability-low; no confidentiality impact and no attacker-controlled data reaches storage.
Suggested fix
Apply the same guard its mutating siblings use, add CheckReadonly (and CheckAdminRole if reference refresh is intended to be an authenticated operation) to the route, or an inline IsReadOnlyRoleContext check consistent with updateEmbedBlock. The endpoint should also honor util.ReadOnly and apply a publish-access check to defID so a reader cannot force cross-scope reindexing. More broadly, the three-way guard strategy (route middleware vs. inline check vs. none) is what allowed this handler to receive no gate at all; a structural backstop, a role-scoped route group for the mutation surface would prevent recurrence.
CVE: This vulnerability corresponds to CVE-2026-68585.
Summary
The /api/block/getBlockInfo endpoint returns document root metadata including the document title (rootTitle) for a block in a publish-forbidden document, with no publish-access check. Its sibling /api/block/getDocInfo applies the publish-access filter, getBlockInfo does not. Both are gated by CheckAuth only, so getBlockInfo is reachable by the publish RoleReader token and by the anonymous account when Publish.Auth.Enable is false.
Details
The list/info side of this API is filtered while the block-info twin is not the asymmetry indicates an oversight rather than intended behavior:
| Endpoint | Returns | Publish-access filter | Route | |---|---|---|---| | getDocInfo | document info/metadata | present | CheckAuth | | getBlockInfo | box, path, rootID, rootTitle, rootChildID, rootIcon | none | CheckAuth |
getBlockInfo takes a caller-supplied block ID, validates only its format, and returns the containing document's root metadata including rootTitle (the document title) with no IsReadOnlyRoleContext / publish-access check. Because getDocInfo performs the filtering for equivalent data, the boundary is clearly meant to apply here; getBlockInfo omits it.
Proof of Concept
Reproduced on a local instance (SiYuan running locally, publish mode enabled on port 6808, publish Basic Auth disabled). Setup: a publish-forbidden document D whose title is a unique marker, containing a block BLOCKID.
1. Mark the document publish-forbidden (admin action): POST http://127.0.0.1:6806/api/filetree/setPublishAccess Authorization: Token <admin-token> {"id":"DOC","visible":false,"password":"","disable":true}
2. Disclosure: the block-info endpoint returns the forbidden doc's title (anonymous, port 6808): POST http://127.0.0.1:6808/api/block/getBlockInfo {"id":"BLOCKID"} Returns HTTP 200 with data.rootTitle set to the publish-forbidden document's title, along with box, path, rootID, and rootIcon. This document's title is not returned by the reader-facing filtered paths.
Impact
An anonymous reader (publish mode with auth disabled) or any publish RoleReader can read the title and root metadata (notebook, path, root ID, icon) of a publish-forbidden document by supplying a block ID from it. This discloses the existence, title, and location of documents an administrator marked as excluded from publishing.
Precondition and scope (stated honestly): the request requires a block ID from the target document; this endpoint does not enumerate arbitrary documents. The disclosure is limited to document metadata, title, notebook, path, root ID, icon — not the document body. Block IDs for forbidden documents are obtainable from other CheckAuth-only endpoints that lack the publish-access filter (reported separately). Impact is confidentiality-only, limited to metadata; no content body, no modification. Encrypted notebooks are out of scope.
Suggested fix
Apply the same publish-access check getDocInfo uses to getBlockInfo before returning root metadata, resolve the block's document and enforce IsReadOnlyRoleContext / the publish-access filter, consistent with the sibling endpoint.
CVE: This vulnerability corresponds to CVE-2026-69084.
Summary
The /api/search/searchEmbedBlock endpoint passes a client-supplied SQL statement verbatim to the database with no validation. The endpoint is gated by CheckAuth only reachable by the publish RoleReader token, and by the anonymous account when Publish.Auth.Enable is false. The statement runs on the main read-write siyuan.db handle through a driver that executes stacked statements, with no single-statement or read-only guard. An unauthenticated request can therefore execute arbitrary SQL reading and writing content across all cleartext notebooks.
Unlike SQL injection into a fixed query, this endpoint accepts a full SQL statement by design and simply fails to restrict who may call it or what the statement may do.
Details
Data flow verbatim, unvalidated:
- searchEmbedBlock (kernel/api/search.go): stmt := arg["stmt"].(string) passed directly to model.SearchEmbedBlock(stmt, …). No validation. - SearchEmbedBlock → SearchEmbedBlockInBox → sql.SelectBlocksRawStmtNoParse(stmt, …) → selectBlocksRawStmt → query(stmt). - query() (kernel/sql/database.go) calls db.Query(stmt) on the global siyuan.db handle.
Missing guards. The comparable endpoints enforce restrictions this one omits: - /api/query/sql runs CheckSingleStatement (all modes) and CheckReadonlyStatement (readonly mode) and is route-gated CheckAuth + CheckAdminRole + CheckReadonly. - fullTextSearchBlock rejects the SQL search method for non-admins (if method == 2 && !IsAdminRoleContext(c)).
searchEmbedBlock has none of these, no statement check, no read-only check, no admin gate.
Route/auth tier. router.go: Handle("POST", "/api/search/searchEmbedBlock", model.CheckAuth, searchEmbedBlock), CheckAuth only. CheckAuth admits RoleReader, and the publish proxy forwards port-6808 traffic with a Reader JWT (anonymous account when publish auth is disabled). Anonymous/reader reachable.
Handle/stacking. query() uses the global siyuan.db handle, the same read-write handle behind the accepted searchDocs finding. Driver is the vendored 88250/go-sqlite3 (mattn fork), whose connection query loops over ;-separated statements, so stacked statements execute for their side effects. The DSN sets no mode=ro / queryonly, so the handle is read-write; ATTACH is available.
Post-hoc filter does not bound the statement. FilterEmbedBlocksByPublishAccess runs on the returned slice after query() has executed. It filters rows; it cannot constrain what the statement did. Any write or ATTACH side effect has already occurred before the filter runs. It is not a security boundary for this sink.
Scope. The main blocks/DB handle spans every opened cleartext notebook. Encrypted notebooks use separate per-box databases and are excluded.
Impact
An unauthenticated request (publish mode with auth disabled) or any publish RoleReader executes arbitrary SQL on the main read-write database. This permits cross-notebook read disclosure of document content, and via the read-write handle and statement stacking, modification of database content and ATTACH-reachable files. No admin role, CSRF token, or write permission through the normal API is required. Encrypted notebooks are not exposed. Code execution is not reachable in the default build (no loadextension).
PoC Steps
1. Build the kernel image from pinned HEAD
docker build -f D:/bb/zitadel/chatto/siyuan/Dockerfile.poc -t siyuan-head D:/bb/zitadel/chatto/siyuan
2. Run fresh, workspace mounted to the host
docker rm -f siyuan-poc 2>nul docker run -d --name siyuan-poc -p 6806:6806 -p 6808:6808 -v D:/bb/siyuan:/siyuan/workspace siyuan-head serve --accessAuthCode=1234567 --port=6806
Confirm it booted (version string back, not Cobra help):
docker logs siyuan-poc curl -s http://127.0.0.1:6806/api/system/version
3. Grab the admin API token from the host file. Use that value wherever TOKEN appears below (yours was g4wj3r04ntobe9m4).
4. Turn on the reader surface: publish on 6808, Basic Auth OFF
curl -s -X POST http://127.0.0.1:6806/api/setting/setPublish -H "Content-Type: application/json" -H "Authorization: Token TOKEN" -d "{\"enable\":true,\"port\":6808,\"auth\":{\"enable\":false,\"accounts\":[]}}"
Must return "data":{"port":6808,...} (non-zero port = bound OK). Port 6808, not 6806.
5. THE PROOF: anonymous reader on 6808, no token (the differential)
Guarded sibling rejects reader SQL: curl -i -X POST http://127.0.0.1:6808/api/search/fullTextSearchBlock -H "Content-Type: application/json" -d "{\"query\":\"SELECT FROM blocks LIMIT 1\",\"method\":2}"
→ -1 / "SQL search requires administrator privileges"
searchEmbedBlock accepts the same reader SQL (no guard): curl -i -X POST http://127.0.0.1:6808/api/search/searchEmbedBlock -H "Content-Type: application/json" -d "{\"embedBlockID\":\"\",\"stmt\":\"SELECT FROM blocks LIMIT 1\",\"excludeIDs\":[]}" → HTTP 200 / code:0 : arbitrary SQL executed at the anonymous reader tier. This pair is the finding.
Suggested fix
Apply the same controls the sibling SQL endpoints already use: route searchEmbedBlock through CheckSingleStatement and CheckReadonlyStatement, and gate the raw-SQL capability behind CheckAdminRole (as /api/query/sql and fullTextSearchBlock's SQL method do). At minimum, the read paths should run on a queryonly=1 handle so a reader-reachable statement cannot write or ATTACH.
SiYuan before v3.8.1 contains a stored cross-site scripting vulnerability in confirmDialog() where unescaped package names and notebook names are interpolated directly into innerHTML assignments. Attackers can submit malicious bazaar packages with HTML/script payloads in the name field that execute in users' browsers when uninstalling packages or unlocking encrypted notebooks.
SiYuan before v3.8.1 fails to properly escape block name, alias, and memo fields in hint, backlink, and breadcrumb rendering functions. Attackers can set a block's name to contain HTML/script tags that execute when another user views documents referencing or displaying that block.
SiYuan before v3.8.1 does not apply the IsForbiddenAbsPath guard (introduced in GHSA-c8r8-95hg-mp34) to the /history/path and /repo/diff/path endpoints in kernel/server/serve.go. These routes require admin authentication but construct file paths independently, so an authenticated administrator can retrieve historical snapshots of sensitive files that the guard is meant to block, including data/.siyuan/publishAccess.json (plaintext publish-mode passwords) and files under data/templates/.
SiYuan 3.8.0 contains a path traversal / sensitive file exposure vulnerability in the RenderTemplate function (kernel/model/template.go), reachable via the POST /api/template/render endpoint (kernel/api/template.go). The endpoint restricts the supplied path only to the workspace directory (util.IsAbsPathInWorkspace) but, unlike the file API's refuseToAccess() blocklist, applies no sensitive-path exclusion. This allows an authenticated attacker to read sensitive workspace files, including conf/conf.json, which contains the API token and cookie signing key. The issue is fixed in v3.8.1.
SiYuan before v3.8.1 fails to filter invisible-tier content from SQL embed blocks, attribute-view keys, and attribute-view backlinks in publish mode. Anonymous readers can enumerate invisible content through these three listing mechanisms despite admin configuration marking content unlisted.
SiYuan before v3.8.1 contains a path traversal vulnerability in the asset.upload MCP tool that accepts arbitrary absolute file paths without workspace boundary validation. Attackers can induce the AI Agent to upload sensitive files such as SSH keys or credentials from outside the workspace into the asset directory through prompt injection.
SiYuan versions before v3.8.1 contain a server-side request forgery vulnerability in the httprequest and webfetch agent tools that perform DNS resolution only at guard time without validating the connect-time resolution. Attackers can use DNS rebinding to answer the guard resolution with a public IP and the connect resolution with a private or metadata IP, bypassing the SSRF defense to access cloud instance metadata and internal services.