defmodule AprsmeWeb.Plugs.RemoteIp do @moduledoc """ Plug that extracts the real client IP from Cloudflare or proxy headers and sets `conn.remote_ip` so downstream logging shows the actual client address. Header priority: 1. `CF-Connecting-IP` (Cloudflare) 2. `X-Forwarded-For` (first IP in the chain) ## Trust model Forwarded headers are only respected when the **TCP peer IP** (the actual connecting IP, before any header rewriting) is within a configured list of trusted proxy CIDRs. If the connecting peer is not trusted, the forwarded headers are ignored and the TCP peer IP is used as-is. Configure via Application environment or plug options: config :aprsme, AprsmeWeb.Plugs.RemoteIp, trusted_proxies: ["10.0.0.0/8", "172.16.0.0/12"] Or inline in the endpoint pipeline: plug AprsmeWeb.Plugs.RemoteIp, trusted_proxies: ["103.21.244.0/22"] When no trusted proxies are configured, **no peer is trusted** and all forwarded headers are ignored — the safest default. """ @behaviour Plug import Plug.Conn require Logger @default_trusted_proxies [] @impl true def init(opts) do trusted_proxies = Keyword.get(opts, :trusted_proxies) || Application.get_env(:aprsme, __MODULE__, [])[:trusted_proxies] || @default_trusted_proxies cidrs = trusted_proxies |> Enum.map(&InetCidr.parse_cidr/1) |> Enum.filter(fn {:ok, _cidr} -> true {:error, reason} -> Logger.warning("Invalid CIDR in RemoteIp trusted_proxies: #{inspect(reason)}") false end) |> Enum.map(fn {:ok, cidr} -> cidr end) %{trusted_proxies: cidrs} end @impl true def call(conn, %{trusted_proxies: cidrs}) do peer_ip = conn.remote_ip conn = if trusted_peer?(peer_ip, cidrs) do case get_client_ip(conn) do {:ok, ip} -> %{conn | remote_ip: ip} :error -> conn end else conn end user_agent = case get_req_header(conn, "user-agent") do [ua | _] -> ua [] -> "-" end Logger.metadata( remote_ip: conn.remote_ip |> :inet.ntoa() |> to_string(), user_agent: user_agent ) conn end defp trusted_peer?(peer_ip, cidrs) do Enum.any?(cidrs, fn cidr -> InetCidr.contains?(cidr, peer_ip) end) end defp get_client_ip(conn) do with :error <- parse_cf_header(conn) do parse_forwarded_for(conn) end end defp parse_cf_header(conn) do case get_req_header(conn, "cf-connecting-ip") do [ip_str | _] -> parse_ip(ip_str) [] -> :error end end defp parse_forwarded_for(conn) do case get_req_header(conn, "x-forwarded-for") do [value | _] -> value |> String.split(",") |> List.first() |> parse_ip() [] -> :error end end defp parse_ip(ip_str) do case ip_str |> String.trim() |> String.to_charlist() |> :inet.parse_address() do {:ok, ip} -> {:ok, ip} {:error, _} -> :error end end end