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.
272 lines
7.5 KiB
Elixir
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
|