10.7 KiB · text History e737fcd
defmodule GitGud.OutboundUrl do
  @moduledoc """
  The one gate in front of every request the forge makes to a URL a
  user (or a remote server) chose: repository imports and their
  provider APIs, mirror / upstream sync, webhook delivery, and
  ActivityPub actor fetches and inbox deliveries.

  Without it any signed-in user could point the server at itself or its
  network — `http://169.254.169.254/` (cloud metadata), the database,
  an admin port on loopback — and read the answer back through an
  import error, a webhook delivery log, or a mirrored repository.

  A URL passes when

    * its scheme is `http` or `https` (`git` too, for git transports),
    * it has a host and carries no credentials-only trickery,
    * and **every** address the host resolves to is public — not
      loopback, link-local (incl. `169.254.169.254`), RFC 1918,
      CGNAT (`100.64.0.0/10`), `0.0.0.0/8`, unique-local (`fc00::/7`),
      multicast, reserved, or an IPv4-mapped / NAT64 form of one of
      those.

  ## DNS rebinding

  Checking a name and then letting the HTTP client resolve it again
  leaves a window where the second answer differs. So callers don't
  re-resolve: `attach/2` makes Req connect to the address we checked
  (keeping the real name for `Host`, SNI and certificate checks), and
  `git_config/2` hands git the same pin through `http.curloptResolve`.
  Redirects are refused outright on both — a redirect would be a fresh,
  unchecked URL. The `git://` transport cannot be pinned (curl isn't
  involved), so for it the rebinding window remains; it is checked
  like the others.

  ## Operator override

  Self-hosters who import from, or deliver webhooks to, their own LAN
  can lift the private-address rule per purpose:

    * `GITGUD_IMPORT_ALLOW_PRIVATE=1` — imports, mirrors, upstream sync
    * `GITGUD_WEBHOOK_ALLOW_PRIVATE=1` — webhook delivery
    * `GITGUD_FEDERATION_ALLOW_PRIVATE=1` — ActivityPub fetch/delivery

  (`config :git_gud, GitGud.OutboundUrl, allow_private: [:import, ...]`.)
  The scheme rule always applies.
  """

  import Bitwise

  @type purpose :: :import | :webhook | :federation
  @type reason ::
          :invalid_url
          | :unsupported_scheme
          | :nxdomain
          | {:blocked_address, String.t()}

  @http_schemes ~w(http https)
  @git_schemes ~w(http https git)

  @doc """
  Checks `url` for `purpose`. Returns `{:ok, target}` with the parsed
  URI and the addresses it resolved to (all of them public, unless the
  operator allowed private ones), or `{:error, reason}`.

  Options: `:schemes` — the allowed schemes (default http/https).
  """
  @spec check(String.t(), purpose, keyword) ::
          {:ok, %{uri: URI.t(), host: String.t(), port: integer, addresses: [tuple]}}
          | {:error, reason}
  def check(url, purpose, opts \\ [])

  def check(url, purpose, opts) when is_binary(url) do
    schemes = Keyword.get(opts, :schemes, @http_schemes)

    with {:ok, %URI{scheme: scheme} = uri} <- parse(url),
         :ok <- check_scheme(scheme, schemes),
         {:ok, host} <- host(uri),
         {:ok, addresses} <- resolve(host),
         :ok <- check_addresses(addresses, purpose) do
      {:ok, %{uri: uri, host: host, port: uri.port || default_port(scheme), addresses: addresses}}
    end
  end

  def check(_url, _purpose, _opts), do: {:error, :invalid_url}

  @doc "`check/3` as a boolean."
  def allowed?(url, purpose, opts \\ []), do: match?({:ok, _}, check(url, purpose, opts))

  @doc "A sentence for an error flash or a delivery log."
  def describe(:invalid_url), do: "That isn't a valid URL."
  def describe(:unsupported_scheme), do: "Only http(s) URLs are allowed."
  def describe(:nxdomain), do: "That host name doesn't resolve."

  def describe({:blocked_address, ip}),
    do: "That host resolves to a private or reserved address (#{ip}), which isn't allowed."

  def describe(other), do: inspect(other)

  # ── Req ──────────────────────────────────────────────────────────────

  @doc """
  Guards a `Req.Request` for `purpose`: redirects off, and a request
  step that checks the URL just before it is sent and pins the
  connection to the checked address. A refused URL never leaves the
  node; the request returns `{:error, %GitGud.OutboundUrl.Blocked{}}`.
  """
  def attach(%Req.Request{} = req, purpose) do
    req
    |> Req.merge(redirect: false)
    |> Req.Request.append_request_steps(outbound_url_guard: &guard_step(&1, purpose))
  end

  @doc "`Req.new(opts) |> attach(purpose)`."
  def req(opts, purpose), do: opts |> Req.new() |> attach(purpose)

  defp guard_step(%Req.Request{url: %URI{} = uri} = request, purpose) do
    case check(URI.to_string(uri), purpose) do
      {:ok, target} ->
        pin(request, target)

      {:error, reason} ->
        {request, GitGud.OutboundUrl.Blocked.exception(reason: reason)}
    end
  end

  # Under a test plug there's no socket to pin. Otherwise connect to the
  # address we checked; Mint's `:hostname` keeps Host/SNI/cert on the
  # real name.
  defp pin(%Req.Request{} = request, target) do
    if Req.Request.get_option(request, :plug) do
      request
    else
      [ip | _] = target.addresses
      connect = Req.Request.get_option(request, :connect_options, [])

      request
      |> Map.put(:url, %{request.url | host: ip_string(ip)})
      |> Req.Request.put_option(:connect_options, Keyword.put(connect, :hostname, target.host))
    end
  end

  # ── git ──────────────────────────────────────────────────────────────

  @doc """
  Checks a git remote for `purpose` and returns the `-c` arguments that
  go before the git subcommand: no redirects, only http(s)/git
  transports, and (for http(s)) the connection pinned to the address we
  checked.

  A local path or `file://` URL is refused — it would let a user clone
  another repository straight off this server's disk. (The test suite
  turns on `allow_local_paths` to use on-disk fixtures as upstreams.)
  """
  @spec git_config(String.t(), purpose) :: {:ok, [String.t()]} | {:error, reason}
  def git_config(url, purpose) when is_binary(url) do
    base = [
      "-c",
      "http.followRedirects=false",
      "-c",
      "protocol.allow=never",
      "-c",
      "protocol.http.allow=always",
      "-c",
      "protocol.https.allow=always",
      "-c",
      "protocol.git.allow=always"
    ]

    if local_path?(url) and config(:allow_local_paths, false) do
      {:ok, ["-c", "protocol.file.allow=always"]}
    else
      with {:ok, target} <- check(url, purpose, schemes: @git_schemes) do
        {:ok, base ++ resolve_pin(target)}
      end
    end
  end

  def git_config(_url, _purpose), do: {:error, :invalid_url}

  defp resolve_pin(%{uri: %URI{scheme: s}, host: host, port: port, addresses: [ip | _]})
       when s in @http_schemes do
    ["-c", "http.curloptResolve=#{host}:#{port}:#{curl_ip(ip)}"]
  end

  defp resolve_pin(_), do: []

  defp local_path?(url) do
    String.starts_with?(url, "/") or String.starts_with?(url, "file:") or
      not String.contains?(url, "://")
  end

  # ── checks ───────────────────────────────────────────────────────────

  defp parse(url) do
    case URI.new(String.trim(url)) do
      {:ok, %URI{} = uri} -> {:ok, uri}
      _ -> {:error, :invalid_url}
    end
  end

  defp host(%URI{host: host}) when is_binary(host) and host != "", do: {:ok, host}
  defp host(_), do: {:error, :invalid_url}

  defp check_scheme(scheme, schemes) when is_binary(scheme) do
    if String.downcase(scheme) in schemes, do: :ok, else: {:error, :unsupported_scheme}
  end

  defp check_scheme(_, _), do: {:error, :unsupported_scheme}

  defp check_addresses(addresses, purpose) do
    if purpose in config(:allow_private, []) do
      :ok
    else
      case Enum.find(addresses, &blocked?/1) do
        nil -> :ok
        ip -> {:error, {:blocked_address, ip_string(ip)}}
      end
    end
  end

  @doc false
  def resolve(host) do
    host = host |> String.trim_leading("[") |> String.trim_trailing("]")

    case :inet.parse_address(String.to_charlist(host)) do
      {:ok, ip} ->
        {:ok, [ip]}

      {:error, _} ->
        case resolver().(host) do
          [] -> {:error, :nxdomain}
          addresses -> {:ok, Enum.uniq(addresses)}
        end
    end
  end

  defp resolver do
    case config(:resolver, nil) do
      fun when is_function(fun, 1) -> fun
      nil -> &system_resolve/1
    end
  end

  defp system_resolve(host) do
    name = String.to_charlist(host)

    for family <- [:inet, :inet6],
        {:ok, addrs} <- [:inet.getaddrs(name, family)],
        addr <- addrs,
        do: addr
  end

  @doc """
  Whether `ip` (an `:inet` tuple) is somewhere a user-chosen URL must
  not reach.
  """
  def blocked?({a, b, _c, _d} = ip) when tuple_size(ip) == 4 do
    a == 0 or a == 10 or a == 127 or a >= 224 or
      (a == 100 and (b &&& 0xC0) == 64) or
      (a == 169 and b == 254) or
      (a == 172 and (b &&& 0xF0) == 16) or
      (a == 192 and b == 168) or
      (a == 192 and b == 0 and elem(ip, 2) in [0, 2]) or
      (a == 198 and b in [18, 19]) or
      (a == 198 and b == 51 and elem(ip, 2) == 100) or
      (a == 203 and b == 0 and elem(ip, 2) == 113)
  end

  def blocked?({a, b, c, d, e, f, g, h} = ip) when tuple_size(ip) == 8 do
    cond do
      # :: and ::1
      {a, b, c, d, e, f, g} == {0, 0, 0, 0, 0, 0, 0} and h in [0, 1] -> true
      # ::ffff:a.b.c.d (mapped) and ::a.b.c.d (compatible)
      {a, b, c, d, e} == {0, 0, 0, 0, 0} and f in [0, 0xFFFF] -> blocked?(v4(g, h))
      # 64:ff9b::/96 NAT64
      {a, b, c, d, e, f} == {0x64, 0xFF9B, 0, 0, 0, 0} -> blocked?(v4(g, h))
      # 2002::/16 6to4 embeds the v4 address in the next 32 bits
      a == 0x2002 -> blocked?(v4(b, c))
      # fc00::/7 unique local, fe80::/10 link-local, fec0::/10 site-local, ff00::/8 multicast
      (a &&& 0xFE00) == 0xFC00 -> true
      (a &&& 0xFFC0) in [0xFE80, 0xFEC0] -> true
      (a &&& 0xFF00) == 0xFF00 -> true
      # 2001:db8::/32 documentation
      a == 0x2001 and b == 0x0DB8 -> true
      true -> false
    end
  end

  def blocked?(_), do: true

  defp v4(hi, lo), do: {hi >>> 8, hi &&& 0xFF, lo >>> 8, lo &&& 0xFF}

  defp ip_string(ip), do: ip |> :inet.ntoa() |> to_string()

  defp curl_ip(ip) when tuple_size(ip) == 8, do: "[" <> ip_string(ip) <> "]"
  defp curl_ip(ip), do: ip_string(ip)

  defp default_port("https"), do: 443
  defp default_port("http"), do: 80
  defp default_port("git"), do: 9418
  defp default_port(_), do: nil

  defp config(key, default) do
    :git_gud |> Application.get_env(__MODULE__, []) |> Keyword.get(key, default)
  end
end