ConduitMcp.Client.URLPolicy (ConduitMCP v0.11.0)

Copy Markdown View Source

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 - https only; 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_private CIDR. An IP-literal host is checked directly.
  • :any - http or https, 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.

Functions

Whether address is in a blocked range, after unwrapping IPv6 forms that embed an IPv4 address (see the moduledoc).

Checks url against policy.

Parses a CIDR string ("10.0.0.0/8", "fd00::/8"; a bare address is a single host) or validates a cidr/0 tuple.

Types

cidr()

@type cidr() :: {:inet.ip_address(), non_neg_integer()}

A CIDR block: a base address and a prefix length.

policy()

@type policy() :: :public_only | :any

reason()

@type reason() ::
  :invalid_url
  | {:scheme_not_allowed, String.t() | nil}
  | :userinfo_not_allowed
  | :invalid_host
  | {:blocked_address, :inet.ip_address()}
  | {:resolve_failed, term()}

resolver()

@type resolver() :: (String.t() -> {:ok, [:inet.ip_address()]} | {:error, term()})

target()

@type target() :: %{
  scheme: String.t(),
  host: String.t(),
  port: :inet.port_number(),
  addresses: [:inet.ip_address()]
}

Functions

blocked?(ip)

@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

check(url, policy, opts \\ [])

@spec check(String.t(), policy(), keyword()) :: {:ok, target()} | {:error, reason()}

Checks url against policy.

Options

  • :allow_private - CIDRs (strings such as "10.1.0.0/16", or cidr/0 tuples) whose addresses :public_only admits even though they are blocked. Raises ArgumentError on an invalid entry.
  • :resolver - resolver/0 used for hostnames (default: the system resolver, A and AAAA).
  • :resolve_timeout - milliseconds per lookup family for the default resolver (default 5000).

parse_cidr(cidr)

@spec parse_cidr(String.t() | cidr()) :: {:ok, cidr()} | :error

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}}