Pure request/response decisions for ExMCP.HttpPlug.
The Plug module owns side effects such as reading request bodies, writing responses, ETS/session management, and SSE processes. This module keeps the reusable protocol, origin, and host decisions as data transformations.
Origin validation
origin_allowed?/2 implements a strict allow-list:
- Requests without an
Originheader are allowed. Non-browser clients (CLIs, SDKs, server-to-server callers) do not send the header, and requiring it would break them. DNS rebinding protection for those callers comes from Host validation (host_allowed?/2). - Requests with an
Originheader are only allowed when the origin is listed in:allowed_origins(or:allowed_originsis:any). There is deliberately no "same origin as the request Host" fallback: in a DNS rebinding attack the Host header is attacker-controlled, so comparing the Origin against it would always pass.
Host validation
host_allowed?/2 compares the request Host header against an allow-list
(:any disables the check). Ports are ignored and bracketed IPv6 literals
such as "[::1]:8080" match both "[::1]" and "::1" entries.
Summary
Functions
Checks a request Host header value against an allow-list.
Normalizes a Host header value: trims, downcases, and strips the port.
Types
@type allowed_hosts() :: :any | [String.t()]
@type origin_context() :: %{ optional(:origin) => String.t() | nil, optional(:scheme) => String.t(), optional(:host) => String.t(), optional(:port) => non_neg_integer() | nil }
Functions
@spec cors_response_origin(origin_context(), map()) :: String.t() | nil
@spec host_allowed?(String.t() | nil, allowed_hosts()) :: boolean()
Checks a request Host header value against an allow-list.
Returns true when allowed_hosts is :any. Otherwise the host must be
present and, after normalization via normalize_host/1, match one of the
allow-list entries (compared case-insensitively, ignoring ports and IPv6
brackets).
Normalizes a Host header value: trims, downcases, and strips the port.
Bracketed IPv6 hosts keep their brackets ("[::1]:8080" becomes "[::1]").
Non-bracketed values only lose a port suffix when they contain a single
:, so a raw IPv6 literal such as "::1" is preserved as-is.
@spec oauth_guard_disabled_error() :: map()
@spec origin_allowed?(origin_context(), map()) :: boolean()