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(ip) when tuple_size(ip) in [4, 8] do case :inet.ntoa(ip) do {:error, _} -> "unknown" charlist -> List.to_string(charlist) end end defp format_ip(_), do: "unknown" end