GHSA-62GX-5Q78-WRVX

Vulnerability from github – Published: 2026-07-15 21:56 – Updated: 2026-07-15 21:56
VLAI
Summary
obsidian-local-rest-api: Authenticated path traversal via URL-encoded %2F in /vault/{path} — arbitrary host file read/write/delete
Details

Summary

The Local REST API's /vault/{path} endpoints (GET/PUT/PATCH/POST/DELETE) percent-decode the request path inside the handler — after Express has already routed and normalized it, then hand it to the Obsidian vault adapter with no confinement check. A literal ../ is resolved/rejected at the routing layer (→ 404), but %2F is not a separator there, so ..%2F..%2F survives routing and is only turned into a real / by the handler's decodeURIComponent, reconstituting a ../ traversal that walks out of the vault. An authenticated client can read, write, or delete arbitrary files on the host with the Obsidian process's privileges.

Details

Framework: Express (import express from "express"; routes registered via this.api.route("/vault/*")…).

The vulnerable line — in src/requestHandler.ts, every vault handler (vaultGet, vaultPut, vaultPatch, vaultPost, vaultDelete) derives the path like this: ts const rawPath = decodeURIComponent( req.path.slice(req.path.indexOf("/", 1) + 1), ); The path is decodeURIComponent'd after Express routing. A literal ../ is collapsed/rejected at the routing layer, but %2F isn't a separator there — so ..%2F..%2F reaches the handler intact and this decodeURIComponent turns it into a real ../../. The string routing saw (…%2F…) is not the string the handler uses (…/…), and %2e%2e behaves the same way.

No confinement on the decoded path. The handlers pass rawPath straight to the vault adapter — e.g. this.app.vault.adapter.readBinary(filePath) / this.app.vault.getAbstractFileByPath(filePath) — with no path.resolve + vault-root prefix check, so the reconstituted ../../ escapes.

The fix already exists in your code — for MOVE only. vaultMove confines correctly: ts const syntheticRoot = "/vault"; const resolved = posix.resolve(syntheticRoot, normalized); if (resolved !== syntheticRoot && !resolved.startsWith(syntheticRoot + "/")) { this.returnCannedResponse(res, { errorCode: ErrorCode.PathTraversalNotAllowed }); return; } GET/PUT/PATCH/POST/DELETE lack this guard. Apply the same posix.resolve(syntheticRoot, …) + startsWith check to the decoded path in every vault handler, and reject any segment that decodes to ...

PoC

Prereq: a running Obsidian with the Local REST API plugin enabled and its configured API key ($API_KEY). Targets below are Unix; adjust per OS (e.g. ..%2F..%2FWindows%2Fwin.ini on Windows).

```bash # READ outside the vault (returns 200 + the target file's real bytes): curl --path-as-is -k -H "Authorization: Bearer $API_KEY" \ "https://127.0.0.1:27124/vault/..%2F..%2F..%2F..%2Fetc%2Fpasswd"

# WRITE outside the vault (creates a file on disk outside the vault root): curl --path-as-is -k -X PUT -H "Authorization: Bearer $API_KEY" --data "pwned" \ "https://127.0.0.1:27124/vault/..%2F..%2F..%2Ftmp%2Fcanary.txt" ```

--path-as-is stops curl from collapsing .. client-side. A plain ../ (unencoded) request returns 404 — only the %2F/%2e%2e encoded form bypasses, confirming the decode-after-routing gap.

Impact

Authenticated arbitrary file read / write / delete outside the Obsidian vault, with the OS privileges of the Obsidian process — typically the user's home directory (SSH keys, browser profiles, dotfiles, credentials). Amplified in MCP/LLM-agent deployments: this API is widely used as an MCP server, so a prompt-injection in vault content (or a malicious MCP client) can make an agent emit a %2F path — turning "the agent can edit my notes" into "the agent can read/write any file on the host," with no user intent to grant filesystem access beyond the vault.

