Add trusted proxy IP enforcement tests
This commit is contained in:
172
lib/parrhesia/web/remote_ip.ex
Normal file
172
lib/parrhesia/web/remote_ip.ex
Normal 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
|
||||
@@ -15,6 +15,7 @@ defmodule Parrhesia.Web.Router do
|
||||
json_decoder: JSON
|
||||
)
|
||||
|
||||
plug(Parrhesia.Web.RemoteIp)
|
||||
plug(:match)
|
||||
plug(:dispatch)
|
||||
|
||||
|
||||
105
test/parrhesia/web/proxy_ip_e2e_test.exs
Normal file
105
test/parrhesia/web/proxy_ip_e2e_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user