Skip to content

fix(openapi-to-mcp): harden the plugin against a hostile document and caller - #13959

Open
AlinsRan wants to merge 12 commits into
apache:masterfrom
AlinsRan:fix/openapi-to-mcp-hardening
Open

AlinsRan wants to merge 12 commits into
apache:masterfrom
AlinsRan:fix/openapi-to-mcp-hardening

Conversation

@AlinsRan

Copy link
Copy Markdown
Contributor

Description

A security review of openapi-to-mcp (added in #13942, merged into master and not in any release) found six issues, all reachable by someone who can either call a tool or serve the OpenAPI document the Route points at. This PR fixes them, with a test per issue.

1. Arguments were validated but not filtered

split_arguments() passed the headerParameters object through as it arrived. The generated input schema does not forbid extra properties, and an operation that declares no header parameter gets no headerParameters container to constrain either, so for most operations the object was free-form. tools/handler then wrote every entry into the outgoing request, after the Route's own headers.

A tool call could therefore:

  • replace a credential the Route adds (Authorization, an API key), because the caller's headers were written last;
  • add headers the API trusts, such as X-Forwarded-For or Host;
  • put a newline in a value. resty.http writes "<name>: <value>\r\n" as given, so that appends headers — or a whole second request — to the one being sent.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"getPetById",
 "arguments":{"pathParameters":{"petId":1},
 "headerParameters":{"Authorization":"attacker","X-A":"v\r\nX-Injected: 1"}}}}

Arguments are now filtered to the parameters the operation declares, in both the flat and the nested shape; the Route's headers are applied last so a call cannot replace them; and a header whose name or value cannot appear in a request header is dropped with a warning. The same filtering closes the query side, where an undeclared key could be appended to the upstream query string (admin=true, a second api_key=).

2. No bound on the upstream response

The response was buffered whole and then pretty-printed by building one table entry per character, which costs several times the body's size in memory. Nothing limited either step, so one tool call against an endpoint that returns a large body could exhaust a worker.

The body is now read in chunks up to max_response_body_size (1 MiB by default, configurable); a larger response fails the call with RESPONSE_TOO_LARGE rather than being buffered. Pretty-printing falls back to the compact form past 256 KiB.

3. $ref expansion had no total budget

Expansion inlines, so a document whose every level fans out b ways produces b^depth nodes; with MAX_DEPTH = 16 and b = 4 that is about 4×10⁹. Ordinary nesting does not increase depth either. Expansion now stops after 50k nodes and degrades what is left to {"type": "object"}.

4. An external $ref was an SSRF primitive

An http(s) $ref is a URL the document chooses and the gateway dials. There was no host restriction, so a document could point the gateway at 169.254.169.254 or an internal admin port, with the fetched structure visible in tools/list. A $ref is now followed only to the host the document itself came from, plus whatever the new allowed_ref_hosts names.

5. SSE sessions were not bound to the Route that issued them

session.create() stored a bare id and handle_post only checked that the id existed. A session id issued on an authenticated Route could be replayed against another openapi-to-mcp Route on the same instance, and the answer — computed with that Route's configuration — landed in the first stream. Sessions now carry the Route and the consumer they were issued for, and the message endpoint refuses anything else. The dict keys also carry a prefix of their own instead of sharing a namespace with mcp-bridge.

6. No Origin check

MCP asks an HTTP transport to validate Origin, because a browser page can otherwise reach a server bound to localhost (DNS rebinding). The new allowed_origins does that; it stays off until a Route names its origins, so nothing changes for existing configurations.

Also in this PR: configured headers can no longer carry a newline, or a name that is not a header name (the schema constrained values of matching names only); and the docs get a security section covering the document as an untrusted input, base_url built from client-controlled variables, and limit-conn for SSE Routes.

Tests

t/plugin/openapi-to-mcp*.t, 769 assertions, all passing locally:

  • an undeclared header or query parameter never reaches the API; a declared one does; a newline in a declared value drops the header; the Route's credential survives a call that tries to replace it;
  • a response over the limit fails the call, one under it comes back whole;
  • expansion stops at the node budget; a $ref to another host is not followed, allowed_ref_hosts (including a wildcard) lets one through;
  • a session id from one Route is refused by another (openapi_to_mcp_cross_route.py), a session keeps its owner across a refresh, and the dict keys are prefixed;
  • an allowed Origin is served, another is refused with 403, and no Origin header still works;
  • configured headers with a newline or a bad name are rejected by the schema.

Which issue(s) this PR fixes:

N/A

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (If not, please discuss on the APISIX mailing list first)

… caller

