diff --git a/lib/microwaveprop_web/plugs/remote_ip.ex b/lib/microwaveprop_web/plugs/remote_ip.ex index 3d4a24f4..cdd1ca5a 100644 --- a/lib/microwaveprop_web/plugs/remote_ip.ex +++ b/lib/microwaveprop_web/plugs/remote_ip.ex @@ -60,6 +60,12 @@ defmodule MicrowavepropWeb.Plugs.RemoteIp do @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 @@ -79,6 +85,14 @@ defmodule MicrowavepropWeb.Plugs.RemoteIp do 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 diff --git a/test/microwaveprop_web/plugs/remote_ip_test.exs b/test/microwaveprop_web/plugs/remote_ip_test.exs index 6d1f016c..c6e54227 100644 --- a/test/microwaveprop_web/plugs/remote_ip_test.exs +++ b/test/microwaveprop_web/plugs/remote_ip_test.exs @@ -185,6 +185,58 @@ defmodule MicrowavepropWeb.Plugs.RemoteIpTest do 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 =