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:
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:
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).
# 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.
Reported by Caleb Brisbin, responsible disclosure — glad to retest a patch.
Summary
The Local REST API's
/vault/{path}endpoints (GET/PUT/PATCH/POST/DELETE) percent-decode the requestpath 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 routinglayer (→ 404), but
%2Fis not a separator there, so..%2F..%2Fsurvives routing and is only turnedinto a real
/by the handler'sdecodeURIComponent, reconstituting a../traversal that walksout 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 viathis.api.route("/vault/*")…).The vulnerable line — in
src/requestHandler.ts, every vault handler (vaultGet,vaultPut,vaultPatch,vaultPost,vaultDelete) derives the path like this:The path is
decodeURIComponent'd after Express routing. A literal../is collapsed/rejected atthe routing layer, but
%2Fisn't a separator there — so..%2F..%2Freaches the handler intact andthis
decodeURIComponentturns it into a real../../. The string routing saw (…%2F…) is notthe string the handler uses (
…/…), and%2e%2ebehaves the same way.No confinement on the decoded path. The handlers pass
rawPathstraight 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.
vaultMoveconfines correctly:GET/PUT/PATCH/POST/DELETE lack this guard. Apply the same
posix.resolve(syntheticRoot, …)+startsWithcheck to the decoded path in every vault handler, and reject any segment that decodesto
...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.inion Windows).--path-as-isstops curl from collapsing..client-side. A plain../(unencoded) request returns404 — only the
%2F/%2e%2eencoded 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
%2Fpath — turning "the agent can edit my notes" into "the agent can read/write any file on thehost," with no user intent to grant filesystem access beyond the vault.
Reported by Caleb Brisbin, responsible disclosure — glad to retest a patch.