✓ Thank you for your purchase. This is your full kit — bookmark this page. Questions? Reply to your Stripe receipt.
# The MCP Server Security Hardening Kit
*Ship an MCP server the internet can't turn into a file-read / RCE machine.*
A working checklist + copy-paste guards + attacker test payloads, written from
real audits of production MCP servers. No fluff, no theory — the exact failure
modes that get MCP servers CVE'd, and the code that stops them.
> **Threat model.** Your MCP tools are driven by an LLM that reads untrusted
> content (web pages, files, tickets, emails). Assume every tool argument is
> attacker-controlled via prompt injection. The attacker's goal: make your
> server read/write files outside its workspace, run commands, or fetch internal
> URLs. Every check below closes one of those doors.
---
## 1. Path confinement (the #1 MCP CVE class)
**The bug:** a tool takes a `path`/`file`/`directory` argument and reads it.
Attacker passes `../../../../etc/passwd`, an absolute path, or a symlink. Real
CVEs (e.g. gemini-bridge CVE-2026-54785) come from `os.path.relpath` +
`startswith("..")` checks that miss symlinks and absolute paths.
**The fix — resolve BOTH ends, then containment-check with `is_relative_to`:**
```python
from pathlib import Path
def resolve_within(root: str, candidate: str) -> Path | None:
"""Return the resolved path IFF it stays inside root, else None.
Follows symlinks on both sides so the check can't be bypassed."""
root_p = Path(root).resolve()
cand = Path(candidate)
abs_p = (cand if cand.is_absolute() else root_p / cand).resolve()
return abs_p if abs_p.is_relative_to(root_p) else None # py3.9+: use try/relative_to
```
**Callers MUST refuse `None` (skip/error) — never fall back to `Path(abs).name`.**
The classic regression: fix `resolve_within` but leave a *second* tool
(`at_command` mode, an export path, a write sink) using the old logic. Audit
**every** call site.
**Test payloads (all must be refused):**
```
../../../etc/passwd
/etc/passwd
..%2f..%2f..%2fetc%2fpasswd # URL-encoded, if you decode
symlink_in_workspace -> /etc # then read symlink/anything
....//....//etc/passwd # doubled-dot bypass of naive strip
```
⚠️ If `root`/`directory` is *itself* a tool argument, path confinement is
meaningless — the attacker just sets `root="/"`. Pin the root to a server-config
value, or allow-list roots.
---
## 2. No shell, ever — argv arrays only
**The bug:** `os.system`, `subprocess.run(cmd, shell=True)`, or f-string command
building. `subprocess.run(f"tool {arg}", shell=True)` → `arg="; rm -rf ~"`.
**The fix:**
```python
subprocess.run(["tool", "-m", model], cwd=directory, input=stdin,
capture_output=True, text=True, timeout=timeout) # list, no shell
```
- Pass user data as **stdin** (`input=`), not as args, when possible.
- Whitelist any arg the user *does* influence (e.g. model name must match a
known set / start with a fixed prefix) to block **argument injection**
(a value like `--dangerously-eval` sneaking in as a flag).
- Set `timeout=`; never let a tool hang the server.
**Test payloads:** `; id`, `$(id)`, `` `id` ``, `--help`, `-o /tmp/pwned`,
`model="--config /etc/shadow"`.
---
## 3. No unsafe deserialization
**The bug:** loading model/config/cache files with `pickle.load`,
`torch.load(..., weights_only=False)`, `yaml.load`/`yaml.unsafe_load`,
`numpy.load(allow_pickle=True)`, `dill`, `joblib.load`, `marshal.loads`. Any of
these on attacker-influenced bytes = RCE. This is huntr's single most-paid class.
**The fix:**
```python
import yaml, torch, json
yaml.safe_load(text) # never yaml.load without SafeLoader
torch.load(path, weights_only=True) # blocks arbitrary __reduce__ (torch>=2.6 default)
json.loads(text) # prefer JSON / safetensors for anything untrusted
```
Never `pickle.load` a file a user can supply. If a format needs pickle, gate it
behind an explicit `trust=True` the *operator* sets, never a tool argument.
---
## 4. No SSRF in URL fetchers
**The bug:** a tool fetches a user-supplied URL → attacker points it at
`http://169.254.169.254/` (cloud metadata), `http://localhost:...`, or
`file://`.
**The fix:** allow-list schemes (`https` only), resolve the host and **reject
private/loopback/link-local IPs** before connecting, disable redirects (or
re-validate each hop), set a timeout, cap response size.
```python
import ipaddress, socket
def safe_host(host: str) -> bool:
ip = ipaddress.ip_address(socket.gethostbyname(host))
return not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved)
```
**Test payloads:** `http://169.254.169.254/latest/meta-data/`,
`http://127.0.0.1:8080/`, `file:///etc/passwd`, a public host that 302s to an
internal one.
---
## 5. Secrets, output & injection hygiene
- **Never** hardcode keys or echo env/secrets in tool output or errors.
- Don't reflect raw request data into responses (stored/reflected XSS if
rendered anywhere).
- Bound & type-validate every argument (max length, expected type, enum). Reject
oversized/unknown input instead of coercing.
- Treat any content the LLM ingested as hostile: it must **never** reach a
privileged action or your own credentials. Keep tool permissions least-privilege.
- Rate-limit and log tool calls; a spike of `../` attempts is an attack signature.
---
## 6. Pre-deploy audit checklist (run every release)
```
[ ] grep -rn "shell=True\|os.system\|os.popen\|subprocess.call(" src/
[ ] grep -rn "pickle.load\|yaml.load(\|torch.load(\|allow_pickle=True\|marshal.load" src/
[ ] grep -rn "open(\|Path(\|send_file\|extractall\|os.path.join" src/ # every path sink
[ ] every path sink flows through resolve_within() and refuses None
[ ] every subprocess is an argv list with a timeout; user args whitelisted
[ ] every URL fetch is scheme+IP validated, no redirects, size-capped
[ ] no secrets in code/output; inputs length+type bounded; errors don't leak paths
[ ] ran the test payloads above against a LOCAL instance — all refused
```
Copy this into your CI. If any box is unchecked, you are one prompt-injection
away from a CVE.
---
*Written by a security researcher who audits MCP servers for a living. Questions
or a specific server you want sanity-checked? Reply to your receipt.*