Noizu.MCP.Auth.Server.SSRF (Noizu MCP v0.1.6)

Copy Markdown View Source

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, no file, no gopher
  • 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 as 169.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

error()

@type error() ::
  :invalid_url
  | :scheme_not_allowed
  | :port_not_allowed
  | :userinfo_not_allowed
  | :unresolvable_host
  | :blocked_address

ip()

@type ip() :: :inet.ip_address()

Functions

blocked_ip?(ip)

@spec blocked_ip?(term()) :: boolean()

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.

check_url(url, opts \\ [])

@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:

  • :resolverfun(host) :: {:ok, [ip]} | {:error, term()}, so this is testable without DNS. Default resolves both A and AAAA records.

  • :allowed_ports — default [443].
  • :allow_privatetesting only; skips the address denylist.

fetch_opts()

@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.

max_body()

@spec max_body() :: pos_integer()

Maximum CIMD response body, in bytes.

resolve(host, opts \\ [])

@spec resolve(
  String.t(),
  keyword()
) :: {:ok, [ip()]} | {:error, :unresolvable_host}

Resolve a host to every address it answers with (A and AAAA), or an IP literal to itself.

timeout()

@spec timeout() :: pos_integer()

Fetch timeout, in milliseconds.