4.2 KiB · text History 4f41acc
defmodule GitGudWeb.Plugs.ClientIp do
  @moduledoc """
  Sets `conn.remote_ip` to the client's address when the request came
  through a reverse proxy we trust (Traefik, in the deployment), so
  rate limits and logs see the client rather than the proxy.

  The address is read from `X-Forwarded-For` right to left: each hop a
  trusted proxy appended is skipped, and the first address that isn't a
  trusted proxy is the client. Entries left of that were written by the
  client itself and are ignored, so the header can't be used to pose as
  someone else. A request whose peer isn't a trusted proxy keeps its
  peer address.

  Trusted proxies default to loopback and the private ranges a cluster
  runs on (10/8, 172.16/12, 192.168/16, fc00::/7). Override with:

      config :git_gud, GitGudWeb.Plugs.ClientIp, trusted_proxies: ["10.42.0.0/16"]

  (`GITGUD_TRUSTED_PROXIES`, comma-separated, in the release.)
  """

  @behaviour Plug

  import Bitwise

  @default_trusted [
    "127.0.0.0/8",
    "::1/128",
    "10.0.0.0/8",
    "172.16.0.0/12",
    "192.168.0.0/16",
    "fc00::/7"
  ]

  @impl true
  def init(opts), do: opts

  @impl true
  def call(%Plug.Conn{remote_ip: peer} = conn, _opts) do
    %{conn | remote_ip: client_ip(peer, Plug.Conn.get_req_header(conn, "x-forwarded-for"))}
  end

  @doc """
  The client's address for a LiveView, from the socket's connect info
  (`:peer_data` and `:x_headers`, both enabled on the `/live` socket).
  Call it from `mount/3`; nil when the connect info isn't there.
  """
  def from_socket(socket) do
    case connect_info(socket, :peer_data) do
      %{address: peer} ->
        forwarded =
          for {"x-forwarded-for", v} <- connect_info(socket, :x_headers) || [], do: v

        client_ip(peer, forwarded)

      _ ->
        nil
    end
  end

  @doc """
  `Phoenix.LiveView.get_connect_info/2`, or nil where there's none to
  read (a socket built by hand, as in `on_mount` unit tests) instead of
  a raise.
  """
  def connect_info(%Phoenix.LiveView.Socket{private: %{connect_info: info}} = socket, key)
      when not is_nil(info),
      do: Phoenix.LiveView.get_connect_info(socket, key)

  def connect_info(_socket, _key), do: nil

  @doc """
  The client address given the peer and the `X-Forwarded-For` header
  values, per the rules in the moduledoc.
  """
  def client_ip(peer, forwarded) do
    trusted = trusted()

    if trusted?(peer, trusted) do
      forwarded
      |> Enum.flat_map(&String.split(&1, ","))
      |> Enum.map(&parse/1)
      |> Enum.reverse()
      |> Enum.reduce_while(peer, fn
        nil, _acc -> {:halt, peer}
        ip, _acc -> if trusted?(ip, trusted), do: {:cont, ip}, else: {:halt, ip}
      end)
    else
      peer
    end
  end

  defp parse(raw) do
    case raw |> String.trim() |> String.to_charlist() |> :inet.parse_strict_address() do
      {:ok, ip} -> ip
      _ -> nil
    end
  end

  defp trusted do
    :git_gud
    |> Application.get_env(__MODULE__, [])
    |> Keyword.get(:trusted_proxies, @default_trusted)
    |> Enum.flat_map(fn cidr ->
      case parse_cidr(cidr) do
        nil -> []
        range -> [range]
      end
    end)
  end

  defp parse_cidr(cidr) do
    {addr, bits} =
      case String.split(String.trim(cidr), "/", parts: 2) do
        [addr, bits] -> {addr, Integer.parse(bits)}
        [addr] -> {addr, :all}
      end

    case :inet.parse_strict_address(String.to_charlist(addr)) do
      {:ok, ip} ->
        size = tuple_size(ip) * if(tuple_size(ip) == 4, do: 8, else: 16)

        case bits do
          :all -> {ip, size}
          {n, ""} when n in 0..size//1 -> {ip, n}
          _ -> nil
        end

      _ ->
        nil
    end
  end

  defp trusted?(ip, ranges), do: Enum.any?(ranges, &in_range?(ip, &1))

  defp in_range?(ip, {net, bits}) when tuple_size(ip) == tuple_size(net) do
    width = if tuple_size(ip) == 4, do: 8, else: 16
    size = tuple_size(ip) * width
    <<a::bitstring-size(^bits), _::bitstring>> = to_bits(ip, width, size)
    <<b::bitstring-size(^bits), _::bitstring>> = to_bits(net, width, size)
    a == b
  end

  defp in_range?(_ip, _range), do: false

  defp to_bits(ip, width, size) do
    int = ip |> Tuple.to_list() |> Enum.reduce(0, fn part, acc -> acc * (1 <<< width) + part end)
    <<int::size(size)>>
  end
end