This vulnerability was reported by Caleb Brisbin through responsible disclosure.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c 4.1.3"
      },
      "package": {
        "ecosystem": "npm",
        "name": "obsidian-local-rest-api"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-15T21:56:45Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\nThe Local REST API\u0027s `/vault/{path}` endpoints (GET/PUT/PATCH/POST/DELETE) percent-decode the request path *inside the handler \u2014 after* Express has already routed and normalized it, then hand it to the Obsidian vault adapter with no confinement check. A literal `../` is resolved/rejected at the routing layer (\u2192 404), but `%2F` is not a separator there, so `..%2F..%2F` survives routing and is only turned into a real `/` by the handler\u0027s `decodeURIComponent`, reconstituting a `../` traversal that walks out of the vault. An **authenticated** client can read, write, or delete **arbitrary files on the host** with the Obsidian process\u0027s privileges.\n\n### Details\n**Framework:** Express (`import express from \"express\"`; routes registered via `this.api.route(\"/vault/*\")\u2026`).\n\n**The vulnerable line** \u2014 in `src/requestHandler.ts`, every vault handler (`vaultGet`, `vaultPut`, `vaultPatch`, `vaultPost`, `vaultDelete`) derives the path like this:\n  ```ts\n  const rawPath = decodeURIComponent(\n    req.path.slice(req.path.indexOf(\"/\", 1) + 1),\n  );\n  ```\nThe path is `decodeURIComponent`\u0027d **after** Express routing. A literal `../` is collapsed/rejected at the routing layer, but `%2F` isn\u0027t a separator there \u2014 so `..%2F..%2F` reaches the handler intact and this `decodeURIComponent` turns it into a real `../../`. **The string routing saw (`\u2026%2F\u2026`) is not the string the handler uses (`\u2026/\u2026`)**, and `%2e%2e` behaves the same way.\n\n**No confinement on the decoded path.** The handlers pass `rawPath` straight to the vault adapter \u2014 e.g. `this.app.vault.adapter.readBinary(filePath)` / `this.app.vault.getAbstractFileByPath(filePath)` \u2014 with no `path.resolve` + vault-root prefix check, so the reconstituted `../../` escapes.\n\n**The fix already exists in your code \u2014 for MOVE only.** `vaultMove` confines correctly:\n  ```ts\n  const syntheticRoot = \"/vault\";\n  const resolved = posix.resolve(syntheticRoot, normalized);\n  if (resolved !== syntheticRoot \u0026\u0026 !resolved.startsWith(syntheticRoot + \"/\")) {\n    this.returnCannedResponse(res, { errorCode: ErrorCode.PathTraversalNotAllowed });\n    return;\n  }\n  ```\nGET/PUT/PATCH/POST/DELETE lack this guard. **Apply the same `posix.resolve(syntheticRoot, \u2026)` + `startsWith` check to the decoded path in every vault handler**, and reject any segment that decodes to `..`.\n\n### PoC\nPrereq: a running Obsidian with the Local REST API plugin enabled and its configured API key (`$API_KEY`). Targets below are Unix; adjust per OS (e.g. `..%2F..%2FWindows%2Fwin.ini` on Windows).\n\n  ```bash\n  # READ outside the vault (returns 200 + the target file\u0027s real bytes):\n  curl --path-as-is -k -H \"Authorization: Bearer $API_KEY\" \\\n    \"https://127.0.0.1:27124/vault/..%2F..%2F..%2F..%2Fetc%2Fpasswd\"\n\n  # WRITE outside the vault (creates a file on disk outside the vault root):\n  curl --path-as-is -k -X PUT -H \"Authorization: Bearer $API_KEY\" --data \"pwned\" \\\n    \"https://127.0.0.1:27124/vault/..%2F..%2F..%2Ftmp%2Fcanary.txt\"\n  ```\n\n`--path-as-is` stops curl from collapsing `..` client-side. A plain `../` (unencoded) request returns 404 \u2014 only the `%2F`/`%2e%2e` encoded form bypasses, confirming the decode-after-routing gap.\n\n### Impact\nAuthenticated arbitrary file **read / write / delete** outside the Obsidian vault, with the OS privileges of the Obsidian process \u2014 typically the user\u0027s home directory (SSH keys, browser profiles, dotfiles, credentials). Amplified in MCP/LLM-agent deployments: this API is widely used as an MCP server, so a prompt-injection in vault content (or a malicious MCP client) can make an agent emit a `%2F` path \u2014 turning \"the agent can edit my notes\" into \"the agent can read/write any file on the host,\" with no user intent to grant filesystem access beyond the vault.\n\nThis vulnerability was reported by Caleb Brisbin through responsible disclosure.",
  "id": "GHSA-62gx-5q78-wrvx",
  "modified": "2026-07-15T21:56:45Z",
  "published": "2026-07-15T21:56:45Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/coddingtonbear/obsidian-local-rest-api/security/advisories/GHSA-62gx-5q78-wrvx"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/coddingtonbear/obsidian-local-rest-api"
    },
    {
      "type": "WEB",
      "url": "https://github.com/coddingtonbear/obsidian-local-rest-api/releases/tag/4.1.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "obsidian-local-rest-api: Authenticated path traversal via URL-encoded %2F in /vault/{path} \u2014 arbitrary host file read/write/delete"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…