fix(plugs): normalize IPv4-mapped IPv6 peers so cf-connecting-ip wins

Bandit's dual-stack listener delivers IPv4 connections to the app as
IPv4-mapped IPv6 tuples (`::ffff:a.b.c.d`). The trust check compared
that 128-bit form against our IPv4 trusted_proxies CIDRs and never
matched, so cf-connecting-ip was ignored on every cloudflared-relayed
request — every visitor logged as the cloudflared sidecar pod IP
(rendered as `0:0:0:0:0:65535:2804:101` etc.).

Collapses the mapped tuple to plain IPv4 before the trust check and
records it that way on the conn, so logs and session storage see the
real client IP that Cloudflare set in cf-connecting-ip.
This commit is contained in:
Graham McIntire 2026-04-25 09:54:19 -05:00
parent b525a4c34e
commit 938af4d6f2
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 66 additions and 0 deletions

View file

@ -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

View file

@ -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 =