From 5d4d181d006d1e16d57a8c36888cee6c31c3e8c7 Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Mon, 16 Mar 2026 19:09:27 +0100 Subject: [PATCH] Add trusted proxy IP enforcement tests --- lib/parrhesia/web/remote_ip.ex | 172 +++++++++++++++++++++++ lib/parrhesia/web/router.ex | 1 + test/parrhesia/web/proxy_ip_e2e_test.exs | 105 ++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 lib/parrhesia/web/remote_ip.ex create mode 100644 test/parrhesia/web/proxy_ip_e2e_test.exs diff --git a/lib/parrhesia/web/remote_ip.ex b/lib/parrhesia/web/remote_ip.ex new file mode 100644 index 0000000..13e999e --- /dev/null +++ b/lib/parrhesia/web/remote_ip.ex @@ -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 diff --git a/lib/parrhesia/web/router.ex b/lib/parrhesia/web/router.ex index b7775b3..58834a3 100644 --- a/lib/parrhesia/web/router.ex +++ b/lib/parrhesia/web/router.ex @@ -15,6 +15,7 @@ defmodule Parrhesia.Web.Router do json_decoder: JSON ) + plug(Parrhesia.Web.RemoteIp) plug(:match) plug(:dispatch) diff --git a/test/parrhesia/web/proxy_ip_e2e_test.exs b/test/parrhesia/web/proxy_ip_e2e_test.exs new file mode 100644 index 0000000..ef490e7 --- /dev/null +++ b/test/parrhesia/web/proxy_ip_e2e_test.exs @@ -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