prop/test/microwaveprop_web/plugs/remote_ip_test.exs
Graham McIntire 10cbd8dd48
fix(remote_ip): log IPv6 in canonical hex via :inet.ntoa
IPv6 segments were being printed in decimal (e.g. 10772:1985:1024:45::1
for 2a14:7c1:400:2d::1), which made request_id logs unreadable.
2026-04-25 18:05:46 -05:00

288 lines
8.1 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 "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