{"id":"GHSA-9gfj-28hw-jchp","summary":"Knowns Unrestricted Path Traversal leading to out-of-bounds arbitrary .md file read, write, and deletion in MCP Docs + Memory Tools","details":"## Overview\n\nVerified. Multiple **Unrestricted Path Traversal** vulnerabilities exist in the Knowns MCP `docs` and `memory` tools, allowing arbitrary file read, write, and deletion operations outside the project sandbox. The storage layer functions (`Get`, `Create`, `Update`, `Rename`, `Delete`) in both `doc_store.go` and `memory_store.go` concatenate user-controlled paths with `filepath.Join()` without any containment validation. \n\nAdditionally, the `docs.update` action with a `newPath` parameter performs a file deletion via `Rename()`, but is classified as `CapWrite` in the permission registry rather than `CapDelete`. This allows an attacker with a `read-write-no-delete` preset to bypass deletion restrictions and destroy arbitrary files outside the project root.\n\n## Affected paths\n\n| File Path | Role | Vulnerability & Execution Impact |\n| :--- | :--- | :--- |\n| **`internal/storage/doc_store.go`** | Vulnerable Sink (Docs) | **Path Traversal in File Operations (CWE-22):** `Get()`, `Create()`, `Update()`, `Rename()`, `Delete()` join user-controlled `path` with `filepath.Join(ds.docsDir(), ...)` without validating path containment. |\n| **`internal/storage/memory_store.go`** | Vulnerable Sink (Memory) | **Path Traversal in Memory Operations (CWE-22):** `GetInLayer()`, `Create()`, `Update()`, `Delete()` join user-controlled `id` with `filepath.Join(dir, models.MemoryFileName(id))` without validation. |\n| **`internal/mcp/handlers/doc.go`** | Pass-Through Handler | **Unsanitized Input Propagation:** MCP handlers pass user-supplied `path`, `folder`, `newPath` directly to storage layer without sanitization. |\n| **`internal/mcp/handlers/memory.go`** | Pass-Through Handler | **Unsanitized Input Propagation:** MCP handlers pass user-supplied `id` directly to storage layer without sanitization. |\n| **`internal/permissions/registry.go`** | Authorization Bypass | **Capability Misclassification (CWE-863):** `docs.update` with `newPath` performs file deletion but is classified as `CapWrite`, bypassing `CapDelete` restrictions. |\n\n## Root Cause\n\n### Missing Path Containment in DocStore\n\nIn `internal/storage/doc_store.go`, all file operations use `filepath.Join()` to construct absolute paths without validating that the resolved path remains within `docsDir()`:\n\n```go\n// Get retrieves a doc by its relative path (without .md extension).\nfunc (ds *DocStore) Get(path string) (*models.Doc, error) {\n    path = strings.TrimPrefix(path, \"/\")\n    path = strings.TrimSuffix(path, \".md\")\n\n    // VULNERABLE: No containment check\n    absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(path)+\".md\")\n    if _, err := os.Stat(absPath); err == nil {\n        // ...\n        return ds.parseFile(absPath, path, folder, false, \"\")\n    }\n    // ...\n}\n\n// Create writes a new doc to .knowns/docs/{path}.md.\nfunc (ds *DocStore) Create(doc *models.Doc) error {\n    if doc.Path == \"\" {\n        return fmt.Errorf(\"doc path is required\")\n    }\n    // VULNERABLE: No containment check\n    absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(doc.Path)+\".md\")\n    if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {\n        return fmt.Errorf(\"create doc dir: %w\", err)\n    }\n    return ds.writeFile(absPath, doc)\n}\n\n// Rename rewrites a doc to a new path and removes the old file.\nfunc (ds *DocStore) Rename(oldPath string, doc *models.Doc) error {\n    // ...\n    oldAbsPath := filepath.Join(ds.docsDir(), filepath.FromSlash(strings.TrimSuffix(oldPath, \".md\"))+\".md\")\n    newAbsPath := filepath.Join(ds.docsDir(), filepath.FromSlash(strings.TrimSuffix(doc.Path, \".md\"))+\".md\")\n    // ...\n    if err := ds.writeFile(newAbsPath, doc); err != nil {\n        return err\n    }\n    if oldAbsPath != newAbsPath {\n        // VULNERABLE: Deletes file at oldAbsPath (can be outside docsDir)\n        if err := os.Remove(oldAbsPath); err != nil && !os.IsNotExist(err) {\n            return err\n        }\n    }\n    return nil\n}\n\n// Delete removes a doc file.\nfunc (ds *DocStore) Delete(path string) error {\n    path = strings.TrimSuffix(path, \".md\")\n    // VULNERABLE: No containment check\n    absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(path)+\".md\")\n    return os.Remove(absPath)\n}\n```\n\n**Critical Flaws:**\n- `filepath.Join` resolves `../` sequences natively\n- No post-Join prefix check (e.g., `strings.HasPrefix(absPath, ds.docsDir())`)\n- No rejection of absolute paths or path traversal sequences\n- `Rename()` performs file deletion via `os.Remove(oldAbsPath)`, which can target files outside the docs directory\n\n### Missing Path Containment in MemoryStore\n\nIn `internal/storage/memory_store.go`, memory operations similarly lack path validation:\n\n```go\n// GetInLayer retrieves a memory entry by ID from a specific layer only.\nfunc (ms *MemoryStore) GetInLayer(id, layer string) (*models.MemoryEntry, error) {\n    // ...\n    dir, err := ms.dirForLayer(layer)\n    if err != nil {\n        return nil, err\n    }\n    // VULNERABLE: No containment check for id containing \"../\"\n    absPath := filepath.Join(dir, models.MemoryFileName(id))\n    if _, err := os.Stat(absPath); err != nil {\n        return nil, fmt.Errorf(\"memory %q not found in %s layer\", id, layer)\n    }\n    return ms.parseFile(absPath, layer)\n}\n\n// Create writes a new memory entry to the appropriate layer directory.\nfunc (ms *MemoryStore) Create(entry *models.MemoryEntry) error {\n    // ...\n    dir, err := ms.dirForLayer(entry.Layer)\n    if err != nil {\n        return err\n    }\n    if err := os.MkdirAll(dir, 0755); err != nil {\n        return fmt.Errorf(\"create memory dir: %w\", err)\n    }\n\n    // VULNERABLE: No containment check for entry.ID containing \"../\"\n    absPath := filepath.Join(dir, models.MemoryFileName(entry.ID))\n    return atomicWrite(absPath, []byte(renderMemory(entry)))\n}\n\n// Delete removes a memory entry by ID.\nfunc (ms *MemoryStore) Delete(id string) error {\n    // ...\n    filename := models.MemoryFileName(id)\n\n    dirs := []string{ms.projectDir(), ms.globalDir()}\n    for _, dir := range dirs {\n        // VULNERABLE: No containment check\n        absPath := filepath.Join(dir, filename)\n        if _, err := os.Stat(absPath); err == nil {\n            return os.Remove(absPath)\n        }\n    }\n\n    return fmt.Errorf(\"memory %q not found\", id)\n}\n```\n\n### Authorization Bypass via Rename-as-Delete\n\nIn `internal/mcp/handlers/doc.go`, the `handleDocUpdate()` function accepts a `newPath` parameter that triggers a rename operation:\n\n```go\nfunc handleDocUpdate(getStore func() *storage.Store, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {\n    // ...\n    if v, ok := stringArg(args, \"newPath\"); ok && strings.TrimSpace(v) != \"\" {\n        doc.Path = strings.Trim(strings.TrimSuffix(v, \".md\"), \"/\")\n    }\n    // ...\n    if oldPath != doc.Path {\n        if err := store.Docs.Rename(oldPath, doc); err != nil {\n            return errFailed(\"rename doc\", err)\n        }\n        // ...\n    }\n    // ...\n}\n```\n\nThe `Rename()` function in `doc_store.go` performs file deletion:\n\n```go\nif oldAbsPath != newAbsPath {\n    if err := os.Remove(oldAbsPath); err != nil && !os.IsNotExist(err) {\n        return err\n    }\n}\n```\n\nHowever, in `internal/permissions/registry.go`, `docs.update` is classified as `CapWrite`:\n\n```go\n\"docs.update\":  {Capability: CapWrite, Target: TargetDoc, Risk: RiskMedium},\n```\n\nThis allows an attacker with a `read-write-no-delete` preset (which permits `CapWrite` but denies `CapDelete`) to delete files by using `docs.update` with a `newPath` parameter.\n\n## Attack Vector\n\n| Phase | Request / Action | Effect |\n| :--- | :--- | :--- |\n| **1. Arbitrary File Read** | `docs.get` with `path=\"../../../victim/secret\"` | Server reads file outside project root via path traversal in `DocStore.Get()`. |\n| **2. Arbitrary File Write** | `docs.create` with `folder=\"../../../victim\"` | Server writes file outside project root via path traversal in `DocStore.Create()`. |\n| **3. Arbitrary File Delete** | `docs.update` with `path=\"../outside/secret.md\"` and `newPath=\"../../../victim/renamed.md\"` | Server deletes file outside project root via path traversal in `DocStore.Rename()`. Bypasses `CapDelete` restriction because `docs.update` is classified as `CapWrite`. |\n| **4. Memory File Read/Write** | `memory.update` with `id=\"x/../../../../victim/secret\"` | Server reads and overwrites file outside project root via path traversal in `MemoryStore.Update()`. |\n\n## Analysis\n\n### Classic Path Traversal Pattern\n\nBoth `DocStore` and `MemoryStore` follow the same vulnerable pattern: user-controlled input is concatenated with a base directory using `filepath.Join()`, then passed directly to file system operations (`os.ReadFile`, `os.WriteFile`, `os.Remove`, `os.Stat`) without any validation.\n\n```go\nabsPath := filepath.Join(baseDir, filepath.FromSlash(userInput))\n// No containment check: strings.HasPrefix(absPath, baseDir)\n// No rejection of \"..\" or absolute paths\n```\n\n`filepath.Join` resolves `../` sequences, allowing attackers to escape the intended directory:\n- Input: `\"../../../etc/passwd\"`\n- Result: `/project/.knowns/docs/../../../etc/passwd` → `/etc/passwd`\n\n### Rename-as-Delete Authorization Bypass\n\nThe `Rename()` function performs two operations:\n1. Write the file to the new location (`newAbsPath`)\n2. Delete the file from the old location (`oldAbsPath`)\n\nBoth paths are vulnerable to traversal. An attacker can:\n- Set `path` to a file outside the project (e.g., `\"../../../victim/target.md\"`)\n- Set `newPath` to another location outside the project\n- The `Rename()` function will delete the file at `path` (outside the project)\n\nBecause `docs.update` is classified as `CapWrite` rather than `CapDelete`, this operation bypasses deletion restrictions in `read-write-no-delete` presets.\n\n### Compounding Factor - Unauthenticated Access\n\nDue to the previously identified **Auth Bypass** vulnerability, all MCP tools are accessible without credentials when the server is started without a password, making this a zero-credential attack.\n\n## Fix\n\n*Patch is available right now at [New Release](https://github.com/knowns-dev/knowns/releases).*","aliases":["CVE-2026-86439"],"modified":"2026-09-25T19:45:10.320830462Z","published":"2026-09-25T19:32:05Z","database_specific":{"github_reviewed_at":"2026-09-25T19:32:05Z","nvd_published_at":null,"cwe_ids":["CWE-22","CWE-306","CWE-863"],"severity":"HIGH","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/knowns-dev/knowns/security/advisories/GHSA-9gfj-28hw-jchp"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-86439"},{"type":"WEB","url":"https://github.com/knowns-dev/knowns/commit/09c5a96fd5817b941dc86669278c1a17db10ed4e"},{"type":"PACKAGE","url":"https://github.com/knowns-dev/knowns"},{"type":"WEB","url":"https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/storage/doc_store.go#L124-L129"},{"type":"WEB","url":"https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/storage/memory_store.go#L203-L211"},{"type":"WEB","url":"https://github.com/knowns-dev/knowns/releases/tag/v0.30.0"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/knowns-before-0.30.0-path-traversal-via-mcp-doc-and-memory-tools"}],"affected":[{"package":{"name":"knowns","ecosystem":"npm","purl":"pkg:npm/knowns"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.30.0"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 0.29.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-9gfj-28hw-jchp/GHSA-9gfj-28hw-jchp.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"}]}