GHSA-275H-V5H9-VR82
Vulnerability from github – Published: 2026-09-02 14:17 – Updated: 2026-09-02 14:17Reporter: Cavan Loughran, Celvex Group Inc.
Summary
The /snippets/*filepath route handler serveSnippets in kernel/server/serve.go performs a bare filepath.Join(util.SnippetsPath, filePath) on the single-decoded c.Request.URL.Path and serves the result with c.File(), with NO IsSubPath containment and NO IsSensitivePath denylist - unlike the sibling /export/ (serveExport) and /appearance/ (serveAppearance) handlers, which both carry IsSubPath, and unlike /assets/ (serveAssets), whose traversal was fixed in GHSA-p4m3-mgmm-c664. Because util.SnippetsPath = WorkspaceDir/data/snippets, an authenticated request to GET /snippets/%2e%2e/%2e%2e/conf/conf.json resolves to WorkspaceDir/conf/conf.json and leaks the kernel API token and AccessAuthCode (the same secret file CVE-2026-30869 leaked from /export/); GET /snippets/%2e%2e/%2e%2e/temp/siyuan.db leaks the full document database.
Affected versions
v3.6.5 and current master (verified by direct source read). The /export/ and /assets/ fixes were endpoint-scoped and never reached serveSnippets.
Technical detail
Sink, kernel/server/serve.go, serveSnippets (verbatim, current master and v3.6.5):
func serveSnippets(ginServer gin.Engine) { ginServer.Handle("GET", "/snippets/filepath", model.CheckAuth, func(c *gin.Context) { filePath := strings.TrimPrefix(c.Request.URL.Path, "/snippets/") if !model.IsAdminRoleContext(c) { if "conf.json" == filePath { c.Status(http.StatusUnauthorized) return } } ext := filepath.Ext(filePath) name := strings.TrimSuffix(filePath, ext) confSnippets, err := model.LoadSnippets() ... for _, s := range confSnippets { if s.Name == name && ("" != ext && s.Type == ext[1:]) { c.Header("Content-Type", mime.TypeByExtension(ext)) c.String(http.StatusOK, s.Content) return } } // when not matched in the config file, look it up on the filesystem filePath = filepath.Join(util.SnippetsPath, filePath) // <-- TAINTED join, no containment c.File(filePath) // <-- arbitrary workspace file read }) }
Taint path, end to end: 1. Route GET /snippets/*filepath is registered with the single middleware model.CheckAuth (authentication only; NO CheckAdminRole). 2. c.Request.URL.Path is the request path AFTER Go net/http has percent-decoded it ONCE. The kernel runs gin.New() with default settings (UseRawPath = false, UnescapePathValues = true) and installs NO path-sanitizing middleware (the global ginServer.Use(...) chain is ControlConcurrency, Timing, Recover, corsMiddleware(), jwtMiddleware, gzip, sessions only - none cleans or rejects ..). net/http does not path.Clean URL.Path for gin handlers, so a single-encoded %2e%2e arrives at the handler as a literal .. segment. 3. filePath := strings.TrimPrefix(c.Request.URL.Path, "/snippets/") yields the attacker-controlled remainder, e.g. ../../conf/conf.json. 4. The non-admin guard checks only "conf.json" == filePath; with traversal the value is "../../conf/conf.json", so the guard does not fire (and admins are not checked at all). 5. The config-snippet name/ext loop does not match a traversal string, so control falls through to the filesystem branch. 6. filePath = filepath.Join(util.SnippetsPath, filePath): Go's filepath.Join runs Clean, which RESOLVES .. segments. Clean("WorkspaceDir/data/snippets" + "/../../conf/conf.json") = WorkspaceDir/conf/conf.json. There is no IsSubPath confinement, so the resolved path escapes the snippets root. 7. c.File(filePath) streams the resolved file to the response body.
Directory layout (confirmed by kernel/util/working.go): WorkspaceDir/ data/snippets/ = util.SnippetsPath (the /snippets/ base) conf/conf.json <-- API token + AccessAuthCode (the secret) temp/siyuan.db <-- full SQLite database From util.SnippetsPath = WorkspaceDir/data/snippets the climb-out is exactly two levels: - GET /snippets/%2e%2e/%2e%2e/conf/conf.json -> WorkspaceDir/conf/conf.json (kernel API token, AccessAuthCode, cookie signing material - the same secrets CVE-2026-30869 leaked). - GET /snippets/%2e%2e/%2e%2e/temp/siyuan.db -> WorkspaceDir/temp/siyuan.db (the entire document database). - GET /snippets/%2e%2e/%2e%2e/%2e%2e/etc/passwd (and deeper) reaches host files outside the workspace; c.File serves any path Clean resolves to, subject only to OS file permissions.
Incomplete-fix lineage (patch-diff)
SiYuan has been fixing path traversal in file-serving handlers ONE endpoint at a time: - /export/ (serveExport): CVE-2026-30869 (IsSensitivePath denylist, v3.5.10), then CVE-2026-41894 / GHSA-hjh7-r5w8-5872 (double-encode bypass, v3.6.5). Now has IsSubPath(exportBaseDir, fullPath) + IsSensitivePath. - /appearance/ (serveAppearance): hardened alongside; has IsSubPath(appearancePath, filePath). - /assets/path (serveAssets): GHSA-p4m3-mgmm-c664; delegates to model.GetAssetAbsPath (containment) + publish-access check. - /snippets/filepath (serveSnippets): NONE. NO IsSubPath, NO IsSensitivePath; only a literal "conf.json" string match for non-admins, defeated by traversal. The fixes that closed /export/ and /assets/ were endpoint-scoped (per-handler IsSubPath/IsSensitivePath/GetAssetAbsPath) rather than a shared request-level path-confinement primitive applied to every c.File/http.ServeFile sink. serveSnippets was never touched. It reaches the SAME secret file (conf/conf.json) the parent CVE-2026-30869 was filed for, at a LOWER bar in one respect: it needs only single URL encoding (no double-encode trick), because there is no containment check to bypass in the first place.
Privilege / reachability (stated honestly)
The route is gated by model.CheckAuth only (any authenticated user), NOT CheckAdminRole. CheckAuth admits any principal that presents a valid API token (Conf.Api.Token), a valid session whose AccessAuthCode == Conf.AccessAuthCode, or BasicAuth. The handler's own if !model.IsAdminRoleContext(c) branch confirms non-admin reachability; that branch only blocks the literal string "conf.json", which the traversal payload "../../conf/conf.json" does not match, so even non-admins leak the secret file. SiYuan supports non-admin authenticated roles (RoleEditor, RoleReader) in shared/published workspace modes, plus access-auth-code logins. Privilege required: PR:L (a valid authenticated session), NOT pre-auth and NOT admin-gated. Reading conf/conf.json yields the admin API token/AccessAuthCode, letting a non-admin escalate to full kernel-admin API control; siyuan.db leaks all note content. The kernel HTTP server is the published interface for self-hosted/Docker deployments (AV:N).
SiYuan's SECURITY.md excludes arbitrary file WRITE outside the workspace as a non-issue, but this finding is arbitrary file READ of in-workspace secrets (conf/conf.json, siyuan.db) and host files. Read-side traversal of conf.json is exactly what CVE-2026-30869 was accepted for, so this is squarely in scope.
Non-destructive: no weaponized exploit is included; the chain is described in prose for the maintainer to reproduce.
Secondary (reported for completeness, not the headline): serveRepoDiff (/repo/diff/*path) shares the same bare filepath.Join(util.TempDir, "repo", "diff", requestPath) + http.ServeFile with NO containment, so .. in requestPath escapes TempDir/repo/diff. BUT it carries model.CheckAdminRole (admin-only), so the trust boundary crossed is weak (an admin already holds the API token). Lower severity; shares the same one-line fix.
Impact
Authenticated (non-admin, PR:L) arbitrary workspace file read: kernel API token + AccessAuthCode (conf/conf.json), the full document database (siyuan.db), and host files outside the workspace. Leaking conf.json enables escalation to full kernel-admin API control (and, per the parent CVE, can be chained toward RCE).
Novelty
GitHub Security Advisories for siyuan-note/siyuan include GHSA-2h2p-mvfx-868w / CVE-2026-30869 (/export/), GHSA-hjh7-r5w8-5872 / CVE-2026-41894 (/export/ double-encode), GHSA-p4m3-mgmm-c664 (/assets/ double-encode), plus stored-XSS/template-injection advisories. NONE references /snippets/ or serveSnippets. OSV / GitLab Advisory Database for the Go module github.com/siyuan-note/siyuan/kernel lists only the /export/ and /assets/ path-traversal entries. Not a duplicate; the contribution is the distinct, unpatched sibling handler serveSnippets reached at a non-admin authenticated privilege.
Remediation
Add the same containment SiYuan already uses in serveExport/serveAppearance: resolve filePath and reject if !gulu.File.IsSubPath(util.SnippetsPath, resolved), and apply util.IsSensitivePath. The identical one-line containment also closes the admin-only /repo/diff/*path (serveRepoDiff) handler.
CWE: CWE-22 (Path Traversal), related CWE-23 (Relative Path Traversal). CVSS v3.1 7.7 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N).
Coordinated-disclosure terms: 90 days from acknowledgement before public disclosure, aligned earlier if a fix ships sooner. No public issue / PR / gist / post has been or will be opened before a coordinated date or a shipped fix. No weaponized PoC has been shared.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/siyuan-note/siyuan/kernel"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.0.0-20260704035520-68cc0f537dfa"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-59832"
],
"database_specific": {
"cwe_ids": [
"CWE-22",
"CWE-23"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-02T14:17:44Z",
"nvd_published_at": "2026-07-09T23:17:05Z",
"severity": "HIGH"
},
"details": "Reporter: Cavan Loughran, Celvex Group Inc.\n\nSummary\n-------\nThe /snippets/*filepath route handler serveSnippets in kernel/server/serve.go performs a bare filepath.Join(util.SnippetsPath, filePath) on the single-decoded c.Request.URL.Path and serves the result with c.File(), with NO IsSubPath containment and NO IsSensitivePath denylist - unlike the sibling /export/ (serveExport) and /appearance/ (serveAppearance) handlers, which both carry IsSubPath, and unlike /assets/ (serveAssets), whose traversal was fixed in GHSA-p4m3-mgmm-c664. Because util.SnippetsPath = WorkspaceDir/data/snippets, an authenticated request to GET /snippets/%2e%2e/%2e%2e/conf/conf.json resolves to WorkspaceDir/conf/conf.json and leaks the kernel API token and AccessAuthCode (the same secret file CVE-2026-30869 leaked from /export/); GET /snippets/%2e%2e/%2e%2e/temp/siyuan.db leaks the full document database.\n\nAffected versions\n-----------------\nv3.6.5 and current master (verified by direct source read). The /export/ and /assets/ fixes were endpoint-scoped and never reached serveSnippets.\n\nTechnical detail\n----------------\nSink, kernel/server/serve.go, serveSnippets (verbatim, current master and v3.6.5):\n\n func serveSnippets(ginServer *gin.Engine) {\n ginServer.Handle(\"GET\", \"/snippets/*filepath\", model.CheckAuth, func(c *gin.Context) {\n filePath := strings.TrimPrefix(c.Request.URL.Path, \"/snippets/\")\n if !model.IsAdminRoleContext(c) {\n if \"conf.json\" == filePath {\n c.Status(http.StatusUnauthorized)\n return\n }\n }\n ext := filepath.Ext(filePath)\n name := strings.TrimSuffix(filePath, ext)\n confSnippets, err := model.LoadSnippets()\n ...\n for _, s := range confSnippets {\n if s.Name == name \u0026\u0026 (\"\" != ext \u0026\u0026 s.Type == ext[1:]) {\n c.Header(\"Content-Type\", mime.TypeByExtension(ext))\n c.String(http.StatusOK, s.Content)\n return\n }\n }\n // when not matched in the config file, look it up on the filesystem\n filePath = filepath.Join(util.SnippetsPath, filePath) // \u003c-- TAINTED join, no containment\n c.File(filePath) // \u003c-- arbitrary workspace file read\n })\n }\n\nTaint path, end to end:\n1. Route GET /snippets/*filepath is registered with the single middleware model.CheckAuth (authentication only; NO CheckAdminRole).\n2. c.Request.URL.Path is the request path AFTER Go net/http has percent-decoded it ONCE. The kernel runs gin.New() with default settings (UseRawPath = false, UnescapePathValues = true) and installs NO path-sanitizing middleware (the global ginServer.Use(...) chain is ControlConcurrency, Timing, Recover, corsMiddleware(), jwtMiddleware, gzip, sessions only - none cleans or rejects ..). net/http does not path.Clean URL.Path for gin handlers, so a single-encoded %2e%2e arrives at the handler as a literal .. segment.\n3. filePath := strings.TrimPrefix(c.Request.URL.Path, \"/snippets/\") yields the attacker-controlled remainder, e.g. ../../conf/conf.json.\n4. The non-admin guard checks only \"conf.json\" == filePath; with traversal the value is \"../../conf/conf.json\", so the guard does not fire (and admins are not checked at all).\n5. The config-snippet name/ext loop does not match a traversal string, so control falls through to the filesystem branch.\n6. filePath = filepath.Join(util.SnippetsPath, filePath): Go\u0027s filepath.Join runs Clean, which RESOLVES .. segments. Clean(\"WorkspaceDir/data/snippets\" + \"/../../conf/conf.json\") = WorkspaceDir/conf/conf.json. There is no IsSubPath confinement, so the resolved path escapes the snippets root.\n7. c.File(filePath) streams the resolved file to the response body.\n\nDirectory layout (confirmed by kernel/util/working.go):\n WorkspaceDir/\n data/snippets/ = util.SnippetsPath (the /snippets/ base)\n conf/conf.json \u003c-- API token + AccessAuthCode (the secret)\n temp/siyuan.db \u003c-- full SQLite database\nFrom util.SnippetsPath = WorkspaceDir/data/snippets the climb-out is exactly two levels:\n- GET /snippets/%2e%2e/%2e%2e/conf/conf.json -\u003e WorkspaceDir/conf/conf.json (kernel API token, AccessAuthCode, cookie signing material - the same secrets CVE-2026-30869 leaked).\n- GET /snippets/%2e%2e/%2e%2e/temp/siyuan.db -\u003e WorkspaceDir/temp/siyuan.db (the entire document database).\n- GET /snippets/%2e%2e/%2e%2e/%2e%2e/etc/passwd (and deeper) reaches host files outside the workspace; c.File serves any path Clean resolves to, subject only to OS file permissions.\n\nIncomplete-fix lineage (patch-diff)\n-----------------------------------\nSiYuan has been fixing path traversal in file-serving handlers ONE endpoint at a time:\n- /export/ (serveExport): CVE-2026-30869 (IsSensitivePath denylist, v3.5.10), then CVE-2026-41894 / GHSA-hjh7-r5w8-5872 (double-encode bypass, v3.6.5). Now has IsSubPath(exportBaseDir, fullPath) + IsSensitivePath.\n- /appearance/ (serveAppearance): hardened alongside; has IsSubPath(appearancePath, filePath).\n- /assets/*path (serveAssets): GHSA-p4m3-mgmm-c664; delegates to model.GetAssetAbsPath (containment) + publish-access check.\n- /snippets/*filepath (serveSnippets): NONE. NO IsSubPath, NO IsSensitivePath; only a literal \"conf.json\" string match for non-admins, defeated by traversal.\nThe fixes that closed /export/ and /assets/ were endpoint-scoped (per-handler IsSubPath/IsSensitivePath/GetAssetAbsPath) rather than a shared request-level path-confinement primitive applied to every c.File/http.ServeFile sink. serveSnippets was never touched. It reaches the SAME secret file (conf/conf.json) the parent CVE-2026-30869 was filed for, at a LOWER bar in one respect: it needs only single URL encoding (no double-encode trick), because there is no containment check to bypass in the first place.\n\nPrivilege / reachability (stated honestly)\n------------------------------------------\nThe route is gated by model.CheckAuth only (any authenticated user), NOT CheckAdminRole. CheckAuth admits any principal that presents a valid API token (Conf.Api.Token), a valid session whose AccessAuthCode == Conf.AccessAuthCode, or BasicAuth. The handler\u0027s own if !model.IsAdminRoleContext(c) branch confirms non-admin reachability; that branch only blocks the literal string \"conf.json\", which the traversal payload \"../../conf/conf.json\" does not match, so even non-admins leak the secret file. SiYuan supports non-admin authenticated roles (RoleEditor, RoleReader) in shared/published workspace modes, plus access-auth-code logins. Privilege required: PR:L (a valid authenticated session), NOT pre-auth and NOT admin-gated. Reading conf/conf.json yields the admin API token/AccessAuthCode, letting a non-admin escalate to full kernel-admin API control; siyuan.db leaks all note content. The kernel HTTP server is the published interface for self-hosted/Docker deployments (AV:N).\n\nSiYuan\u0027s SECURITY.md excludes arbitrary file WRITE outside the workspace as a non-issue, but this finding is arbitrary file READ of in-workspace secrets (conf/conf.json, siyuan.db) and host files. Read-side traversal of conf.json is exactly what CVE-2026-30869 was accepted for, so this is squarely in scope.\n\nNon-destructive: no weaponized exploit is included; the chain is described in prose for the maintainer to reproduce.\n\nSecondary (reported for completeness, not the headline): serveRepoDiff (/repo/diff/*path) shares the same bare filepath.Join(util.TempDir, \"repo\", \"diff\", requestPath) + http.ServeFile with NO containment, so .. in requestPath escapes TempDir/repo/diff. BUT it carries model.CheckAdminRole (admin-only), so the trust boundary crossed is weak (an admin already holds the API token). Lower severity; shares the same one-line fix.\n\nImpact\n------\nAuthenticated (non-admin, PR:L) arbitrary workspace file read: kernel API token + AccessAuthCode (conf/conf.json), the full document database (siyuan.db), and host files outside the workspace. Leaking conf.json enables escalation to full kernel-admin API control (and, per the parent CVE, can be chained toward RCE).\n\nNovelty\n-------\nGitHub Security Advisories for siyuan-note/siyuan include GHSA-2h2p-mvfx-868w / CVE-2026-30869 (/export/), GHSA-hjh7-r5w8-5872 / CVE-2026-41894 (/export/ double-encode), GHSA-p4m3-mgmm-c664 (/assets/ double-encode), plus stored-XSS/template-injection advisories. NONE references /snippets/ or serveSnippets. OSV / GitLab Advisory Database for the Go module github.com/siyuan-note/siyuan/kernel lists only the /export/ and /assets/ path-traversal entries. Not a duplicate; the contribution is the distinct, unpatched sibling handler serveSnippets reached at a non-admin authenticated privilege.\n\nRemediation\n-----------\nAdd the same containment SiYuan already uses in serveExport/serveAppearance: resolve filePath and reject if !gulu.File.IsSubPath(util.SnippetsPath, resolved), and apply util.IsSensitivePath. The identical one-line containment also closes the admin-only /repo/diff/*path (serveRepoDiff) handler.\n\nCWE: CWE-22 (Path Traversal), related CWE-23 (Relative Path Traversal). CVSS v3.1 7.7 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N).\n\nCoordinated-disclosure terms: 90 days from acknowledgement before public disclosure, aligned earlier if a fix ships sooner. No public issue / PR / gist / post has been or will be opened before a coordinated date or a shipped fix. No weaponized PoC has been shared.",
"id": "GHSA-275h-v5h9-vr82",
"modified": "2026-09-02T14:17:44Z",
"published": "2026-09-02T14:17:44Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-275h-v5h9-vr82"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-59832"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/commit/68cc0f537dfa4502496dfa794e71835421c25c09"
},
{
"type": "PACKAGE",
"url": "https://github.com/siyuan-note/siyuan"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/releases/tag/v3.7.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Siyuan: Authenticated path traversal in /snippets/ static handler (serveSnippets) leaks conf/conf.json secrets and siyuan.db"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.