Decides whether the client may connect to a URL, before any connection is
made. It guards against server-supplied URLs (OAuth metadata, redirects,
resource_metadata) pointing the client at internal networks (SSRF).
iex> ConduitMcp.Client.URLPolicy.check("https://127.1/", :public_only)
{:error, {:blocked_address, {127, 0, 0, 1}}}Policies
:public_only-httpsonly; the host is resolved (A and AAAA) and the URL is refused if any address is blocked (see below), unless that address falls inside an:allow_privateCIDR. An IP-literal host is checked directly.:any-httporhttps, no address checks and no DNS lookup.
On success the result carries the resolved addresses, so the caller can
connect to exactly those (pinning) instead of resolving again and racing a
DNS rebind; host stays the hostname for TLS SNI and certificate checks.
Host parsing
Hosts are parsed, never pattern-matched as strings. A host whose last label
is numeric is parsed with the WHATWG URL IPv4 rules, so every notation a
browser or resolver accepts is normalized first: 127.1, 0x7f.1,
0177.0.0.1, 017700000001, 2130706433 and 127.0.0.1. are all
{127, 0, 0, 1}, and an invalid numeric host (1.2.3.4.5, 09.1) is
refused. Bracketed IPv6 literals are parsed strictly. A URL with user info,
a percent-encoded or non-ASCII host, an IPv6 zone id, a backslash, or
whitespace or control characters is refused outright.
Blocked addresses
IPv4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.168.0.0/16,
198.18.0.0/15, 224.0.0.0/4, 240.0.0.0/4.
IPv6: ::/96 (which holds ::, ::1 and the deprecated IPv4-compatible
form), 100::/64, 64:ff9b:1::/48, fc00::/7, fe80::/10, fec0::/10,
ff00::/8. IPv6 forms that embed an IPv4 address are unwrapped and the
embedded address is checked against the IPv4 list: IPv4-mapped
::ffff:0:0/96, IPv4-translated ::ffff:0:0:0/96, NAT64 64:ff9b::/96
and 6to4 2002::/16. So [::ffff:127.0.0.1] and [64:ff9b::a00:1] are
blocked, while a NAT64 address embedding a public IPv4 address (as DNS64
networks synthesize) is not.
Summary
Types
A CIDR block: a base address and a prefix length.
Types
@type cidr() :: {:inet.ip_address(), non_neg_integer()}
A CIDR block: a base address and a prefix length.
@type policy() :: :public_only | :any
@type reason() :: :invalid_url | {:scheme_not_allowed, String.t() | nil} | :userinfo_not_allowed | :invalid_host | {:blocked_address, :inet.ip_address()} | {:resolve_failed, term()}
@type resolver() :: (String.t() -> {:ok, [:inet.ip_address()]} | {:error, term()})
@type target() :: %{ scheme: String.t(), host: String.t(), port: :inet.port_number(), addresses: [:inet.ip_address()] }
Functions
@spec blocked?(:inet.ip_address()) :: boolean()
Whether address is in a blocked range, after unwrapping IPv6 forms that
embed an IPv4 address (see the moduledoc).
iex> ConduitMcp.Client.URLPolicy.blocked?({0, 0, 0, 0, 0, 0xFFFF, 0x7F00, 1})
true
iex> ConduitMcp.Client.URLPolicy.blocked?({93, 184, 216, 34})
false
Checks url against policy.
Options
:allow_private- CIDRs (strings such as"10.1.0.0/16", orcidr/0tuples) whose addresses:public_onlyadmits even though they are blocked. RaisesArgumentErroron an invalid entry.:resolver-resolver/0used for hostnames (default: the system resolver, A and AAAA).:resolve_timeout- milliseconds per lookup family for the default resolver (default 5000).
Parses a CIDR string ("10.0.0.0/8", "fd00::/8"; a bare address is a
single host) or validates a cidr/0 tuple.
iex> ConduitMcp.Client.URLPolicy.parse_cidr("10.0.0.0/8")
{:ok, {{10, 0, 0, 0}, 8}}