Add trusted proxy IP enforcement tests

This commit is contained in:
2026-03-16 19:09:27 +01:00
parent fd17026c32
commit 5d4d181d00
3 changed files with 278 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
defmodule Parrhesia.Web.RemoteIp do
@moduledoc false
import Bitwise
@spec init(term()) :: term()
def init(opts), do: opts
@spec call(Plug.Conn.t(), term()) :: Plug.Conn.t()
def call(conn, _opts) do
if trusted_proxy?(conn.remote_ip) do
case forwarded_ip(conn) do
nil -> conn
forwarded_ip -> %{conn | remote_ip: forwarded_ip}
end
else
conn
end
end
defp forwarded_ip(conn) do
conn
|> x_forwarded_for_ip()
|> fallback_forwarded_ip(conn)
|> fallback_real_ip(conn)
end
defp x_forwarded_for_ip(conn) do
conn
|> Plug.Conn.get_req_header("x-forwarded-for")
|> List.first()
|> parse_x_forwarded_for()
end
defp fallback_forwarded_ip(nil, conn) do
conn
|> Plug.Conn.get_req_header("forwarded")
|> List.first()
|> parse_forwarded_header()
end
defp fallback_forwarded_ip(ip, _conn), do: ip
defp fallback_real_ip(nil, conn) do
conn
|> Plug.Conn.get_req_header("x-real-ip")
|> List.first()
|> parse_ip_string()
end
defp fallback_real_ip(ip, _conn), do: ip
defp trusted_proxy?(remote_ip) do
Enum.any?(trusted_proxies(), &ip_in_cidr?(remote_ip, &1))
end
defp trusted_proxies do
:parrhesia
|> Application.get_env(:trusted_proxies, [])
|> Enum.filter(&is_binary/1)
end
defp parse_x_forwarded_for(value) when is_binary(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.find_value(&parse_ip_string/1)
end
defp parse_x_forwarded_for(_value), do: nil
defp parse_forwarded_header(value) when is_binary(value) do
value
|> String.split(",")
|> Enum.find_value(fn part ->
part
|> String.split(";")
|> Enum.find_value(&forwarded_for_segment/1)
end)
end
defp parse_forwarded_header(_value), do: nil
defp forwarded_for_segment(segment) do
case String.split(segment, "=", parts: 2) do
[key, ip] ->
if String.downcase(String.trim(key)) == "for" do
ip
|> String.trim()
|> String.trim("\"")
|> String.trim_leading("[")
|> String.trim_trailing("]")
|> parse_ip_string()
end
_other ->
nil
end
end
defp parse_ip_string(value) when is_binary(value) do
value
|> String.trim()
|> String.split(":", parts: 2)
|> List.first()
|> then(fn ip ->
case :inet.parse_address(String.to_charlist(ip)) do
{:ok, parsed_ip} -> parsed_ip
_other -> nil
end
end)
end
defp parse_ip_string(_value), do: nil
defp ip_in_cidr?(ip, cidr) do
with {network, prefix_len} <- parse_cidr(cidr),
{:ok, ip_size, ip_value} <- ip_to_int(ip),
{:ok, network_size, network_value} <- ip_to_int(network),
true <- ip_size == network_size,
true <- prefix_len >= 0,
true <- prefix_len <= ip_size do
mask = network_mask(ip_size, prefix_len)
(ip_value &&& mask) == (network_value &&& mask)
else
_other -> false
end
end
defp parse_cidr(cidr) when is_binary(cidr) do
case String.split(cidr, "/", parts: 2) do
[address, prefix_str] ->
with {prefix_len, ""} <- Integer.parse(prefix_str),
{:ok, ip} <- :inet.parse_address(String.to_charlist(address)) do
{ip, prefix_len}
else
_other -> :error
end
[address] ->
case :inet.parse_address(String.to_charlist(address)) do
{:ok, {_, _, _, _} = ip} -> {ip, 32}
{:ok, {_, _, _, _, _, _, _, _} = ip} -> {ip, 128}
_other -> :error
end
_other ->
:error
end
end
defp parse_cidr(_cidr), do: :error
defp ip_to_int({a, b, c, d}) do
{:ok, 32, (a <<< 24) + (b <<< 16) + (c <<< 8) + d}
end
defp ip_to_int({a, b, c, d, e, f, g, h}) do
{:ok, 128,
(a <<< 112) + (b <<< 96) + (c <<< 80) + (d <<< 64) + (e <<< 48) + (f <<< 32) + (g <<< 16) +
h}
end
defp ip_to_int(_ip), do: :error
defp network_mask(_size, 0), do: 0
defp network_mask(size, prefix_len) do
all_ones = (1 <<< size) - 1
all_ones <<< (size - prefix_len)
end
end

View File

@@ -15,6 +15,7 @@ defmodule Parrhesia.Web.Router do
json_decoder: JSON
)
plug(Parrhesia.Web.RemoteIp)
plug(:match)
plug(:dispatch)

