GHSA-62GX-5Q78-WRVX
Vulnerability from github – Published: 2026-07-15 21:56 – Updated: 2026-07-15 21:56Summary
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.
{
"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"
}
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.