defmodule MicrowavepropWeb.Plugs.RemoteIpTest do use ExUnit.Case, async: true import Plug.Conn import Plug.Test alias MicrowavepropWeb.Plugs.RemoteIp # A trusted peer that looks like a k8s cluster pod or ingress. @trusted_peer {10, 42, 0, 1} # An external peer: must NOT be able to spoof headers. @untrusted_peer {8, 8, 8, 8} defp call_plug(conn, opts \\ []) do RemoteIp.call(conn, RemoteIp.init(opts)) end defp with_peer(conn, ip) do Map.put(conn, :remote_ip, ip) end describe "call/2 with trusted peer" do test "uses cf-connecting-ip when immediate peer is trusted" do conn = :get |> conn("/") |> with_peer(@trusted_peer) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug() assert conn.remote_ip == {1, 2, 3, 4} end test "falls back to x-forwarded-for when no cf-connecting-ip (trusted peer)" do conn = :get |> conn("/") |> with_peer(@trusted_peer) |> put_req_header("x-forwarded-for", "5.6.7.8") |> call_plug() assert conn.remote_ip == {5, 6, 7, 8} end test "prefers cf-connecting-ip over x-forwarded-for (trusted peer)" do conn = :get |> conn("/") |> with_peer(@trusted_peer) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> put_req_header("x-forwarded-for", "5.6.7.8") |> call_plug() assert conn.remote_ip == {1, 2, 3, 4} end test "takes first IP from comma-separated x-forwarded-for (trusted peer)" do conn = :get |> conn("/") |> with_peer(@trusted_peer) |> put_req_header("x-forwarded-for", "10.0.0.1, 10.0.0.2, 10.0.0.3") |> call_plug() assert conn.remote_ip == {10, 0, 0, 1} end test "preserves original remote_ip when no headers present (trusted peer)" do conn = :get |> conn("/") |> with_peer(@trusted_peer) |> call_plug() assert conn.remote_ip == @trusted_peer end test "handles IPv6 header value (trusted peer)" do conn = :get |> conn("/") |> with_peer(@trusted_peer) |> put_req_header("cf-connecting-ip", "2001:db8::1") |> call_plug() assert conn.remote_ip == {8193, 3512, 0, 0, 0, 0, 0, 1} end test "handles loopback peer (IPv4)" do conn = :get |> conn("/") |> with_peer({127, 0, 0, 1}) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug() assert conn.remote_ip == {1, 2, 3, 4} end test "handles loopback peer (IPv6 ::1)" do conn = :get |> conn("/") |> with_peer({0, 0, 0, 0, 0, 0, 0, 1}) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug() assert conn.remote_ip == {1, 2, 3, 4} end test "ignores malformed IP in header (trusted peer)" do conn = :get |> conn("/") |> with_peer(@trusted_peer) |> put_req_header("cf-connecting-ip", "not-an-ip") |> call_plug() assert conn.remote_ip == @trusted_peer end end describe "call/2 with untrusted peer" do test "ignores cf-connecting-ip from untrusted peer (spoof attempt)" do conn = :get |> conn("/") |> with_peer(@untrusted_peer) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug() assert conn.remote_ip == @untrusted_peer end test "ignores x-forwarded-for from untrusted peer (spoof attempt)" do conn = :get |> conn("/") |> with_peer(@untrusted_peer) |> put_req_header("x-forwarded-for", "1.2.3.4, 5.6.7.8") |> call_plug() assert conn.remote_ip == @untrusted_peer end test "ignores both spoofed headers from untrusted peer" do conn = :get |> conn("/") |> with_peer(@untrusted_peer) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> put_req_header("x-forwarded-for", "9.9.9.9") |> call_plug() assert conn.remote_ip == @untrusted_peer end end describe "call/2 with IPv6 peers" do test "trusts IPv6 ULA peer when configured" do # fc00::/7 ULA range — included in default trusted list. peer = {0xFC00, 0, 0, 0, 0, 0, 0, 1} conn = :get |> conn("/") |> with_peer(peer) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug(trusted_proxies: ["fc00::/7"]) assert conn.remote_ip == {1, 2, 3, 4} end test "does not trust a public IPv6 peer when only ULA is trusted" do peer = {0x2001, 0xDB8, 0, 0, 0, 0, 0, 1} conn = :get |> conn("/") |> with_peer(peer) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug(trusted_proxies: ["fc00::/7"]) assert conn.remote_ip == peer end end describe "call/2 with IPv4-mapped IPv6 peers (Bandit dual-stack listener)" do # Bandit serves IPv4 connections on a dual-stack socket as IPv4-mapped # IPv6 tuples, e.g. cloudflared pod 10.244.0.101 → {0,0,0,0,0,0xffff, # 0x0AF4, 0x0065}. Without normalization the trust check sees an IPv6 # peer and never matches the IPv4 trusted_proxies CIDRs, so # cf-connecting-ip stays unread and every request looks like the # cloudflared sidecar. test "treats ::ffff:10.244.0.101 as the IPv4 cluster pod and trusts it" do mapped_peer = {0, 0, 0, 0, 0, 0xFFFF, 0x0AF4, 0x0065} conn = :get |> conn("/") |> with_peer(mapped_peer) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug() assert conn.remote_ip == {1, 2, 3, 4} end test "normalizes a remaining IPv4-mapped peer to plain IPv4 in the conn" do # No forwarded headers — the conn should still walk away with a # plain IPv4 remote_ip rather than the mapped IPv6 form so logs # and downstream code see consistent IPv4 tuples. mapped_peer = {0, 0, 0, 0, 0, 0xFFFF, 0x0AF4, 0x0065} conn = :get |> conn("/") |> with_peer(mapped_peer) |> call_plug() assert conn.remote_ip == {10, 244, 0, 101} end test "an IPv4-mapped public peer is NOT trusted (no header spoofing)" do # ::ffff:8.8.8.8 — public IPv4 reached us directly somehow. mapped_public = {0, 0, 0, 0, 0, 0xFFFF, 0x0808, 0x0808} conn = :get |> conn("/") |> with_peer(mapped_public) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug() # The spoof attempt is rejected; the normalized IPv4 peer is used. assert conn.remote_ip == {8, 8, 8, 8} end end describe "call/2 with custom trusted_proxies option" do test "explicit empty list trusts nothing — even loopback is ignored" do conn = :get |> conn("/") |> with_peer({127, 0, 0, 1}) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug(trusted_proxies: []) assert conn.remote_ip == {127, 0, 0, 1} end test "logs IPv6 peer in canonical hex form, not decimal" do # Regression: a public IPv6 client like 2a14:7c1:400:2d::1 was being # logged as "10772:1985:1024:45:0:0:0:1" because we stringified the # 8-tuple in decimal. Logger metadata should hold the canonical hex. peer = {0x2A14, 0x07C1, 0x0400, 0x002D, 0, 0, 0, 1} :get |> conn("/") |> with_peer(peer) |> call_plug() assert {:remote_ip, ip_str} = List.keyfind(Logger.metadata(), :remote_ip, 0) assert ip_str =~ "2a14" refute ip_str =~ "10772" end test "single-host CIDR (/32) trusts only that exact IP" do conn = :get |> conn("/") |> with_peer({203, 0, 113, 5}) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug(trusted_proxies: ["203.0.113.5/32"]) assert conn.remote_ip == {1, 2, 3, 4} conn2 = :get |> conn("/") |> with_peer({203, 0, 113, 6}) |> put_req_header("cf-connecting-ip", "1.2.3.4") |> call_plug(trusted_proxies: ["203.0.113.5/32"]) assert conn2.remote_ip == {203, 0, 113, 6} end end end