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
|
json_decoder: JSON
|
||||||
)
|
)
|
||||||
|
|
||||||
|
plug(Parrhesia.Web.RemoteIp)
|
||||||
plug(:match)
|
plug(:match)
|
||||||
plug(:dispatch)
|
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