prop/test/microwaveprop_web/plugs/remote_ip_test.exs
Graham McIntire ef54125aa6
fix(security): only trust forwarded-ip headers from known proxies
The remote_ip plug previously trusted Cf-Connecting-Ip and X-Forwarded-For
on every request, so any client reaching a pod directly (cluster-internal,
kubectl port-forward, misconfigured Service) could spoof conn.remote_ip,
which flows into session storage and logs.

Now only honour those headers when the immediate TCP peer sits inside a
configured CIDR list. Default list covers loopback, RFC1918, CGNAT,
link-local, and IPv6 ULA — matches the k8s pod/service network.
Override via config :microwaveprop, :trusted_proxies, or per-environment
via the TRUSTED_PROXY_CIDRS env var in runtime.exs.

Also refresh the stale "nginx/dokku proxy" moduledoc — deployment has
been k8s + Cloudflare for a while.
2026-04-21 13:19:52 -05:00

220 lines
5.8 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 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