Skip to main content
Glama
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`.