Skip to content

Authenticated path traversal via URL-encoded %2F in /vault/{path} — arbitrary host file read/write/delete

High
coddingtonbear published GHSA-62gx-5q78-wrvx Jun 4, 2026

Package

obsidian-local-rest-api (Obsidian plugin)

Affected versions

<= 4.1.2

Patched versions

4.1.3

Description

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.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Credits