PhoenixKit.Utils.IpAddress (phoenix_kit v2.64.1)

Copy Markdown View Source

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

client_address(conn)

@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"

client_address_from_socket(socket)

@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.

extract_from_conn(conn)

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/1 directly)

extract_from_socket(socket)

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/1 directly). "unknown" also covers a host endpoint that never declared :peer_data in the socket's connect_info — see that function's docs.

extract_ip_address(arg1)

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 :address key containing IP tuple, or nil

Returns

  • IPv4 as "a.b.c.d" string (an IPv4-mapped ::ffff:a.b.c.d included)
  • IPv6 in its standard compressed hex form ("2001:db8::1")
  • "unknown" for invalid or missing data

local_address?(address)

@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

network(address)

@spec network(String.t() | nil) :: String.t() | nil

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"

trusted_proxy?(address)

@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