A security review of the merged plugin turned up six issues, all of them
reachable by someone who can call a tool or serve the OpenAPI document.

Tool call arguments were validated but not filtered. The generated input
schema does not forbid extra properties, and an operation that declares no
header parameter gets no headerParameters container to constrain either, so
`headerParameters` reached the API as it arrived: a caller could add request
headers, replace the credential the route configures, or put a newline in a
value and, since resty.http writes the header line as given, append a
request of its own. Arguments are now filtered to the parameters the
operation declares, in both the flat and the nested shape; the route's own
headers are written last so a tool call cannot replace them; and a header
whose name or value cannot appear in a request header is dropped. The same
filtering closes the query-parameter side, where an undeclared key could be
appended to the upstream query string.

The upstream response was read whole and then pretty-printed by building one
table entry per character, so a large response cost several times its size
in memory with no bound. The body is now read in chunks up to
`max_response_body_size` (1 MiB by default) and a larger one fails the call;
pretty-printing falls back to the compact form past 256 KiB.

$ref expansion inlines, so a document whose every level fans out several ways
grows as the product of those widths -- MAX_DEPTH alone left room for billions
of nodes. Expansion now stops at 50k nodes. An external $ref is also a URL the
document chooses and the gateway dials, so it is followed only to the host the
document itself came from, plus whatever `allowed_ref_hosts` names.

An SSE session was a bare id in a shared dict: any route could accept it, so
a session id issued on an authenticated route could be used from another one
to push a tool result into that stream. Sessions now carry the route and
consumer they were issued for, and their dict keys carry a prefix of their
own rather than sharing a namespace with mcp-bridge.

`allowed_origins` adds the Origin check the MCP spec asks of an HTTP
transport; it is off until the route names its origins. Configured headers can
no longer carry a newline or a name that is not a header name. The docs get a
security section: the document is an input rather than trusted configuration,
`base_url` should not be built from client-controlled variables, and an SSE
route reachable by untrusted clients wants limit-conn.
lua-resty-http rejects a '?' inside the request path, so the query string has
to travel in its own field.
Review of the previous commit found two behaviours that the switch from
request_uri() to connect()+request() had changed by accident.

A body with neither Content-Length nor chunked encoding ends when the
connection does, and the streaming reader reports that as "closed" alongside
the last piece of it. Treating every read error as a failure threw a complete
body away and answered with a transport error instead. The read now keeps the
chunk it was handed and only treats "closed" as an error when the response
declared a length it did not deliver.

Setting Host from the parsed URL dropped the port: the client appends it
itself when it is not the default for the scheme, and only when the caller
left the header unset, so a backend on any other port was addressed as if it
were on 80. The header is left to the client again.

Both live in a new fetch module, which openapi/loader.lua now uses too: the
document fetch was still unbounded, and up to eight more documents are pulled
in by $ref from hosts the document names, so the same cap applies there (4 MiB).

Also from the review:
- The route's own headers are checked for a newline at request time, not only
  in the schema. They are templates resolved per request, so the schema cannot
  vouch for what a variable puts in them.
- The schema patterns anchor with \z. PCRE's $ also matches before a trailing
  newline, so "X-Trace: v\n" passed the check it was written to fail.
- The Origin rejection is emitted after the JSON content type is set.
- allowed_origins no longer treats "*" as an entry: leaving the field out is
  what turns the check off.
- openapi/loader.lua says "declares no paths" rather than calling a document
  with webhooks and no paths "not an openapi document".

New tests: the Host the API receives, a connection-close-delimited body, a
body larger than one read, the compact fallback past 256 KiB, a trailing
newline in a configured header, a newline arriving through a variable, and
Origin on the SSE transport.
The new case was appended with the number it had in another file; reindex
requires them to run in sequence.
apache#13956 landed on master and touched the same files. Three places needed a
real merge rather than one side or the other:

- session.lua: master stores the values a stream resolved in the :alive
  entry, this branch stores the owner the session belongs to. The entry now
  carries both, as one JSON object, and `exists()` still refuses a session
  id presented by anyone else.
- cache.lua: master's `loader.validate()` check runs before this branch's
  host-restricted `$ref` resolution.
- loader.lua: master's `validate()` alongside this branch's bounded fetch.

The docs and the two test files had both sides appending to the same
place; every case from both is kept and renumbered.
Six problems, all in code this branch added.

- The $ref node budget charged the document's own nodes to it. A large but
  perfectly ordinary spec would exhaust it, and since Lua does not define
  the order the traversal takes, the subtree that degraded to a generic
  object could be paths -- leaving tools/list empty with only a warning to
  say why. Only what an expansion produces is counted now.
