Guards for the one place the authorization server fetches a URL a client
chose: the CIMD (client-id metadata document) fetch, where client_id is
itself an https URL the server dereferences.
That is a textbook SSRF primitive, so the fetch is fenced on every side:
- https only — no
http, nofile, nogopher - no credentials or non-standard ports in the URL
- every resolved address must be public — the denylist below covers
IPv4, IPv6, and IPv4-mapped/compatible IPv6, so
[::ffff:169.254.169.254]is refused as firmly as169.254.169.254. The cloud metadata endpoint is the specific address this exists to stop. - redirects are not followed — a public URL that 302s to
http://169.254.169.254/would otherwise walk straight through the check - 64 KiB body cap and a 5 s timeout — a metadata document is a few hundred bytes; anything else is a decompression bomb or a slow-loris
Residual risk, stated plainly
This is a check-then-connect design, so DNS rebinding between
check_url/2 and the socket open is not eliminated. Closing it fully
requires connecting to the already-resolved IP while sending the original SNI
and Host, which neither Req nor Finch exposes cleanly. Accepted, and the
reason resolve/2 returns the addresses it checked: a fetcher that can pin
should.
iex> Noizu.MCP.Auth.Server.SSRF.blocked_ip?({169, 254, 169, 254})
true
iex> Noizu.MCP.Auth.Server.SSRF.blocked_ip?({0, 0, 0, 0, 0, 0xFFFF, 0xA9FE, 0xA9FE})
true
iex> Noizu.MCP.Auth.Server.SSRF.blocked_ip?({93, 184, 216, 34})
false
Summary
Functions
True when an address is in a range that must never be fetched: private,
loopback, link-local (including 169.254.169.254), CGNAT, multicast,
reserved, or the IPv6 spellings of any of those.
Validate a URL for fetching, resolving its host and checking every address.
Options a fetcher must honour. Passed to Noizu.MCP.Auth.Server.CIMD's
:fetcher so the transport choice does not get to reinvent the limits.
Maximum CIMD response body, in bytes.
Resolve a host to every address it answers with (A and AAAA), or an IP literal to itself.
Fetch timeout, in milliseconds.
Types
@type error() ::
:invalid_url
| :scheme_not_allowed
| :port_not_allowed
| :userinfo_not_allowed
| :unresolvable_host
| :blocked_address
@type ip() :: :inet.ip_address()
Functions
True when an address is in a range that must never be fetched: private,
loopback, link-local (including 169.254.169.254), CGNAT, multicast,
reserved, or the IPv6 spellings of any of those.
@spec check_url( term(), keyword() ) :: {:ok, %{uri: URI.t(), addresses: [ip()]}} | {:error, error()}
Validate a URL for fetching, resolving its host and checking every address.
Returns {:ok, %{uri: URI.t(), addresses: [ip]}}.
Options:
:resolver—fun(host) :: {:ok, [ip]} | {:error, term()}, so this is testable without DNS. Default resolves both A and AAAA records.:allowed_ports— default[443].:allow_private— testing only; skips the address denylist.
@spec fetch_opts() :: keyword()
Options a fetcher must honour. Passed to Noizu.MCP.Auth.Server.CIMD's
:fetcher so the transport choice does not get to reinvent the limits.
@spec max_body() :: pos_integer()
Maximum CIMD response body, in bytes.
Resolve a host to every address it answers with (A and AAAA), or an IP literal to itself.
@spec timeout() :: pos_integer()
Fetch timeout, in milliseconds.