{"id":"CVE-2026-69086","aliases":["GHSA-7hm9-v7vf-7g4w","GO-2026-6373"],"title":"SiYuan: Path Traversal via unvalidated avID in RenderAttributeView/AV read endpoints : reader-reachable cross-scope attribute-view disclo…","summary":"SiYuan: Path Traversal via unvalidated avID in RenderAttributeView/AV read endpoints : reader-reachable cross-scope attribute-view disclosure","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-20260720151813-0f5a0e7c67b0"],"patched":["github.com/siyuan-note/siyuan/kernel 0.0.0-20260720151813-0f5a0e7c67b0"],"published":"2026-09-03","updated":"2026-09-10","sourceUpdated":"2026-09-10T15:25:43.460785762Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-7hm9-v7vf-7g4w","references":[{"url":"https://github.com/siyuan-note/siyuan/security/advisories/GHSA-7hm9-v7vf-7g4w"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-69086"},{"url":"https://github.com/siyuan-note/siyuan"},{"url":"https://www.vulncheck.com/advisories/siyuan-before-path-traversal-via-unvalidated-avid"},{"url":"https://github.com/advisories/GHSA-7hm9-v7vf-7g4w"}],"tags":["osv","go","ghsa"],"epss":0.0035,"epssPercentile":0.28597,"cwe":["CWE-22"],"ingestedAt":"2026-09-03T21:08:46.026Z","slug":"CVE-2026-69086","body":"## Overview\n\n**CVE:** This vulnerability corresponds to [CVE-2026-69086](https://nvd.nist.gov/vuln/detail/CVE-2026-69086).\n\n### Summary\n\nFour attribute-view read endpoints build a filesystem path from a caller-controlled `id`/`avID` and read it without confining the result to the attribute-view storage directory (`DataDir/storage/av/`). On the load (file-exists) code path there is no boundary check, so an `avID` containing `../` segments escapes `storage/av/` and causes the kernel to read a `.json` file elsewhere in the workspace.\n\nThe endpoints require only `CheckAuth`, which the publish service's `RoleReader` token satisfies; when `Publish.Auth.Enable` is `false` the publish proxy uses the anonymous account, making the surface reachable with no credentials.\n\n### Details\n\nAffected endpoints (all gated by `CheckAuth` only, no `CheckAdminRole`):\n\n- `POST /api/av/renderAttributeView` &nbsp;→ `arg[\"id\"]`\n- `POST /api/av/getAttributeViewKeysByID` → `arg[\"avID\"]`\n- `POST /api/av/getAttributeViewKeys` &nbsp;→ `arg[\"id\"]`\n- `POST /api/av/getCurrentAttrViewImages` → `arg[\"id\"]`\n\nIn `model.RenderAttributeView` (`model/attribute_view_render.go`), the only identifier guard `ast.IsNodeIDPattern(avID)` sits **inside** the `if !filelock.IsExist(existPath)` (create) branch:\n\n```go\nexistPath = GetAttributeViewDataPath(avID)      // path built from avID, no check\nif !filelock.IsExist(existPath) {               // NOT-EXIST / CREATE branch\n    if !createIfNotExist {\n        return // NotFound\n    }\n    if !ast.IsNodeIDPattern(avID) {             // <-- ONLY id guard, create branch only\n        return ErrInvalidID\n    }\n    // ... create ...\n}\nattrView, err = av.ParseAttributeView(avID)     // LOAD runs unconditionally\n```\n\nWhen the traversal `avID` resolves to a file that already exists, the `!filelock.IsExist(...)` condition is `false`, the entire block (including the line with `ast.IsNodeIDPattern`) is skipped, and control falls straight through to `av.ParseAttributeView(avID)`. That function rebuilds the path via `filepath.Join(DataDir, \"storage\", \"av\", avID+\".json\")` and calls `filelock.ReadFile` with no `filepath.Rel` / `IsSubPath` / `..` rejection:\n\n```go\n// av.ParseAttributeView -> attributeViewDataPathByBox / GetAttributeViewDataPath\navJSONPath = filepath.Join(DataDir, \"storage\", \"av\", avID+\".json\")  // no boundary check\n// -> parseAttributeViewByPathInBox(avJSONPath, boxID)\ndata, _ = filelock.ReadFile(avJSONPath)                             // SINK\n```\n\n`filepath.Join` cleans the path but does **not** reject `..` segments, so it provides no containment. The three `getAttributeView*` endpoints call `ParseAttributeView` with no create branch at all, so they never even reach the `ast.IsNodeIDPattern` check same defect, same auth tier.\n\nThe root cause is that identifier validation is placed on a single code branch rather than confining the load to the AV base directory, so the load path reads a caller-controlled location.\n\n### PoC\n\n**Precondition:** publish mode enabled (default port `6808`); reachable by a `RoleReader` publish token, or anonymously when `Publish.Auth.Enable` is `false`.\n\nA request to `/api/av/renderAttributeView` with an `id` composed of `../` path segments that resolves to an existing `.json` file outside `DataDir/storage/av/` causes that file to be read and parsed instead of being rejected, because the identifier validation is only reached on the not-exist/create branch.\n\nI have withheld the exact encoded `id` value from this draft to avoid publishing a live traversal against internet-exposed publish instances. I'm happy to provide the precise value and a screenshot privately in this thread on request.\n\n### Impact\n\nAn authenticated publish `RoleReader` or an anonymous client when publish auth is disabled can cause the kernel to read `.json` files outside the attribute-view directory. Because the loaded file is unmarshalled into the attribute-view structure, the reliable primitives are:\n\n1. Disclosure of attribute-view (database) content from other scopes/notebooks the reader is not authorized to see.\n2. A `.json`-path existence oracle for arbitrary workspace locations.\n\nFiles not conforming to the AV schema are read but reflect little content, and the `.json` suffix is force-appended, so this is **not** a general arbitrary-file read. No admin role, CSRF token, or write permission is required.\n\n### Suggested fix\n\nValidate `avID` with `ast.IsNodeIDPattern` before path construction on **all** branches (move it ahead of `FindAttributeViewPath` / `GetAttributeViewDataPath`), or preferably, so every caller inherits it confine at the sink: in `attributeViewDataPathByBox` / `GetAttributeViewDataPath`, compute the joined path and reject it unless `filepath.Rel(avBaseDir, cleaned)` stays within `avBaseDir` (no leading `..`). Sink-side confinement also covers the three `getAttributeView*` endpoints that never reach the create-branch guard.\n\n## Affected packages\n\n- `github.com/siyuan-note/siyuan/kernel < 0.0.0-20260720151813-0f5a0e7c67b0`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `github.com/siyuan-note/siyuan/kernel 0.0.0-20260720151813-0f5a0e7c67b0`","depth":"twilight","depthScore":42,"depthScoreParts":{"impact":42.4,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}