Utilities for extracting and formatting IP addresses.
Supports both IPv4 and IPv6 addresses with proper error handling. Prevents Protocol.UndefinedError when working with IPv6 tuples.
Usage
Extract from LiveView socket:
ip = PhoenixKit.Utils.IpAddress.extract_from_socket(socket)Extract from Plug.Conn:
ip = PhoenixKit.Utils.IpAddress.extract_from_conn(conn)Extract from peer_data directly:
ip = PhoenixKit.Utils.IpAddress.extract_ip_address(%{address: {192, 168, 1, 1}})Trusted proxies
X-Forwarded-For and X-Real-IP are written by whoever sent the request, so
they are only believed when the connection comes from a proxy of yours.
config :phoenix_kit, :trusted_proxies says which peers those are:
# Believe the headers only from these peers (CIDR blocks or bare addresses)
config :phoenix_kit, :trusted_proxies, ["172.18.0.7/32", "10.0.0.0/8"]
# Believe them from nobody: the client is always the connection's peer
config :phoenix_kit, :trusted_proxies, []- Not set (the default). A loopback or private peer (127.0.0.0/8, 10/8,
172.16/12, 192.168/16,
::1,fc00::/7) is taken for a proxy and its headers are believed. This is what installs got before the option existed. Any process that can open a connection from such an address can then claim any client address, so set the option on a production install. The first request on a VM whose forwarded header is honoured this way logs one warning. - A list. The headers are believed only when the peer is inside one of the blocks — public or private alike. A malformed entry matches nothing.
[]. Nobody is trusted; the client is the peer.- Not a list (a string, a map) trusts nobody. Inside a list, an entry
that does not parse is ignored and the valid entries still apply. Either
case logs one warning per VM. Write IPv4 blocks as IPv4
(
"172.18.0.0/16"), not as::ffff:IPv6.
One proxy hop is supported: the client is the LAST x-forwarded-for entry,
even when that entry is another trusted proxy, so a chain such as
CDN -> Caddy -> app reports the CDN. In Docker the proxy's address changes
when its container is recreated; pin it (ipv4_address in compose) or
resolve it by name at boot (config/runtime.exs), otherwise a fixed /32
silently stops matching and every client appears as the proxy.
The same rule applies to client_address/1 (Plug.Conn) and
client_address_from_socket/1 (LiveView), and through trusted_proxy?/1
to the other readers of a forwarded header: X-Forwarded-Proto in
PhoenixKit.API.RequestContext and PhoenixKitWeb.Plugs.EnsureOAuthScheme,
and the proxy rebind of PhoenixKit.Utils.SessionFingerprint.
Examples
iex> PhoenixKit.Utils.IpAddress.extract_ip_address(%{address: {192, 168, 1, 1}})
"192.168.1.1"
iex> PhoenixKit.Utils.IpAddress.extract_ip_address(%{address: {8193, 3512, 1, 0, 0, 0, 0, 1}})
"2001:db8:1::1"
iex> PhoenixKit.Utils.IpAddress.extract_ip_address(nil)
"unknown"
iex> PhoenixKit.Utils.IpAddress.extract_ip_address(%{})
"unknown"
Summary
Functions
The visitor's address as best a site behind a reverse proxy can tell.
The visitor's address as a LiveView socket knows it — or nil when the socket cannot tell.
Extracts the visitor's IP address from a Plug.Conn.
Extracts the visitor's IP address from a Phoenix.LiveView socket.
Extracts IP address from peer_data map.
Whether address (a string or an :inet tuple) is loopback or private —
where a reverse proxy on the same box or network connects from, the
addresses client_address/1 looks past. Anything that does not parse is
not.
The network an address belongs to — what to count or compare visitors by.
Whether address (a string or an :inet tuple) is a trusted proxy: a peer
whose forwarded headers may be believed. The rule client_address/1
applies, for callers that read another forwarded header
(x-forwarded-proto) or decide something by the peer alone. With
config :phoenix_kit, :trusted_proxies unset, any loopback or private
address (local_address?/1); with it set, only an address inside the
list; with an invalid value, nobody (and the one warning per VM about the
invalid value is logged here as well). Anything that does not parse is not.
Functions
@spec client_address(Plug.Conn.t()) :: String.t() | nil
The visitor's address as best a site behind a reverse proxy can tell.
extract_from_conn/1 answers the TCP peer, which behind nginx (or in a
container) is the proxy's own address for every visitor. This one takes
conn.remote_ip (which a host's RemoteIp plug already rewrites) and, when
that is a trusted proxy — by default a loopback or private address, a proxy
on the same box or the same network; with :trusted_proxies set, only an
address inside it — the LAST entry of x-forwarded-for, the one that proxy
appended (a visitor can send their own header; they cannot control what
nginx appends after it), then x-real-ip. Any other remote_ip is the
answer as is. Returns nil when nothing is known.
A forwarded address may carry the port the proxy saw (Caddy's {remote}
writes 203.0.113.7:28858 and [2001:db8::1]:28858); the port is dropped.
An IPv4 visitor reaching a dual-stack listener as ::ffff:a.b.c.d is
reported as a.b.c.d.
iex> conn = %Plug.Conn{remote_ip: {127, 0, 0, 1}, req_headers: [{"x-forwarded-for", "9.9.9.9, 203.0.113.7"}]}
iex> PhoenixKit.Utils.IpAddress.client_address(conn)
"203.0.113.7"
@spec client_address_from_socket(Phoenix.LiveView.Socket.t()) :: String.t() | nil
The visitor's address as a LiveView socket knows it — or nil when the socket cannot tell.
From the connect info the endpoint passes: a :peer_data address that is
not a trusted proxy is the visitor; a trusted one (by default any loopback
or private address, see "Trusted proxies" above) is a proxy, and only the
forwarded address in :x_headers (x-forwarded-for, x-real-ip) names
the visitor then. An endpoint that passes no :x_headers — core's
installed endpoint passes :peer_data only — leaves a proxied socket
with no answer, and nil is that answer: the caller must not treat the
proxy as the visitor.
Extracts the visitor's IP address from a Plug.Conn.
Proxy-aware: delegates to client_address/1, which reads x-forwarded-for
/ x-real-ip when conn.remote_ip is a trusted proxy (by default a
loopback or private address — see "Trusted proxies" above), and takes any
other conn.remote_ip as-is.
Parameters
conn: Plug.Conn struct
Returns
- IP address string or "unknown" (never nil — callers that need to tell
"known" apart from "unknown" should call
client_address/1directly)
Extracts the visitor's IP address from a Phoenix.LiveView socket.
Proxy-aware: delegates to client_address_from_socket/1, which reads the
connect info's :x_headers when the socket's :peer_data is a trusted
proxy (by default a loopback or private address — see "Trusted proxies"
above), and takes any other :peer_data address as-is.
Parameters
socket: Phoenix.LiveView.Socket struct
Returns
- IP address string or "unknown" (never nil — callers that need to tell
"known" apart from "unknown" should call
client_address_from_socket/1directly). "unknown" also covers a host endpoint that never declared:peer_datain the socket'sconnect_info— see that function's docs.
Extracts IP address from peer_data map.
Handles both IPv4 (4-tuple) and IPv6 (8-tuple) addresses. Returns "unknown" for nil, invalid, or missing data.
Parameters
peer_data: Map with:addresskey containing IP tuple, or nil
Returns
- IPv4 as "a.b.c.d" string (an IPv4-mapped
::ffff:a.b.c.dincluded) - IPv6 in its standard compressed hex form ("2001:db8::1")
- "unknown" for invalid or missing data
@spec local_address?(String.t() | :inet.ip_address() | nil) :: boolean()
Whether address (a string or an :inet tuple) is loopback or private —
where a reverse proxy on the same box or network connects from, the
addresses client_address/1 looks past. Anything that does not parse is
not.
This is the default rule client_address/1 applies when :trusted_proxies
is not set; once that option is set, trust follows the list instead (see
trusted_proxy?/1).
iex> PhoenixKit.Utils.IpAddress.local_address?("172.18.0.8")
true
iex> PhoenixKit.Utils.IpAddress.local_address?("203.0.113.7")
false
The network an address belongs to — what to count or compare visitors by.
An IPv4 address is its own network. An IPv6 address is its /64: that is
the block one household, phone or server is handed, and every address in
it is theirs to use — operating systems rotate a temporary address inside
it daily, and anyone can pick a fresh one per request. Keyed on the full
address, a rate limit is one new address away from empty and a session
"changes IP" every day.
Exceptions, where a /64 would put many hosts in one bucket:
- IPv4-mapped (
::ffff:a.b.c.d) and well-known NAT64 (64:ff9b::/96) unmap to the embedded IPv4, as does deprecated IPv4-compatible (::a.b.c.d) - loopback (
::1), unspecified (::), and link-local (fe80::/10) are the address itself
Anything that does not parse (including "unknown") comes back unchanged.
iex> PhoenixKit.Utils.IpAddress.network("2a0d:3344:6a:c310:88f8:482c:e41a:9ef5")
"2a0d:3344:6a:c310::/64"
iex> PhoenixKit.Utils.IpAddress.network("203.0.113.7")
"203.0.113.7"
iex> PhoenixKit.Utils.IpAddress.network("::ffff:203.0.113.7")
"203.0.113.7"
iex> PhoenixKit.Utils.IpAddress.network("unknown")
"unknown"
@spec trusted_proxy?(String.t() | :inet.ip_address() | nil) :: boolean()
Whether address (a string or an :inet tuple) is a trusted proxy: a peer
whose forwarded headers may be believed. The rule client_address/1
applies, for callers that read another forwarded header
(x-forwarded-proto) or decide something by the peer alone. With
config :phoenix_kit, :trusted_proxies unset, any loopback or private
address (local_address?/1); with it set, only an address inside the
list; with an invalid value, nobody (and the one warning per VM about the
invalid value is logged here as well). Anything that does not parse is not.
iex> PhoenixKit.Utils.IpAddress.trusted_proxy?("203.0.113.7")
false