- The same-origin rule for an external $ref compared the host alone. A
  document served from 127.0.0.1 could name any other port on that address,
  the Admin API and etcd among them, and publish the shape of what came
  back through tools/list. Scheme, host and port are compared, and an
  allowed_ref_hosts entry may pin a port.
- A body was treated as framed whenever any Transfer-Encoding was present,
  but the client reads chunked framing only for "Transfer-Encoding:
  chunked" on HTTP/1.1. An "identity" body, which ends at the close like an
  unframed one, was reported as a network error. Framing is now decided by
  the client's own predicate, and a Content-Length that is not reached is
  the one case where a close is a truncated body.
- The cache key joined allowed_ref_hosts with a comma, so { "a.com,b.com" }
  -- one entry, allowing nothing -- collided with { "a.com", "b.com" }, and
  the same set in another order took a second slot. It is sorted and joined
  with a NUL.
- The header schema anchored with \z, which only PCRE has. The schema is
  served over the Admin API and validated by clients whose regex flavour
  reads \z as a literal "z", where every valid configuration looks invalid.
  It anchors with $, and the gap that leaves -- $ also matches before a
  trailing newline -- is closed at request time, where the header is
  dropped.
- The document size ceiling was a constant while the response ceiling was
  an option. It is max_document_size, and it covers the documents an
  external $ref pulls in as well.
@AlinsRan
AlinsRan marked this pull request as ready for review September 17, 2026 23:58
Two ways a call could reach past its arguments, both through headers the
document declares as parameters.

A declared "Transfer-Encoding" or "Content-Length" let the caller frame the
request the gateway sends. resty.http drops Content-Length as soon as a
Transfer-Encoding says chunked, then writes the body exactly as given and
unchunked, so the upstream reads the argument body as chunk headers and the
caller decides where the request ends -- and the connection goes back to
the pool for the next tool call. Those, Host, and the hop-by-hop headers
are refused from a call whatever the document says; a Route's own headers
are left alone, since an operator naming one means it.

Header names are case-insensitive and the client normalises them at the
end, but a plain Lua table holds "Authorization" and "authorization" as two
keys. Both reached the client, which resolved the collision in pairs()
order, so a caller who picked a spelling the hash put last replaced the
Route's credential -- the opposite of what this code says it guarantees.
Headers are now written one entry per lowercased name, which makes the
Route's, written last, the one that counts.
@AlinsRan AlinsRan self-assigned this Sep 18, 2026
@AlinsRan
AlinsRan force-pushed the fix/openapi-to-mcp-hardening branch from 25345a0 to 7d631ea Compare September 18, 2026 02:06
nic-6443
nic-6443 previously approved these changes Sep 18, 2026

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Origin validation is disabled by default

origin_rejected() returns false when allowed_origins is unset, so every existing openapi-to-mcp route still accepts arbitrary browser origins. This leaves the DNS-rebinding issue described by the PR unfixed by default and conflicts with the MCP Streamable HTTP requirement that servers validate the Origin header on all incoming connections.

Please make Origin validation secure by default: reject a present Origin unless it matches an explicitly trusted origin (or a safely derived route origin), and require an explicit escape hatch for intentionally accepting any origin. Add SSE and Streamable HTTP regressions showing that a malicious Origin is rejected when allowed_origins is omitted, while requests without Origin remain supported for non-browser clients.

MCP asks an HTTP transport to validate Origin, and checking it only where
allowed_origins was configured left every existing Route accepting a call
from any page in any browser.

A request that carries an Origin is now accepted only from the origin it
was addressed to. allowed_origins names the other origins a Route accepts,
and ["*"] accepts any, which is the escape hatch for a deployment that
means it. A request with no Origin is untouched: no non-browser MCP client
sends one, and those are most of them.

Two things this does not claim. An opaque origin -- "null", which a
sandboxed frame and a file:// page send -- matches nothing and is refused.
And it is not by itself a defence against DNS rebinding, where the attacker
owns the name and both Origin and Host are theirs; a Route that declares
`hosts` is not reachable that way at all, since a request carrying another
Host does not match it. The docs say so rather than implying more.
…s own

A Route that lists the origins it accepts does not additionally accept its own: where an attacker controls the name a request was sent to, Origin and Host are both theirs and match each other, so treating that as trusted would go around the list the operator wrote. The docs said what happens with no list; they now say what happens with one.

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The default same-origin check still permits DNS rebinding

When allowed_origins is unset, origin_rejected() accepts a request whenever its Origin equals the scheme and Host taken from that same request. In a DNS rebinding attack, the browser sends both Origin: http://attacker.example and Host: attacker.example after DNS changes attacker.example to the APISIX or private address. The two values still compare equal, so a URI-only Route with no hosts constraint accepts the request. The test fixture creates exactly that kind of Route.

