{"id":"CVE-2026-59832","aliases":["GHSA-275h-v5h9-vr82","GO-2026-6350"],"title":"Siyuan: Authenticated path traversal in /snippets/ static handler (serveSnippets) leaks conf/conf.json secrets and siyuan.db","summary":"Siyuan: Authenticated path traversal in /snippets/ static handler (serveSnippets) leaks conf/conf.json secrets and siyuan.db","severity":"high","cvss":7.7,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","vendor":"siyuan-note","product":"github.com/siyuan-note/siyuan/kernel","ecosystem":"go","affected":["github.com/siyuan-note/siyuan/kernel < 0.0.0-20260704035520-68cc0f537dfa"],"patched":["github.com/siyuan-note/siyuan/kernel 0.0.0-20260704035520-68cc0f537dfa"],"published":"2026-09-02","updated":"2026-09-10","sourceUpdated":"2026-09-10T15:25:57.076834034Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-275h-v5h9-vr82","references":[{"url":"https://github.com/siyuan-note/siyuan/security/advisories/GHSA-275h-v5h9-vr82"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-59832"},{"url":"https://github.com/siyuan-note/siyuan/commit/68cc0f537dfa4502496dfa794e71835421c25c09"},{"url":"https://github.com/siyuan-note/siyuan"},{"url":"https://github.com/siyuan-note/siyuan/releases/tag/v3.7.1"},{"url":"https://github.com/advisories/GHSA-275h-v5h9-vr82"}],"tags":["osv","go","ghsa"],"epss":0.00465,"epssPercentile":0.39364,"cwe":["CWE-22","CWE-23"],"ingestedAt":"2026-09-02T14:45:30.597Z","slug":"CVE-2026-59832","body":"## Overview\n\nReporter: 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 && (\"\" != ext && 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)   // <-- TAINTED join, no containment\n          c.File(filePath)                                        // <-- 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'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.\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  <-- API token + AccessAuthCode (the secret)\n    temp/siyuan.db  <-- 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 -> 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 -> 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'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).\n\nSiYuan'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.\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.\n\n## Affected packages\n\n- `github.com/siyuan-note/siyuan/kernel < 0.0.0-20260704035520-68cc0f537dfa`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/siyuan-note/siyuan/kernel 0.0.0-20260704035520-68cc0f537dfa`","depth":"twilight","depthScore":42,"depthScoreParts":{"impact":42.4,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}