Plug that validates the Origin request header against an allowlist.
Reads the allowlist from conn.private[:allowed_origins]. Accepted shapes:
:allowed_origins | Behaviour |
|---|---|
nil (unset) | Fails closed: any request carrying an Origin is rejected |
"*" | All origins allowed — the explicit opt-out |
| a list of strings | Only those origins are allowed (exact match) |
| a bare string | Only that origin is allowed (exact match) |
a Regex | Origins matching the pattern are allowed |
ConduitMcp.Transport.StreamableHTTP and ConduitMcp.Transport.SSE check
the shape at init/1 and raise ArgumentError for anything else. When this
plug is used on its own, an unsupported value fails closed.
Anchor a Regex allowlist
The pattern is tested with Regex.match?/2, which matches anywhere in the
origin. ~r/https:\/\/example\.com/ therefore also allows
https://example.com.evil.test. Anchor both ends:
~r/\Ahttps:\/\/example\.com\z/.
Other rules:
- OPTIONS requests always pass (CORS preflight; the router answers them before any MCP handler runs)
- Requests without an
Originheader pass — see below - Disallowed origins receive a 403 JSON error response
Why an unset allowlist fails closed
A warning does not stop a request: a page on https://evil.example could
POST to a loopback MCP server and, if the response carried
access-control-allow-origin: *, read the reply. "No browser origin is
trusted" is the only default that is safe for a server bound to loopback on
a developer machine.
Pass allowed_origins: "*" to allow every origin explicitly.
Why missing Origin still passes — and what that does not cover
Native MCP clients (Claude Desktop, IDEs, CLIs) are not browsers and do not
send an Origin header. Rejecting header-less requests would break every
legitimate non-browser client, so they pass.
Origin validation is not DNS-rebinding protection
After a successful DNS rebind the attacker's page is, from the browser's
point of view, same-origin with your server — and browsers attach no
Origin to a same-origin GET. Such a request therefore takes the
header-less path above and this plug never runs its allowlist. What Origin
validation does cover is the cross-origin case: a page on another origin
cannot POST to your server, because a cross-origin POST always carries
Origin.
The control that covers rebinding is Host validation, which this library
does not implement. For a server a browser could reach — anything bound to
loopback on a developer machine — put it behind a proxy that rejects
unexpected Host values, or require authentication so the rebound request
has no credential. This matters most for
ConduitMcp.Transport.SSE's GET /sse, which is a readable stream.