diff --git a/lib/aprsme_web/endpoint.ex b/lib/aprsme_web/endpoint.ex index 62e1148..d863587 100644 --- a/lib/aprsme_web/endpoint.ex +++ b/lib/aprsme_web/endpoint.ex @@ -48,6 +48,7 @@ defmodule AprsmeWeb.Endpoint do param_key: "request_logger", cookie_key: "request_logger" + plug AprsmeWeb.Plugs.RemoteIp plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint], log: {AprsmeWeb.Plugs.LogFilter, :log_level, []} diff --git a/lib/aprsme_web/plugs/remote_ip.ex b/lib/aprsme_web/plugs/remote_ip.ex new file mode 100644 index 0000000..252bf67 --- /dev/null +++ b/lib/aprsme_web/plugs/remote_ip.ex @@ -0,0 +1,57 @@ +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) + """ + @behaviour Plug + + import Plug.Conn + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + case get_client_ip(conn) do + {:ok, ip} -> %{conn | remote_ip: ip} + :error -> conn + 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 diff --git a/test/aprsme_web/plugs/remote_ip_test.exs b/test/aprsme_web/plugs/remote_ip_test.exs new file mode 100644 index 0000000..896e40b --- /dev/null +++ b/test/aprsme_web/plugs/remote_ip_test.exs @@ -0,0 +1,81 @@ +defmodule AprsmeWeb.Plugs.RemoteIpTest do + use AprsmeWeb.ConnCase + + alias AprsmeWeb.Plugs.RemoteIp + + describe "call/2" do + test "sets remote_ip from CF-Connecting-IP header", %{conn: conn} do + conn = + conn + |> put_req_header("cf-connecting-ip", "203.0.113.50") + |> RemoteIp.call([]) + + assert conn.remote_ip == {203, 0, 113, 50} + end + + test "sets remote_ip from X-Forwarded-For when CF header missing", %{conn: conn} do + conn = + conn + |> put_req_header("x-forwarded-for", "198.51.100.25, 10.0.0.1") + |> RemoteIp.call([]) + + assert conn.remote_ip == {198, 51, 100, 25} + end + + test "prefers CF-Connecting-IP over X-Forwarded-For", %{conn: conn} do + conn = + conn + |> put_req_header("cf-connecting-ip", "203.0.113.50") + |> put_req_header("x-forwarded-for", "198.51.100.25") + |> RemoteIp.call([]) + + assert conn.remote_ip == {203, 0, 113, 50} + end + + test "handles IPv6 addresses", %{conn: conn} do + conn = + conn + |> put_req_header("cf-connecting-ip", "2001:db8::1") + |> RemoteIp.call([]) + + assert conn.remote_ip == {8193, 3512, 0, 0, 0, 0, 0, 1} + end + + test "leaves remote_ip unchanged when no proxy headers present", %{conn: conn} do + original_ip = conn.remote_ip + + conn = RemoteIp.call(conn, []) + + assert conn.remote_ip == original_ip + end + + test "leaves remote_ip unchanged when header contains invalid IP", %{conn: conn} do + original_ip = conn.remote_ip + + conn = + conn + |> put_req_header("cf-connecting-ip", "not-an-ip") + |> RemoteIp.call([]) + + assert conn.remote_ip == original_ip + end + + test "trims whitespace from IP addresses", %{conn: conn} do + conn = + conn + |> put_req_header("cf-connecting-ip", " 203.0.113.50 ") + |> RemoteIp.call([]) + + assert conn.remote_ip == {203, 0, 113, 50} + end + + test "takes first IP from X-Forwarded-For chain", %{conn: conn} do + conn = + conn + |> put_req_header("x-forwarded-for", "203.0.113.50, 10.0.0.1, 172.16.0.1") + |> RemoteIp.call([]) + + assert conn.remote_ip == {203, 0, 113, 50} + end + end +end