The default, SSRF-guarded Client ID Metadata Document fetcher - CIMD
(draft-ietf-oauth-client-id-metadata-document-01, 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 over Req -> Finch -> Mint.
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, connect and receive timeouts (: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- 2-arity fun `(charlist_host, :inet:inet6) -> `. 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- body size cap. Default5_120(draft's recommended 5 KB).:request_timeout_ms- connect and receive 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.
Returns true iff ip is a special-use address (RFC 6890) an authorization
server MUST NOT dereference for CIMD: loopback, private, link-local, CGNAT,
0.0.0.0/8, multicast, the reserved/documentation ranges, the IPv6
equivalents (fc00::/7, fe80::/10, ff00::/8, ::1, ::), and every IPv6
form that embeds an IPv4 - IPv4-mapped (::ffff:0:0/96), NAT64
(64:ff9b::/96), 6to4 (2002::/16), and IPv4-compatible (::/96) - which is
unwrapped to its embedded IPv4 and re-checked, so an internal IPv4 cannot be
smuggled past the guard through any of them.
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.
Returns true iff ip is a special-use address (RFC 6890) an authorization
server MUST NOT dereference for CIMD: loopback, private, link-local, CGNAT,
0.0.0.0/8, multicast, the reserved/documentation ranges, the IPv6
equivalents (fc00::/7, fe80::/10, ff00::/8, ::1, ::), and every IPv6
form that embeds an IPv4 - IPv4-mapped (::ffff:0:0/96), NAT64
(64:ff9b::/96), 6to4 (2002::/16), and IPv4-compatible (::/96) - which is
unwrapped to its embedded IPv4 and re-checked, so an internal IPv4 cannot be
smuggled past the guard through any of them.
This is the single source of truth for the guard's CIDR table; the
:allow_loopback exception is applied by the caller (fetch/2), not here, so
this predicate always reports loopback as special-use.