View File

@@ -0,0 +1,105 @@
defmodule Parrhesia.Web.ProxyIpE2ETest do
use ExUnit.Case, async: false
alias __MODULE__.TestClient
alias Ecto.Adapters.SQL.Sandbox
alias Parrhesia.Repo
setup_all do
{:ok, _apps} = Application.ensure_all_started(:websockex)
:ok
end
setup do
:ok = Sandbox.checkout(Repo)
Sandbox.mode(Repo, {:shared, self()})
previous_trusted_proxies = Application.get_env(:parrhesia, :trusted_proxies, [])
on_exit(fn ->
Application.put_env(:parrhesia, :trusted_proxies, previous_trusted_proxies)
Sandbox.mode(Repo, :manual)
end)
{:ok, port: free_port()}
end
test "websocket relay blocks a forwarded client IP from a trusted proxy", %{port: port} do
Application.put_env(:parrhesia, :trusted_proxies, ["127.0.0.1/32"])
assert :ok = Parrhesia.Storage.moderation().block_ip(%{}, "203.0.113.10")
start_supervised!({Bandit, plug: Parrhesia.Web.Router, ip: {127, 0, 0, 1}, port: port})
wait_for_server(port)
assert {:error, %WebSockex.RequestError{code: 403, message: "Forbidden"}} =
TestClient.start_link(relay_url(port), self(),
extra_headers: [{"x-forwarded-for", "203.0.113.10"}]
)
end
test "websocket relay ignores forwarded client IPs from untrusted proxies", %{port: port} do
Application.put_env(:parrhesia, :trusted_proxies, [])
assert :ok = Parrhesia.Storage.moderation().block_ip(%{}, "203.0.113.10")
start_supervised!({Bandit, plug: Parrhesia.Web.Router, ip: {127, 0, 0, 1}, port: port})
wait_for_server(port)
assert {:ok, client} =
TestClient.start_link(relay_url(port), self(),
extra_headers: [{"x-forwarded-for", "203.0.113.10"}]
)
assert_receive :connected
Process.exit(client, :normal)
end
defp wait_for_server(port) do
health_url = "http://127.0.0.1:#{port}/health"
1..50
|> Enum.reduce_while(:error, fn _attempt, _acc ->
case Req.get(health_url, receive_timeout: 200, connect_options: [timeout: 200]) do
{:ok, %{status: 200, body: "ok"}} ->
{:halt, :ok}
_other ->
Process.sleep(50)
{:cont, :error}
end
end)
|> case do
:ok -> :ok
:error -> flunk("server was not ready at #{health_url}")
end
end
defp relay_url(port), do: "ws://127.0.0.1:#{port}/relay"
defp free_port do
{:ok, socket} = :gen_tcp.listen(0, [:binary, active: false, packet: :raw, reuseaddr: true])
{:ok, port} = :inet.port(socket)
:ok = :gen_tcp.close(socket)
port
end
defmodule TestClient do
use WebSockex
def start_link(url, parent, opts \\ []) do
WebSockex.start_link(url, __MODULE__, parent, opts)
end
@impl true
def handle_connect(_conn, parent) do
send(parent, :connected)
{:ok, parent}
end
@impl true
def handle_disconnect(_disconnect_map, parent) do
{:ok, parent}
end
end
end