The default, SSRF-guarded Client ID Metadata Document fetcher - CIMD
(draft-ietf-oauth-client-id-metadata-document-02, IETF OAuth WG).
This is the only component of the CIMD feature that makes an outbound request,
so it is where the draft's Security Considerations are enforced. Implements
AttestoPhoenix.ClientIdMetadata.Fetcher with a one-shot Mint connection.
Req remains the optional dependency gate and injectable test transport,
but production requests do not create hostname-keyed Finch pools.
SSRF algorithm (draft Security Considerations)
fetch/2 runs the following, erroring closed at the first failure:
- Re-validate the URL is
httpsand satisfies the draft §2 grammar viaAttesto.ClientIdMetadata.validate_client_id/1(defense in depth - the caller is never trusted). Failure ->{:error, {:invalid_url, reason}}. - Resolve the host to A/AAAA records through the injectable
:resolver(defaults to:inet.getaddrs/2for both:inetand:inet6). No records ->{:error, :unresolvable}. The resolver seam lets tests inject addresses without real DNS, and is the hook the DNS-rebinding defense below pins against. - Reject special-use IPs (RFC 6890). Every resolved address is checked
against
special_use_ip?/1; if any is special-use the fetch is refused with{:error, {:blocked_ip, ip}}. Loopback is permitted only when:allow_loopbackistrue(development). - Pin to a validated IP. The request is dialed at one checked address
(
Mint's:hostnameconnect option keeps TLS SNI, certificate hostname verification, and theHostheader on the original hostname while the socket targets the pinned IP), closing the DNS-rebinding TOCTOU between the check in step 3 and the connect: the name cannot be re-resolved to an internal address after it was validated. - GET with
Accept: application/json, one total deadline covering DNS resolution, connect, send, and receive (:request_timeout_ms, default5_000), and redirects disabled (draft MUST). Any redirect is surfaced as its 3xx status. - Status must be
200; any other status ->{:error, {:status, n}}. - Content-Type must be
application/jsonorapplication/<x>+json; otherwise{:error, :bad_content_type}. - Size cap. The body is refused once it exceeds
:max_document_bytes(default5_120) ->{:error, :too_large}.
On success returns {:ok, %{body: body, cache_control: directives}} where
directives are the parsed Cache-Control / Expires freshness hints
(RFC 9111) for the caller to clamp and store.
Options
:resolver- a 2-arity DNS resolver(charlist_host, :inet | :inet6 -> {:ok, [:inet.ip_address()]} | {:error, term()}). Defaults to:inet.getaddrs/2. Injected by tests to exercise the SSRF guard and the DNS-rebinding pin without real DNS.:allow_loopback- whentrue, loopback addresses (127.0.0.0/8,::1) are permitted (the draft's "AS runs on loopback" exception). Defaultfalse.:max_document_bytes- positive body size cap. Default5_120(draft's recommended 5 KB).:request_timeout_ms- positive total DNS and HTTP request timeout. Default5_000.:req_options- extra options merged into the underlyingReqrequest (e.g. test transport overrides). Escape hatch; not part of the public contract.
Summary
Types
A resolved IP address, as returned by :inet.getaddrs/2.
Functions
Fetch a validated CIMD client_id URL under the SSRF algorithm documented on
this module. See AttestoPhoenix.ClientIdMetadata.Fetcher for the contract.
Delegates to the always-available hostname and address policy.
Types
@type ip() :: :inet.ip_address()
A resolved IP address, as returned by :inet.getaddrs/2.
Functions
@spec fetch(String.t(), keyword()) :: {:ok, AttestoPhoenix.ClientIdMetadata.Fetcher.result()} | {:error, term()}
Fetch a validated CIMD client_id URL under the SSRF algorithm documented on
this module. See AttestoPhoenix.ClientIdMetadata.Fetcher for the contract.
Delegates to the always-available hostname and address policy.