A differential auditor for the MCP header-mirroring plane (SEP-2243): do the header plane and the body plane agree, and is anything on the path obliged to notice?
Offline. LLM-free. Pure standard library, no dependencies. 60 rules in 7 families, a severity-weighted score, and a CI exit-code gate.
MCP 2026-07-28 made Streamable HTTP say the same thing twice. Mcp-Method
mirrors method, Mcp-Name mirrors params.name/params.uri, and
x-mcp-header promotes chosen tool-call arguments into Mcp-Param-{Name}
headers — all so that gateways, WAFs and load balancers can route, meter and
authorize without parsing the body. That is a deliberate, useful design. It
is also the classic precondition for a request-smuggling-shaped bug: two
components deriving one decision from two different sources of truth.
The specification's only defence is a single sentence — "Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body" — and it binds the last component in the chain, protecting a decision the first one already made. If the origin skips that check, or performs it slightly differently than the gateway did (case, numeric spelling, base64 sentinel, duplicate fields), then every policy in front of it is forgeable. SEP-2243 goes further and admits that header values "originate from tool call arguments, which may be influenced by an LLM" and MUST NOT be trusted for security decisions — a prohibition stated in prose and enforced by nothing.
janus is the smallest thing that proves the idea: reconstruct both readings of a captured request — what a body-blind intermediary believes, and what the origin will actually execute — and report where they diverge, escalating exactly when the declared topology says a security decision reads the header that diverged.
exchange 1: tools/call
Mcp-Method header=tools/call body=tools/call
MCP-Protocol-Version header=2026-07-28 body=2026-07-28
!! Mcp-Name header=get_weather body=query_analytics
!! Mcp-Param-TenantId header=acme-corp body=globex-corp
Mcp-Param-Token header=AKIAIOSFODNN7EXAMPLE body=AKIAIOSFODNN7EXAMPLE
The WAF allowlisted get_weather and admitted the tenant acme-corp. The
origin ran query_analytics against globex-corp, and answered 200.
Python 3.11+. No dependencies to install for the tool itself; pytest only for
the tests.
python -m janus audit fixtures/hostile/capture.json # full audit
python -m janus audit fixtures/clean/capture.json # the conformant one
python -m janus views fixtures/hostile/capture.json # both readings, side by side
python -m janus manifest tools.json # static x-mcp-header audit only
python -m janus catalog # the 60 rulesOr via make:
make run # audits both reference captures — the demo
make test # 442 tests
make views
make catalog
make fixtures # regenerate the hostile fixtures from the committed builderFlags: --json for machine-readable output, --fail-on {CRITICAL,HIGH,MEDIUM,LOW,INFO,NONE}
to set the CI gate (default HIGH), --show-views to append the two-view table
to an audit.
Exit codes: 0 clean, 1 findings at or above --fail-on, 2 the capture
could not be read — a tool that cannot read its input must never look like a
pass.
A capture is JSON: a list of exchanges (raw header pairs, JSON-RPC body, and
the response), the tools/list manifest, and a declared topology.
{
"topology": {"components": [
{"id": "edge", "kind": "gateway", "checks_protocol_version": true,
"policies": [{"id": "authz", "kind": "authorize", "keys": ["Mcp-Name"],
"verified_against_identity": false}]},
{"id": "origin", "kind": "origin",
"processes_body": true, "validates_header_body": true}
]},
"tools": [ ... ],
"exchanges": [{"seq": 1,
"request": {"headers": [["Mcp-Method", "tools/call"], ["Mcp-Name", "get_weather"]],
"body": {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {...}}},
"response": {"status": 200, "body": {...}}}]
}Headers are a list of pairs, never an object. Duplicate field names and their order are the raw material of the entire SMUGGLE family; a JSON object would silently discard both.
The topology block is what makes severity meaningful. The same Mcp-Name
mismatch is a curiosity behind a dumb load balancer and an authorization bypass
behind a gateway that allowlists tool names. janus takes trust as a declared
input rather than guessing it — the same choice experiment 029 made for
telemetry peers, and for the same reason: the tool cannot discover your
deployment, and pretending otherwise produces confident nonsense.
capture.json ──▶ loader.py ──▶ Capture (frozen dataclasses)
│ │
(only filesystem) ├──▶ manifest.py static x-mcp-header graph
│ + reachability walker
├──▶ views.py HeaderView vs BodyView
│ (headers.py: multimap,
│ sentinel codec ×2, RFC 9110)
└──▶ analyzer.py pure Capture -> [Finding]
│
score.py ─┴─ report.py ──▶ text / JSON / exit code
Observation is split from judgement. loader.py is the only module that
touches the filesystem; everything downstream is a pure function with no I/O,
clock or randomness. A committed fixture and a freshly recorded session
therefore grade through identical code, every rule is unit-testable in
isolation, and captures can be committed and re-audited in CI. tests/test_purity.py
asserts the boundary structurally — it parses each pure module's imports and
fails if any of them reaches for os, pathlib, random, time or friends.
The sentinel codec is implemented twice, on purpose. read_strict follows
the spec exactly; read_lenient models the forgiving decoder that ships in real
proxies and log pipelines (case-insensitive markers, repaired base64 padding).
Where the two disagree, two boxes on the path recover different plaintext from
identical bytes — that disagreement is the finding (SMUGGLE08, SMUGGLE09).
Self-defending. Every byte of evidence is rendered inert (\xNN) before it
reaches a report, so reading janus's output can never execute a captured CRLF or
ANSI payload in your terminal. A test asserts the rendered report contains no
control bytes, and a second test asserts the hostile fixture really does contain
raw CRLF — so the first cannot pass because the payload went missing.
| Family | Question | Rules |
|---|---|---|
MIRROR |
Do the two planes say the same thing? | 9 |
SMUGGLE |
Can two parsers read the same bytes differently? | 10 |
ENFORCE |
Did the origin actually perform the MUST? | 8 |
XPARAM |
Are the x-mcp-header annotations legal and safe? |
14 |
POLICY |
Is the deployment's trust in these headers sound? | 6 |
LEAK |
What does mirroring push into every proxy's logs? | 6 |
CONF |
Request-metadata conformance and hygiene | 7 |
Highlights: MIRROR05 (divergence concealed inside a base64 sentinel, invisible
to any byte-comparing intermediary), SMUGGLE09 (uppercase =?BASE64? markers
— a literal to the spec, decodable to a lenient reader), ENFORCE01 (the origin
accepted a mismatch, which voids every upstream policy at once), POLICY03
(a security decision keyed on a header sourced from LLM-chosen arguments),
XPARAM01 (and the parameter behind it is unconstrained), LEAK04 (a secret
wrapped in base64 — no confidentiality gained, but your log scanners now miss
it, which is strictly worse than plaintext).
Rejected: an active prober. Experiment 023 (rampart) drives a live HTTP
server with crafted probes, and the same could be done here — send a mismatched
Mcp-Name and see whether -32020 comes back. It was rejected because it
answers a strictly smaller question. A prober can only reach the origin, so it
tests one component's conformance; the defect janus targets lives in the gap
between two components, and the gateway's policy is not observable from the
wire at all. Probing would also require a live deployment and would make CI
depend on the network. The passive design accepts a capture plus a declared
topology, costs nothing to run, and can grade an architecture before it is
built.
Chosen: a passive two-view differential over a declared topology. Higher
input burden — someone must describe the path — in exchange for the only
analysis that can say "this mismatch matters because edge-waf:tool-allowlist
decides on it." Memory is O(one exchange) since exchanges are analysed
independently; CPU is linear in headers × exchanges plus one schema walk per
tool. Maintenance cost is concentrated in the rule catalog, which is a flat
table.
A third option — encoding the two readers in TLA+ and model-checking the agreement property — was considered and rejected on the same grounds experiment 028 rejected it: the interesting failures are concrete parser behaviours (padding repair, case folding, duplicate resolution), not state-space exploration, and the output shape would not be a list of findings a CI job can gate on.
$ python -m janus audit fixtures/hostile/capture.json
==============================================================================
janus - MCP header-mirroring plane audit
capture: hostile-reference
==============================================================================
score 0/100 grade F
found 80 finding(s) across 7 famil(ies)
sev 19x CRITICAL, 21x HIGH, 28x MEDIUM, 11x LOW, 1x INFO
exchanges 18
tools 5
components 2
validator NONE - nothing binds headers to the body
------------------------------------------------------------------------------
ENFORCE - Origin validation of the MUST (14)
------------------------------------------------------------------------------
[CRIT] ENFORCE01 Origin accepted a request whose header and body disagree
at exchange 1
The origin answered 200 after receiving a request whose planes
disagree (Mcp-Name disagrees with params.name; Mcp-Param-TenantId
disagrees with params.arguments.tenant_id). The specification
requires 400 with -32020 here. Because this check is the only thing
binding the header plane to the body, skipping it voids every
upstream decision made from these headers.
fix: Implement the header/body equality check. This single omission
voids every header-based policy in front of the origin.
...
And the conformant reference capture:
$ python -m janus audit fixtures/clean/capture.json
score 100/100 grade A
found 0 finding(s) across 0 famil(ies)
validator origin
No findings. The header plane and the body plane agree, and every
policy that reads a mirrored header sits behind a validating origin.
The clean fixture is deliberately not trivial. It mirrors a tool parameter,
legitimately base64-encodes a non-ASCII argument, routes on Mcp-Method and
authorizes on Mcp-Param-Region. It scores 100/A because the origin validates
header against body and the gateway re-derives the region decision from the
authenticated principal — not because it avoids the risky features. Tests assert
those two properties directly, and two further tests flip each one and assert
the fixture then fails, so it cannot go green for the wrong reason.
$ make test
442 passed
Weighted toward negative cases, because a rule that fires on conformant traffic
is worse than no rule: a legitimate sentinel must not read as smuggling; a
duplicate header with identical values is not a differential; a
whitespace-only difference must not oblige the origin to reject (the spec
requires it to accept); 42 vs 42.0 must be reported as the benign numeric
class; Mcp-Name must never be scored for entropy, because its content is
dictated by the spec and scoring it would punish conformance.
tests/test_coverage.py fails CI if any of the 60 rules is not exercised by a
fixture, and tests/test_fixtures.py re-runs the committed
build_hostile.py and diffs its output against what is shipped.
Stated plainly, because the tool is only as honest as its caveats.
- Trust topology is a declared input, not a discovery. janus cannot see your gateway config. If you describe the path incorrectly — or not at all — severity escalation and the whole POLICY family degrade accordingly. With no topology given, janus still reports MIRROR/SMUGGLE/XPARAM/LEAK/CONF, but cannot tell you whether a divergence matters.
- It confirms rather than searches. janus grades the exchanges you give it. It does not explore the space of requests a client could send, and will not discover a divergence that no captured request exhibits.
- Classifiers are pattern-based.
LEAK01/LEAK02use name heuristics, known credential formats and entropy. A bespoke secret format will be missed; an opaque-but-harmless identifier over 20 characters may be flagged. Both are tuned to favour a false positive over a missed credential in a log. - The lenient reader is a model, not a census.
read_lenientrepresents a plausible forgiving decoder. A specific proxy may be stricter or stranger. Where janus reports a two-reader split, the claim is "these bytes admit two readings", not "your proxy definitely picks this one". - No capture shim ships here. Producing the capture JSON from a live deployment is left to the operator; the format is documented above and is deliberately trivial to emit from a proxy access log plus a body dump.
x-mcp-headerscope follows the shipped spec, not the SEP. Where SEP-2243 says nested properties are illegal and the 2026-07-28 specification permitsproperties-only chains, janus implements the specification and reports the difference here rather than splitting it.- Intermediary behaviour is asserted, not tested.
POLICY05(stripping unrecognisedMcp-Param-*) is read from your topology declaration; janus does not verify it against the wire. - Scoring is blunt on purpose. One CRITICAL costs 40 points. The intent is that a header plane with an unvalidated authorization decision behind it can never read as "mostly fine"; it is not a calibrated risk metric.
Differential analysis of two HTTP parsers is mature work — see The HTTP Garden
(arXiv:2405.17737), the 2026 duplicate-header CVEs (@hapi/content
CVE-2026-44974, python-hyper/h2 CVE-2026-71554, Go HTTP/2 CVE-2026-2219) and
every WAF ruleset. Header/body consistency is not even a discovery: it is a MUST
written into the MCP specification. janus does not claim the technique. The
wedge is the assembly — an MCP-aware, topology-typed, offline, dependency-free
auditor that models the deployment as two readers of one request, audits the
x-mcp-header annotation graph statically, and treats the mirroring plane as an
egress channel into proxy logs. Full discussion in RESEARCH.md.
MIT.