defmodule AprsmeWeb.Plugs.RemoteIpTest do use AprsmeWeb.ConnCase alias AprsmeWeb.Plugs.RemoteIp describe "trust gating" do test "trusted peer (within CIDR) gets forwarded IP from X-Forwarded-For", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: ["10.0.0.0/8"]) conn = %{conn | remote_ip: {10, 0, 0, 1}} |> put_req_header("x-forwarded-for", "203.0.113.50") |> RemoteIp.call(opts) assert conn.remote_ip == {203, 0, 113, 50} end test "untrusted peer (outside CIDR) gets TCP peer IP, ignoring X-Forwarded-For", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: ["10.0.0.0/8"]) conn = %{conn | remote_ip: {1, 2, 3, 4}} |> put_req_header("x-forwarded-for", "203.0.113.50") |> RemoteIp.call(opts) assert conn.remote_ip == {1, 2, 3, 4} end test "untrusted peer ignores CF-Connecting-IP header", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: ["10.0.0.0/8"]) conn = %{conn | remote_ip: {1, 2, 3, 4}} |> put_req_header("cf-connecting-ip", "203.0.113.50") |> RemoteIp.call(opts) assert conn.remote_ip == {1, 2, 3, 4} end test "CF-Connecting-IP respected when peer is trusted", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: ["10.0.0.0/8"]) conn = %{conn | remote_ip: {10, 0, 0, 1}} |> put_req_header("cf-connecting-ip", "203.0.113.50") |> RemoteIp.call(opts) assert conn.remote_ip == {203, 0, 113, 50} end test "trusted IPv6 peer gets IPv6 from header", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: ["2001:db8::/32"]) conn = %{conn | remote_ip: {8193, 3512, 0, 0, 0, 0, 0, 1}} |> put_req_header("cf-connecting-ip", "2001:db8::2") |> RemoteIp.call(opts) assert conn.remote_ip == {8193, 3512, 0, 0, 0, 0, 0, 2} end test "untrusted IPv6 peer keeps its own IP", %{conn: conn} do untrusted_ip = {0, 0, 0, 0, 0, 0, 0, 1} opts = RemoteIp.init(trusted_proxies: ["2001:db8::/32"]) conn = %{conn | remote_ip: untrusted_ip} |> put_req_header("x-forwarded-for", "2001:db8::2") |> RemoteIp.call(opts) assert conn.remote_ip == untrusted_ip end test "malformed header value does not crash on untrusted peer", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: ["10.0.0.0/8"]) conn = %{conn | remote_ip: {1, 2, 3, 4}} |> put_req_header("cf-connecting-ip", "not-an-ip") |> RemoteIp.call(opts) assert conn.remote_ip == {1, 2, 3, 4} end test "malformed header value does not crash on trusted peer", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: ["10.0.0.0/8"]) conn = %{conn | remote_ip: {10, 0, 0, 1}} |> put_req_header("cf-connecting-ip", "not-an-ip") |> RemoteIp.call(opts) assert conn.remote_ip == {10, 0, 0, 1} end test "no trusted proxies configured means no peer is trusted", %{conn: conn} do opts = RemoteIp.init(trusted_proxies: []) conn = %{conn | remote_ip: {10, 0, 0, 1}} |> put_req_header("x-forwarded-for", "203.0.113.50") |> RemoteIp.call(opts) assert conn.remote_ip == {10, 0, 0, 1} end end describe "call/2 with default trusted proxies (local host)" do # build_conn() defaults remote_ip to {127, 0, 0, 1} which is trusted # by the default test config (127.0.0.0/8, ::1/128). test "sets remote_ip from CF-Connecting-IP header", %{conn: conn} do opts = RemoteIp.init([]) conn = conn |> put_req_header("cf-connecting-ip", "203.0.113.50") |> RemoteIp.call(opts) assert conn.remote_ip == {203, 0, 113, 50} end test "sets remote_ip from X-Forwarded-For when CF header missing", %{conn: conn} do opts = RemoteIp.init([]) conn = conn |> put_req_header("x-forwarded-for", "198.51.100.25, 10.0.0.1") |> RemoteIp.call(opts) assert conn.remote_ip == {198, 51, 100, 25} end test "prefers CF-Connecting-IP over X-Forwarded-For", %{conn: conn} do opts = RemoteIp.init([]) conn = conn |> put_req_header("cf-connecting-ip", "203.0.113.50") |> put_req_header("x-forwarded-for", "198.51.100.25") |> RemoteIp.call(opts) assert conn.remote_ip == {203, 0, 113, 50} end test "handles IPv6 addresses", %{conn: conn} do opts = RemoteIp.init([]) conn = conn |> put_req_header("cf-connecting-ip", "2001:db8::1") |> RemoteIp.call(opts) 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 opts = RemoteIp.init([]) conn = RemoteIp.call(conn, opts) 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 opts = RemoteIp.init([]) conn = conn |> put_req_header("cf-connecting-ip", "not-an-ip") |> RemoteIp.call(opts) assert conn.remote_ip == original_ip end test "trims whitespace from IP addresses", %{conn: conn} do opts = RemoteIp.init([]) conn = conn |> put_req_header("cf-connecting-ip", " 203.0.113.50 ") |> RemoteIp.call(opts) assert conn.remote_ip == {203, 0, 113, 50} end test "takes first IP from X-Forwarded-For chain", %{conn: conn} do opts = RemoteIp.init([]) conn = conn |> put_req_header("x-forwarded-for", "203.0.113.50, 10.0.0.1, 172.16.0.1") |> RemoteIp.call(opts) assert conn.remote_ip == {203, 0, 113, 50} end end end