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

# I. Introduction

![](https://cdn.hashnode.com/uploads/covers/65102b1d5866800ea27ebefa/245de54d-36b3-4dea-b775-c27c5d1027c8.png align="center")

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):

```plaintext
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):

```plaintext
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:

```plaintext
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:

```plaintext
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`:

```plaintext
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:

```plaintext
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:

```plaintext
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):

```plaintext
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:

```plaintext
$ 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.
