Egress filtering to defend against Server-Side Request Forgery (SSRF).
This module resolves a hostname to IP addresses and validates that none of
them fall inside private, loopback, link-local or otherwise internal network
ranges before a caller is allowed to connect. It is transport-agnostic: it
has no dependency on the transport or on Philter.Config. Policy is supplied
entirely through resolve_and_validate/2 options.
Why resolve here?
Validating the hostname string is not enough: an attacker can point a public DNS name at an internal IP (a "DNS rebinding" style attack). We therefore resolve the name ourselves and hand the caller the exact IPs to connect to, so the transport connects to an address we have already validated rather than re-resolving the name.
Blocked ranges
IPv4:
0.0.0.0/8(this-network)10.0.0.0/8(RFC1918)100.64.0.0/10(CGNAT)127.0.0.0/8(loopback)169.254.0.0/16(link-local, cloud metadata)172.16.0.0/12(RFC1918)192.168.0.0/16(RFC1918)240.0.0.0/4(reserved, includes the broadcast address)
IPv6:
::(unspecified)::1(loopback)fc00::/7(unique local addresses)fe80::/10(link-local)
IPv6 forms that embed an IPv4 address are unwrapped to that address and re-checked against the IPv4 ranges, so a translated form of a blocked address cannot slip through:
- IPv4-mapped (
::ffff:a.b.c.d) - IPv4-compatible (
::a.b.c.d) - NAT64 (
64:ff9b::a.b.c.d) - 6to4 (
2002::/16, embedding the IPv4 in the second and third hextets) - Teredo (
2001:0000::/32, embedding the client IPv4, bit-inverted, in the last two hextets)
Summary
Functions
Returns true if ip falls inside a blocked (internal) range.
Resolves host and validates the resulting addresses against the block set.
Types
@type reason() :: :no_addresses | :dns_timeout | {:blocked, :inet.ip_address()}
Functions
@spec blocked?(:inet.ip_address()) :: boolean()
Returns true if ip falls inside a blocked (internal) range.
IPv6 forms that embed an IPv4 address (IPv4-mapped, IPv4-compatible, NAT64, 6to4 and Teredo) are unwrapped to that address before checking, so translated forms of blocked addresses are caught.
@spec resolve_and_validate( String.t(), keyword() ) :: {:ok, [:inet.ip_address()]} | {:error, reason()}
Resolves host and validates the resulting addresses against the block set.
Returns {:ok, addrs} with every validated address in resolution order (the
transport should try them in order; they are deliberately not reduced to a
single address). Returns {:error, reason} otherwise.
Options
:block_private_networks- block internal ranges. Defaulttrue.:allowed_hosts- list of host strings that bypass the block check entirely (the escape hatch). Comparison is case-insensitive and ignores a single trailing dot. Default[].:resolver- a 2-arity function(charlist_host, family)returning{:ok, [ip]} | {:error, term}, matching:inet.getaddrs/2. Default&:inet.getaddrs/2. Note the host is passed as a charlist.:dns_timeout- milliseconds to bound resolution. Default5_000.
Error reasons
:no_addresses- both address families failed or returned nothing.:dns_timeout- resolution exceeded:dns_timeout.{:blocked, ip}-ipfell inside a blocked range. This is intended for server logs only; never expose it to end users.
Escape hatch
If the normalised host is a member of the normalised :allowed_hosts, the
block check is skipped but the name is still resolved so the caller receives
IPs to connect to, even if those IPs are private.