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.
179 lines
5.4 KiB
Elixir
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
|