Bypassing iron-proxy's MCP Tool Allowlist With a Content-Type Header
iron-proxy is an egress proxy to manage traffic for untrusted workloads. It works as a MITM proxy, has a DNS server and can apply an allowlist on MCP tool calls.
While trying to sandbox an agent with docker, I added iron-proxy as a container to route all the traffic through it. The idea was to have agent and MCP each in their own containers and isolated networks, both communicating through the proxy. The proxy was the only container having outbound internet access. Either all the workloads had to go through the proxy, which only allowed access to domains and MCP tools explicitly whitelisted, or were dropped.
I discovered that iron-proxy only enforces the MCP tool call allowlist when the Content-Type header matches application/json. This means an agent can send a JSON-RPC body for a denied tool with a different Content-Type header and it can be executed.
Disclosure: AI agents were used to test the behaviour across multiple MCP server implementations, content types and iron-proxy versions.
The Test Setup
For testing the bypass, I used the following setup:
- The workload container: An isolated container, which only has proxy as a way out. In the real workload this was the agent container.
- The proxy container: The iron-proxy itself.
- The upstream container: A simple container with python script that logs the MCP tool calls it receives. In the real workload this was the MCP container.

The proxy config allowed only one host and one tool:
dns:
enabled: false
proxy:
http_listen: ":8080"
tls:
mode: sni-only
transforms:
- name: allowlist
config:
domains:
- "mcp.test"
mcp:
error:
code: -32001
message: "blocked by iron-proxy policy"
servers:
- name: demo
rules:
- host: "mcp.test"
paths: ["/mcp", "/mcp/*"]
tools:
- name: "allowed_tool"
mcp_gateway:
routes:
- name: demo
rules:
- host: "mcp.test"
paths: ["/mcp", "/mcp/*"]
upstream: "http://upstream:8901/mcp"
log:
level: infoPer this config, only allowed_tool should be executed when called. Any other tool call should never reach the upstream container, the proxy should record a call with decision:deny and reason as tool_not_allowed. The following call works as expected and we see a log entry for it:
# control: denied, upstream untouched
curl -s -x http://127.0.0.1:8080 http://mcp.test/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"denied_tool","arguments":{}}}'
# response: blocked by the proxy
{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"blocked by iron-proxy policy"}}But if we replace the header with Content-Type: text/plain:
curl -s -x http://127.0.0.1:8080 http://mcp.test/mcp \
-H 'Content-Type: text/plain' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"denied_tool","arguments":{}}}'
# response: answered by the upstream
{"jsonrpc":"2.0","id":0,"result":{"tools":[]}}The upstream answers instead of the proxy. If this were a real MCP server, the denied tool would have run.
The proxy keeps audit logs for all requests. The log records three different shapes:
- An allowed call writes a request record and a response record.
- A denied call writes a request record only.
- A bypassed call writes a response record only, because no policy ran on the request. The decision is recorded as
allow.
The missing request record is the only way to tell a bypassed call from a truly allowed call, as the decision for both is recorded as allow in the logs.
The Root Cause
From the iron-proxy source, the interceptor only inspected a POST when the declared media type parses as application/json:
// We only inspect application/json POST requests. GET listeners and other
// shapes pass through; their responses are still wrapped if they arrive
// over text/event-stream so the listener stream is filtered.
if req.Method != http.MethodPost {
return nil, nil
}
if !mediaTypeIs(req.Header.Get("Content-Type"), mediaTypeJSON) {
return nil, nil
}mediaTypeIs parses the header with Go’s mime.ParseMediaType and compares the result to application/json. If the parse fails, the function returns false and the caller treats that as not being an MCP request. So the request is forwarded without any policy check.
Every header value that fails this check therefore skips the allowlist check:
Content-Type sent | Proxy | Upstream |
|---|---|---|
application/json | rejected | untouched |
application/json; charset=utf-8 | rejected | untouched |
Application/JSON | rejected | untouched |
application/json; | rejected | untouched |
text/plain | forwarded | logged by the upstream |
application/x-www-form-urlencoded | forwarded | logged by the upstream |
no Content-Type at all | forwarded | logged by the upstream |
text/plain, application/json | forwarded | logged by the upstream |
application/json, text/plain | forwarded | logged by the upstream |
duplicate Content-Type headers | forwarded | logged by the upstream |
application/jsonx | forwarded | logged by the upstream |
application/json-patch+json | forwarded | logged by the upstream |
application/json; charset= | forwarded | logged by the upstream |
Out of these 13 cases, 4 were rejected and 9 were forwarded.
Limitations
This bypass in itself is not a full exploit. The other half depends on the MCP server checking for content type headers (the MCP server I originally encountered this with did not). I tested 4 MCP servers behind the same proxy config, each exposing a tool not on the allowlist: the official Python SDK (mcp 2.2.0 and 1.30.0), the official TypeScript SDK (1.30.0) and mark3labs/mcp-go (1.1.0).
The setup is the same as above, and the execution is confirmed when a tool handler prints the call in the upstream log, because the HTTP response alone can be misleading: a server can accept the body and still answer with a JSON-RPC error.
| Server | text/plain, form-urlencoded, none | application/json, text/plain and application/json; charset= |
|---|---|---|
python mcp 2.2.0 | 400, not executed | executed |
python mcp 1.30.0 | 400, not executed | executed |
| TypeScript SDK 1.30.0 | 415, not executed | 415, not executed |
mark3labs/mcp-go 1.1.0 | 400, not executed | 400, not executed |
Two header values did work: application/json, text/plain and application/json; charset=. Go does not parse either of them. It rejects the first because a comma is not valid inside a media type, and the second because the charset parameter has no value.
The Python SDK accepts both values. It validates the header in two different places but neither check blocks our call. The first check runs for every POST and compares a prefix:
return content_type is not None and content_type.lower().startswith("application/json")A value that fails it returns 400 with Invalid Content-Type header.
The second check looks for application/json among the comma-separated parts:
content_type_parts = [part.strip() for part in content_type.split(";")[0].split(",")]
return any(part == CONTENT_TYPE_JSON for part in content_type_parts)A value that fails this one returns 415 Unsupported Media Type.
Both of our values pass both checks. application/json, text/plain passes the first on its prefix, and passes the second because the split on , produces ["application/json", "text/plain"], where one part matches. application/json; charset= passes the first on its prefix, and passes the second because everything before the ; is exactly application/json.
Possible Exploits
A local, trusted MCP server
Let’s say we have a local, trusted MCP server for database, running on the other side of the proxy as the agent. Assume tools like list_tables, describe_table, create_table, drop_table, etc and that the agent can interact with the database through MCP only. We want to deny access to the destructive tool: drop_table. With this bypass, the agent can access the drop_table tool by simply changing the Content-Type header.
A local / remote server that needs authentication
iron-proxy supports injecting credentials into the calls. For MCP servers that need authentication, the gateway attaches route credentials to all the calls it forwards. Let’s say we have the proxy config like this:
mcp_gateway:
routes:
- name: demo
rules:
- host: "mcp.test"
paths: ["/mcp", "/mcp/*"]
upstream: "http://upstream:8901/mcp"
credentials:
- source:
type: env
var: UPSTREAM_TOKEN
inject:
header: "x-upstream-token"
require: trueThe bypassed call goes through the same gateway, and reaches the upstream carrying the real token. Note that the agent itself does not hold this credential and this can only be injected by the proxy. The audit line records action: "allow" and credentials_injected: ["UPSTREAM_TOKEN:header:X-Upstream-Token"] on a request that has no request record. So a denied tool that needs authentication can still run.
Bypassing argument matching
The proxy also supports limiting the tool calls conditionally, from the docs’ own example:
tools:
- name: "allowed_tool"
- name: "create_issue"
when:
- path: "owner"
equals: "ironsh"This should allow the create_issue tool only when the owner is ironsh.
Testing the same with different Content-Type values gives the following results:
| Call | Content-Type | Proxy |
|---|---|---|
create_issue owner: ironsh | application/json | forwarded |
create_issue owner: attacker | application/json | -32001, upstream untouched |
create_issue owner: attacker | text/plain | forwarded |
create_issue owner: attacker | form-urlencoded | forwarded |
create_issue owner: attacker | none | forwarded |
So the argument matching can be bypassed the same way as the tool calls.
Reporting and Fix
I reported the issue privately to the vendor, and they validated and fixed it promptly in v0.50.0 (fix(mcp): handle POST requests consistently, PR #254). The conditional check was removed, and every POST request that matches the MCP server is now inspected, so the check does not depend on the header anymore. The same 13 header shapes are now all rejected by the proxy and nothing reaches the upstream MCP.
Timeline
- 18 September 2026: reported the issue. The fix was merged the same day in PR #254 and
v0.50.0was published hours later. I re-ran the tests against the new version and the bypass was gone. - 9 October 2026: advisory published, listing
0.23.0through0.49.0as affected and0.50.0as the patched version.
This was my first security report. I admit I refreshed the advisory page more than once.
If you have any feedback or suggestions, feel free to reach out at reply@aabidk.dev