rampart
README.md
# Development Experiment 023 — rampart
**Offline, dependency-free transport-layer hardening auditor for the MCP Streamable HTTP transport.**
rampart probes (or audits a recorded probe of) an MCP Streamable HTTP endpoint for the
transport-*perimeter* defects the MCP spec's three-line "Security Warning" exists to
prevent — and that half a dozen 2026 CVEs prove SDKs keep getting wrong:
- **DNS rebinding** via missing/naive `Origin` and `Host` validation
- permissive / credentialed **CORS**
- weak, guessable, reused or state-leaking **`Mcp-Session-Id`** values, and **session fixation**
- cleartext / NeighborJack exposure and missing auth on non-loopback binds
- **`MCP-Protocol-Version`** and endpoint/method **conformance**
It ships two byte-stable reference servers — a **hardened** one that scores a clean
**100/A**, and a **vulnerable** one that demonstrates 15 ground-truth defects (three
CRITICAL) — plus a severity-weighted score, letter grade and a CI exit-code gate.
Pure Python standard library. No third-party runtime dependencies. `pytest` only for tests.
---
## Thesis
Experiments #011–#022 in this series each audited one *message/content* channel of MCP
(tools, prompts, resources, roots, sampling, elicitation, completion, tasks, the stateless
lifecycle, and cross-channel taint flow). #016 `deputy` covered OAuth **authorization**.
But underneath every one of those sits the raw **HTTP transport**, and that is where MCP
is actually bleeding in 2026: the most-CVE'd real MCP vulnerability class of the year is
**DNS rebinding against local MCP servers** — the Python, Java, Go and Ruby SDKs and the
official MCP Inspector (CVSS 9.4) all shipped without the `Origin` validation the spec
marks **MUST**. Backslash's "NeighborJack" scan found most public MCP servers bind
`0.0.0.0` by default; BlueRock found 36.7% of 7,000 servers SSRF-exposed.
The MCP spec's transport hardening requirements are *three sentences long*. rampart turns
those three sentences (plus the session-ID contract and the protocol-version rule) into a
battery of active HTTP probes and a deterministic rule engine, so a developer can answer
"is my MCP endpoint rebinding-safe?" in one command — the smallest thing that proves the
transport perimeter can be checked mechanically, offline, with no LLM and no dependencies.
This is the series' **second dynamic entry** (after #011 `gauntlet`, which drove *stdio*)
and its **first on the HTTP transport / perimeter layer**.
---
## How to run
```bash
# 1) Audit the bundled reference servers end-to-end (spins up loopback servers, probes them)
python main.py demo --kind both
make run # equivalent
# 2) Probe a live MCP endpoint you own
python main.py probe http://127.0.0.1:8000/mcp
python main.py probe https://my-mcp.example/mcp --fail-on high --min-score 90 --json
# 3) Audit a previously recorded probe report, fully offline
python main.py audit captures/vulnerable.probe.json
# 4) Print the rule catalog
python main.py catalog
# 5) Regenerate the shipped captures
python main.py save-captures captures
# Tests
python -m pytest # 60 tests
make test
```
No installation is required (zero runtime deps). Optionally `pip install -e .` to get a
`rampart` console script.
### Exit codes (CI gate)
`0` pass · `2` gate failed (a finding at/above `--fail-on`, default `high`, or score below
`--min-score`) · `1` usage/runtime error. Drop `rampart probe … --fail-on high` into a CI
step to block a rebinding-vulnerable server from shipping.
---
## Architecture
```
touches network pure & deterministic
┌───────────────────────────┐ ┌────────────────────────────────────┐
│ prober.py │ Probe │ analyzer.py catalog.py │
│ crafts a fixed battery │ Report │ ProbeReport -> [Finding] │
│ of HTTP probes, each ├───────►│ 6 family analyzers, 24 rules │
│ isolating ONE property │ (JSON)│ no clock, no randomness, no net │
└───────────────────────────┘ └───────────────┬────────────────────┘
▲ │
│ drives ▼
┌────────┴───────────┐ scoring.py (weight→score→grade→gate)
│ refserver.py │ report.py (text / JSON)
│ hardened | vulnerable│ cli.py (probe|audit|demo|catalog)
│ one handler, a Policy│
└─────────────────────┘
```
**Separation of observation from judgement is the core design choice.** The prober is the
only module that touches the network; it emits a normalised `ProbeReport` (status, headers,
a small body excerpt per probe — plain data, no interpretation). The analyzer consumes a
`ProbeReport` and is *pure*: no network, no clock, no randomness, so the same report always
yields the same findings. Two consequences fall out for free:
1. rampart grades a **live probe** and a **recorded report** through the identical code
path (`probe` vs `audit`), so evidence can be captured once and re-audited forever.
2. every one of the 24 rules is unit-testable with a hand-written `ProbeReport` — no
sockets in the rule tests.
**Each probe isolates exactly one property.** The `origin_foreign` probe sends a foreign
`Origin` but a *legitimate* `Host`, version and session, so a finding points at Origin
validation and nothing else. This "one variable per probe" design is what lets the analyzer
map an outcome straight to a rule.
**The reference servers share every line of transport code** and differ only in a `Policy`
dataclass (validate Origin? exact vs substring match? enforce sessions? secure vs sequential
session factory? …). That is precisely rampart's thesis rendered executable: the gap between
a safe and a catastrophic MCP server is *configuration/defaults*, not code.
### Rule families (24 rules)
| Family | What it checks | Flagship |
|--------|----------------|----------|
| **RBND** | DNS rebinding: `Origin`/`Host` validation (spec MUST) | `RBND01` foreign Origin accepted · `RBND02` foreign Host accepted (rebinding) — **CRITICAL** |
| **CORS** | Cross-origin sharing posture | `CORS03` credentialed wildcard — **CRITICAL** |
| **SESS** | `Mcp-Session-Id` entropy, format (0x21–0x7E), uniqueness, fixation, enforcement | `SESS05` client-supplied session accepted (fixation) |
| **XPORT** | Cleartext, NeighborJack bind, missing auth, verbose errors | `XPORT01` cleartext to remote host |
| **VER** | `MCP-Protocol-Version` conformance (MUST 400 on invalid) | `VER01` |
| **CONF** | Streamable-HTTP endpoint/method conformance (GET→405, notify→202) | `CONF01` / `CONF02` |
Severity weights: CRITICAL 40 · HIGH 15 · MEDIUM 6 · LOW 2 · INFO 0. Score = `max(0, 100 −
Σweights)`. Grade: A≥90, B≥75, C≥60, D≥40, else F.
---
## Trade-offs & design decisions
- **Dynamic prober vs passive log auditor.** A passive auditor (like #013–#022) can only
see what a capture happens to contain; transport hardening lives in how a server *responds
to a hostile request*, which a benign trace never exercises. So rampart is an active
prober — but it stores its evidence as a `ProbeReport` and analyses that, keeping the
offline-auditable, deterministic, LLM-free character of the series (and enabling
`save-captures` → commit → `audit` in CI without a live server).
- **Loopback-only reference servers.** The vulnerable server is a teaching artefact; it
binds `127.0.0.1:0` and derives its weak session IDs from a counter + fixed epoch base, so
captures are byte-stable and nothing insecure is ever exposed.
- **Heuristic session analysis.** Entropy (`SESS02`) and sequential-ID (`SESS01`) detection
are heuristics over a small sample, not a statistical randomness proof — deliberately
conservative (64-bit floor; boundary-anchored epoch match) to avoid flagging real
cryptographic IDs. This matches the "cheap, offline, high-signal" posture of the series.
- **`Origin: absent` is INFO, not a defect.** Browsers always attach `Origin` on
cross-origin requests and legit CLI clients omit it, so missing-Origin acceptance is not
itself exploitable; it is recorded (`RBND05`) but never lowers the score. This is why the
hardened server scores a true 100/A while remaining CLI-compatible.
- **`http.server` over a framework.** Zero dependencies keeps the prototype auditable and
portable; the cost is a hand-rolled minimal MCP endpoint, which is fine for a prober target.
---
## Assumptions & limitations
- rampart audits the **transport perimeter**, not application logic or the message channels
(those are experiments #011–#022). A 100/A means the HTTP front door is hardened, not that
the tools behind it are safe.
- The prober assumes a single MCP endpoint path (default derived from the URL, e.g. `/mcp`)
that speaks the Streamable HTTP transport. Old HTTP+SSE-only servers will read as
conformance findings, not crashes.
- Some checks are **posture-conditional**: `XPORT01/02/03` (cleartext, NeighborJack,
missing-auth) only fire for a **non-loopback** target, because they are not risks on
`127.0.0.1`. Audit a remote deployment to exercise them (unit tests cover them
synthetically).
- rampart sends a small, benign battery (initialize / tools/list / notifications / a
malformed body / a CORS preflight). It is a hardening check for **servers you own or are
authorised to test**, not an internet scanner.
- Session entropy/prediction is heuristic (see trade-offs). A server using a novel-but-secure
scheme could in principle draw a false `SESS02`; the 64-bit floor is set well below a UUIDv4.
---
## Sample run
`python main.py demo --kind both` (loopback ports vary per run):
```
### reference server: hardened ###
======================================================================
rampart - MCP Streamable HTTP transport hardening audit
======================================================================
target : http://127.0.0.1:64673/mcp
host : 127.0.0.1 (loopback=True, scheme=http)
note : probed 14 vectors, 4 session IDs sampled
SCORE : 100/100 GRADE: A
FINDINGS: 1 [critical=0 high=0 medium=0 low=0 info=1]
----------------------------------------------------------------------
[RBND]
INFO RBND05 Missing Origin accepted on a state-changing request
A state-changing request with no Origin header was accepted (HTTP 200).
----------------------------------------------------------------------
### reference server: vulnerable ###
======================================================================
rampart - MCP Streamable HTTP transport hardening audit
======================================================================
target : http://127.0.0.1:64692/mcp
host : 127.0.0.1 (loopback=True, scheme=http)
note : probed 14 vectors, 4 session IDs sampled
SCORE : 0/100 GRADE: F
FINDINGS: 15 [critical=3 high=4 medium=2 low=5 info=1]
----------------------------------------------------------------------
[RBND]
CRITICAL RBND01 Foreign Origin accepted
Request with Origin 'https://attacker.example' was accepted (HTTP 200); the server does not validate Origin.
CRITICAL RBND02 Foreign Host accepted (DNS rebinding)
Request with a foreign Host header 'attacker.example' was accepted (HTTP 200) over a loopback connection.
HIGH RBND03 Origin allowlist bypass via suffix/substring match
Suffix-bypass Origin 'http://127.0.0.1:64692.attacker.example' was accepted (HTTP 200); validation likely uses substring matching.
HIGH RBND04 Origin: null accepted
Origin: null was accepted (HTTP 200).
INFO RBND05 Missing Origin accepted on a state-changing request
[CORS]
CRITICAL CORS03 Credentialed CORS for a foreign origin
Access-Control-Allow-Origin: * is combined with Access-Control-Allow-Credentials: true, exposing authenticated responses to a foreign origin.
LOW CORS04 Overly permissive CORS methods/headers
[SESS]
HIGH SESS01 Predictable / sequential session IDs
Issued session IDs are sequential: ['sess-1-1700000001', 'sess-2-1700000002', ...].
HIGH SESS05 Client-supplied (unissued) session ID accepted (session fixation)
MEDIUM SESS06 Request without session ID accepted where a session is required
MEDIUM SESS07 Session ID embeds decodable / sensitive data
Session ID leaks internal state (embeds an epoch-like timestamp 1700000001): 'sess-1-1700000001'.
[XPORT]
LOW XPORT04 Verbose error / stack-trace disclosure
[VER]
LOW VER01 Invalid MCP-Protocol-Version not rejected with 400
[CONF]
LOW CONF01 GET without SSE support does not return 405
LOW CONF02 Notification/response not acknowledged with 202
----------------------------------------------------------------------
```
The `demo --kind both` exits `0` because the *hardened* server passes the gate; the
vulnerable server's expected failure is reported but does not fail the run. `demo --kind
vulnerable` exits `2`.
---
## Layout
```
rampart/
model.py ProbeResult / ProbeReport / Finding / Severity
catalog.py 24 rules across 6 families (identity & description)
analyzer.py pure ProbeReport -> [Finding] engine (per-family)
scoring.py severity weights -> score/grade + CI gate
report.py text and JSON renderers
prober.py active HTTP probe battery (the only networked module)
refserver.py hardened + vulnerable reference servers (one handler, a Policy)
cli.py probe | audit | demo | catalog | save-captures
captures/ hardened.probe.json (100/A) · vulnerable.probe.json (0/F)
tests/ 60 tests: per-rule analyzer, scoring, catalog, e2e loopback, CLI
main.py convenience entry (== python -m rampart)
```
See `RESEARCH.md` for the 2026 signals — CVEs, incidents and spec text — that motivated it.
## License
MIT — see `LICENSE`.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues