prop/test/microwaveprop_web/plugs/remote_ip_test.exs
Graham McIntire 938af4d6f2
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.
2026-04-25 09:54:20 -05:00

272 lines
7.5 KiB
Elixir

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