Comparing two attacker-controlled request headers blocks ordinary cross-origin calls, but it does not provide the independent trust anchor needed to close the DNS-rebinding issue. The new tests use an evil Origin with the default Host: localhost, so they do not exercise this bypass.

Please validate both Host and Origin against an independent allowlist (for example, secure localhost defaults plus explicitly configured hosts/origins), or reject requests carrying Origin when there is no trusted configuration. Add a regression with matching Host: attacker.example and Origin: http://attacker.example on a Route without a host predicate and expect 403.

…Host

Comparing the Origin of a request with the Host of that same request is
comparing two headers the same client wrote. Under DNS rebinding the
attacker owns the name, so both carry it and agree, and a Route with no
host predicate accepted the call -- the case the check was added for.

The Origin is now checked against something the operator configured:
allowed_origins, or the hosts the Route declares in host/hosts, which the
attacker's name does not satisfy. Where the Route declares neither, the one
remaining case is loopback at both ends: a page can only have
http://localhost as its origin if it is served from the machine itself, and
no rebinding produces that. Anything else carrying an Origin is refused.

A request with no Origin is still accepted, unchanged: no non-browser MCP
client sends one.

@membphis membphis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Wildcard Route hosts still permit DNS rebinding

When allowed_origins is unset, host_matches() treats a Route entry such as *.example.com as trusted and origin_rejected() accepts every matching subdomain. An attacker who controls attacker.example.com can serve a page from it and then rebind its DNS to APISIX. The browser continues to send Host: attacker.example.com and Origin: http://attacker.example.com; APISIX matches the wildcard Route and this check returns false, so the same DNS-rebinding path remains open for supported wildcard-host configurations.

A Route wildcard is a routing predicate, not a safe Origin allowlist. Please derive default trust only from literal Route hosts and require an explicit, exact allowed_origins configuration when the matched host predicate contains a wildcard. Add Streamable HTTP and SSE regressions using hosts: ["*.example.com"] with matching Host and Origin for an attacker-controlled subdomain, expecting 403.

…igin

A "*.example.com" entry says which requests reach the Route. It does not
say which origins are trusted: whoever controls one name under it can serve
a page there and rebind it to the gateway, and the request then arrives
with Host and Origin both on that name and matches the Route.

Only a literal host the Route declares counts towards the default trust
now. A Route matched on a wildcard accepts an origin through
allowed_origins and nowhere else.
@AlinsRan

Copy link
Copy Markdown
Contributor Author

[P1] Wildcard Route hosts still permit DNS rebinding

Agreed — a wildcard says which requests reach the Route, not which origins are trusted. Fixed in 76c1fba.

Default trust is now derived only from literal hosts in host / hosts. An entry beginning with * is skipped when collecting them, so a Route whose only host predicate is a wildcard has no anchor at all and refuses every request that carries an Origin. Such a Route accepts one through allowed_origins and nowhere else.

New regressions, hosts: ["*.example.com"], Host and Origin both attacker.example.com:

Transport
Streamable HTTP no allowed_origins 403
SSE no allowed_origins 403
Streamable HTTP allowed_origins: ["http://app.example.com"] app.example.com → 200, attacker.example.com → 403

A Route mixing both — hosts: ["*.example.com", "mcp.example.com"] — trusts mcp.example.com only, since that is the one entry the operator wrote out.

Docs in both languages now state the wildcard case explicitly rather than saying "the hosts the Route declares".

…e budget on components

core.request.header() hands back the first of a repeated header, so the
check for more than one Origin could never fire and the second one was
quietly ignored. It reads the header table now.

$ref expansion covered the whole document although tools are generated
from paths alone, so a document of any size spent the node budget on
components -- exactly where a large one keeps its schemas -- and whatever
was left, possibly paths itself, degraded to a generic object. Only paths
is expanded now, and max_expanded_nodes makes the budget a Route option,
since nothing distinguishes a legitimately large document from one built
to exhaust memory except how large the operator says their specs get.

Alongside, three smaller ones:

- The Route-host anchor compared only the hostname, so a page on another
  port of a declared host passed. The port now has to agree, where
  agreeing means both spelled it out the same way or neither did -- which
  is also what a TLS-terminating proxy in front produces.
- An allowed_origins entry without a scheme matched nothing, leaving the
  Route refusing every browser with no way to see why. The schema rejects
  it, and the 403 body names allowed_origins.
- fetch.lua says why an HTTP/1.0 response framed as chunked is refused.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants