Server Implementation
An MCP `resources/read` handler accepts a URI and reads it from disk without resolving and containing the path, letting a model read any file the server process can access. Structurally identical to MCP-003 path traversal but scoped to the `resources/read` primitive instead of tool arguments — both can fire on the same server.
MCP exposes a `resources/read` handler that maps a URI (e.g. `file:///notes/agenda.md`) to a file. If the handler joins the URI's path component to a base directory without resolution + containment, traversal sequences (`../`, absolute paths, symlinks) escape the intended scope. The bug is structurally identical to MCP-003 path traversal but lives at the resources layer rather than a tool argument.
`resources/read` is invoked transparently by MCP clients during normal context retrieval — the user often never sees the URI being requested. A model that's been prompt-injected into asking for `file:///etc/passwd` can read it back through the MCP channel without the user ever clicking anything.
BASE = "/srv/notes" |
@server.resource_handler("read") |
async def read_resource(uri: str) -> str: |
path = os.path.join(BASE, uri.removeprefix("file://")) |
with open(path) as f: |
return f.read() |
from pathlib import Path |
BASE = Path("/srv/notes").resolve() |
@server.resource_handler("read") |
async def read_resource(uri: str) -> str: |
rel = uri.removeprefix("file://").lstrip("/") |
candidate = (BASE / rel).resolve() |
if BASE not in candidate.parents and candidate != BASE: |
raise ValueError("path outside scope") |
return candidate.read_text() |
MCPSafe flags `resources/read` handlers that pass the URI argument into `open`, `Path(...).read_text`, `fs.readFile`, or equivalent without an intervening containment check (`Path.resolve()` + `is_relative_to`, or `path.resolve()` + `startsWith(base)`).
See the full threat catalog for every documented detection.
MCPSafe runs this check — and every other rule in the catalog — on any MCP server you paste in.
Scan now