prop/lib/microwaveprop_web/plugs/remote_ip.ex
Graham McIntire 938af4d6f2
fix(plugs): normalize IPv4-mapped IPv6 peers so cf-connecting-ip wins
Bandit's dual-stack listener delivers IPv4 connections to the app as
IPv4-mapped IPv6 tuples (`::ffff:a.b.c.d`). The trust check compared
that 128-bit form against our IPv4 trusted_proxies CIDRs and never
matched, so cf-connecting-ip was ignored on every cloudflared-relayed
request — every visitor logged as the cloudflared sidecar pod IP
(rendered as `0:0:0:0:0:65535:2804:101` etc.).

Collapses the mapped tuple to plain IPv4 before the trust check and
records it that way on the conn, so logs and session storage see the
real client IP that Cloudflare set in cf-connecting-ip.
2026-04-25 09:54:20 -05:00

179 lines
5.4 KiB
Elixir

defmodule MicrowavepropWeb.Plugs.RemoteIp do
@moduledoc """
Extracts the client IP from `Cf-Connecting-Ip` / `X-Forwarded-For` headers,
but only when the immediate peer (`conn.remote_ip`) is a trusted proxy.
Deployment: the app runs as pods in the k8s `prop` namespace behind the
cluster ingress, which itself sits behind Cloudflare. The only hosts that
should ever terminate TCP to a pod are ingress replicas on the cluster
pod network, so we only honour forwarded-IP headers when the raw socket
peer falls inside a configured CIDR list.
Trusted proxies are configured via `config :microwaveprop, :trusted_proxies`
(a list of CIDR strings). The default list covers loopback and the RFC1918
/ ULA ranges used by k8s cluster pod networks. `config/runtime.exs` reads
`TRUSTED_PROXY_CIDRS` (comma-separated) in production for tuning without
a rebuild.
Rationale: without this guard any client that reaches a pod directly
(cluster-internal, `kubectl port-forward`, a misconfigured Service) could
spoof `Cf-Connecting-Ip` to set `conn.remote_ip`, which flows into session
storage and logs.
"""
@behaviour Plug
import Bitwise
require Logger
@ip_headers ["cf-connecting-ip", "x-forwarded-for"]
# RFC1918 + CGNAT + loopback + link-local + IPv6 loopback + ULA.
# Matches typical k8s pod/service CIDRs and the ingress mesh.
@default_trusted_proxies [
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"100.64.0.0/10",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10"
]
@impl true
def init(opts) do
cidrs =
Keyword.get_lazy(opts, :trusted_proxies, fn ->
Application.get_env(:microwaveprop, :trusted_proxies, @default_trusted_proxies)
end)
parsed =
cidrs
|> Enum.map(&parse_cidr/1)
|> Enum.reject(&is_nil/1)
%{trusted_proxies: parsed}
end
@impl true
def call(conn, %{trusted_proxies: trusted_proxies}) do
# Bandit's dual-stack listener serves IPv4 connections as IPv4-mapped
# IPv6 tuples (`::ffff:a.b.c.d`). Normalize those to plain IPv4 so
# the trust check matches our IPv4 CIDRs and downstream logs see
# consistent IPv4 tuples instead of `0:0:0:0:0:65535:...` forms.
conn = %{conn | remote_ip: normalize_ip(conn.remote_ip)}
conn =
if peer_trusted?(conn.remote_ip, trusted_proxies) do
case extract_forwarded_ip(conn) do
nil -> conn
ip -> %{conn | remote_ip: ip}
end
else
conn
end
Logger.metadata(
remote_ip: format_ip(conn.remote_ip),
method: conn.method,
request_path: conn.request_path
)
conn
end
# Collapse an IPv4-mapped IPv6 tuple (`::ffff:a.b.c.d`) to a plain IPv4
# tuple so the rest of the pipeline sees one canonical form.
defp normalize_ip({0, 0, 0, 0, 0, 0xFFFF, hi, lo}) do
{hi >>> 8, hi &&& 0xFF, lo >>> 8, lo &&& 0xFF}
end
defp normalize_ip(ip), do: ip
defp extract_forwarded_ip(conn) do
Enum.find_value(@ip_headers, fn header ->
case Plug.Conn.get_req_header(conn, header) do
[value | _] ->
value
|> String.split(",")
|> hd()
|> String.trim()
|> parse_ip()
_ ->
nil
end
end)
end
defp parse_ip(str) do
case :inet.parse_address(String.to_charlist(str)) do
{:ok, ip} -> ip
_ -> nil
end
end
# Parses "10.0.0.0/8" or "fc00::/7" into {ip_integer, mask_integer, bit_width}.
# Invalid entries are logged and dropped at init-time.
defp parse_cidr(cidr) when is_binary(cidr) do
case String.split(cidr, "/", parts: 2) do
[ip_str, prefix_str] ->
with {prefix, ""} <- Integer.parse(prefix_str),
{:ok, ip} <- :inet.parse_address(String.to_charlist(ip_str)),
bit_width = bit_width_for(ip),
true <- prefix >= 0 and prefix <= bit_width do
mask = cidr_mask(prefix, bit_width)
{ip_to_integer(ip), mask, bit_width}
else
_ ->
Logger.warning("RemoteIp: could not parse CIDR #{inspect(cidr)}")
nil
end
_ ->
Logger.warning("RemoteIp: CIDR missing /prefix: #{inspect(cidr)}")
nil
end
end
defp parse_cidr(_), do: nil
defp bit_width_for(ip) when tuple_size(ip) == 4, do: 32
defp bit_width_for(ip) when tuple_size(ip) == 8, do: 128
# Build a prefix-bit mask: e.g. prefix=24, bit_width=32 -> 0xFFFFFF00.
defp cidr_mask(0, _bit_width), do: 0
defp cidr_mask(prefix, bit_width) do
host_bits = bit_width - prefix
all_ones = (1 <<< bit_width) - 1
all_ones - ((1 <<< host_bits) - 1)
end
defp peer_trusted?(peer_ip, trusted) when tuple_size(peer_ip) in [4, 8] do
peer_width = bit_width_for(peer_ip)
peer_int = ip_to_integer(peer_ip)
Enum.any?(trusted, fn {cidr_int, mask, bit_width} ->
bit_width == peer_width and band(peer_int, mask) == band(cidr_int, mask)
end)
end
defp peer_trusted?(_, _), do: false
defp ip_to_integer({a, b, c, d}) do
(a <<< 24) + (b <<< 16) + (c <<< 8) + d
end
defp ip_to_integer({a, b, c, d, e, f, g, h}) do
(a <<< 112) + (b <<< 96) + (c <<< 80) + (d <<< 64) +
(e <<< 48) + (f <<< 32) + (g <<< 16) + h
end
defp format_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
defp format_ip({a, b, c, d, e, f, g, h}), do: "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
defp format_ip(_), do: "unknown"
end