9.1 KiB · text History 49fdeb3
defmodule GitGud.OutboundUrlTest do
  # Touches the global OutboundUrl config.
  use GitGud.DataCase, async: false
  use Oban.Testing, repo: GitGud.Repo

  alias GitGud.Federation.RemoteActors
  alias GitGud.Imports
  alias GitGud.OutboundUrl
  alias GitGud.Repositories
  alias GitGud.Webhooks
  alias GitGud.Webhooks.WebhookDelivery
  alias GitGud.Webhooks.Workers.DeliverWebhook

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  setup do
    prev = Application.get_env(:git_gud, OutboundUrl, [])
    on_exit(fn -> Application.put_env(:git_gud, OutboundUrl, prev) end)
    {:ok, prev: prev}
  end

  defp put_config(prev, kv),
    do: Application.put_env(:git_gud, OutboundUrl, Keyword.merge(prev, kv))

  describe "check/3" do
    test "refuses loopback, link-local, private, CGNAT and reserved addresses" do
      for url <- [
            "http://127.0.0.1/",
            "http://127.1:8080/",
            "http://2130706433/",
            "http://169.254.169.254/latest/meta-data/",
            "http://10.0.0.1/",
            "http://172.16.5.4/",
            "http://192.168.1.1/",
            "http://100.64.0.1/",
            "http://0.0.0.0/",
            "http://[::1]/",
            "http://[::ffff:127.0.0.1]/",
            "http://[::ffff:169.254.169.254]/",
            "http://[fc00::1]/",
            "http://[fd12:3456::1]/",
            "http://[fe80::1]/",
            "http://[64:ff9b::a00:1]/",
            "http://localhost/",
            "https://db.internal.test/",
            # One private answer among public ones is enough to refuse.
            "https://dual.test/"
          ] do
        assert {:error, {:blocked_address, _}} = OutboundUrl.check(url, :import), url
      end
    end

    test "accepts public hosts and addresses" do
      assert {:ok, %{host: "github.com", port: 443}} =
               OutboundUrl.check("https://github.com/acme/x.git", :import)

      assert {:ok, _} = OutboundUrl.check("http://8.8.8.8/", :webhook)
      assert {:ok, _} = OutboundUrl.check("http://[2606:4700::1111]/", :federation)
    end

    test "refuses other schemes and junk" do
      assert {:error, :unsupported_scheme} = OutboundUrl.check("file:///etc/passwd", :import)
      assert {:error, :unsupported_scheme} = OutboundUrl.check("ftp://example.com/", :import)
      assert {:error, :unsupported_scheme} = OutboundUrl.check("gopher://example.com/", :import)
      assert {:error, _} = OutboundUrl.check("/var/lib/repos/x.git", :import)
      assert {:error, :invalid_url} = OutboundUrl.check(nil, :import)
      assert {:error, :nxdomain} = OutboundUrl.check("https://nowhere.test/", :import)
    end

    test "the operator override lifts the private rule for one purpose only", %{prev: prev} do
      put_config(prev, allow_private: [:import])

      assert {:ok, _} = OutboundUrl.check("http://192.168.1.10/", :import)
      assert {:error, {:blocked_address, _}} = OutboundUrl.check("http://192.168.1.10/", :webhook)
      # The scheme rule still applies.
      assert {:error, :unsupported_scheme} = OutboundUrl.check("file:///etc/passwd", :import)
    end
  end

  describe "git_config/2" do
    test "pins http(s) remotes to the checked address and turns redirects off" do
      assert {:ok, args} = OutboundUrl.git_config("https://example.com/a/b.git", :import)
      assert "http.followRedirects=false" in args
      assert "protocol.allow=never" in args
      assert "http.curloptResolve=example.com:443:93.184.216.34" in args
    end

    test "refuses on-disk paths and file:// outside of the test fixture switch", %{prev: prev} do
      put_config(prev, allow_local_paths: false)

      assert {:error, _} = OutboundUrl.git_config("/srv/repos/someone/private.git", :import)
      assert {:error, _} = OutboundUrl.git_config("file:///srv/repos/x.git", :import)
      assert {:error, _} = OutboundUrl.git_config("--upload-pack=touch /tmp/x", :import)
      assert {:error, _} = OutboundUrl.git_config("ext::sh -c whoami", :import)

      assert {:error, {:blocked_address, _}} =
               OutboundUrl.git_config("http://10.0.0.1/x.git", :import)
    end
  end

  describe "attach/2" do
    test "a refused URL never reaches the adapter" do
      test_pid = self()

      req =
        Req.new(url: "http://169.254.169.254/latest/meta-data/", retry: false)
        |> Req.merge(
          plug: fn conn ->
            send(test_pid, :reached)
            Plug.Conn.resp(conn, 200, "")
          end
        )
        |> OutboundUrl.attach(:import)

      assert {:error, %OutboundUrl.Blocked{reason: {:blocked_address, "169.254.169.254"}}} =
               Req.get(req)

      refute_received :reached
    end

    test "allowed URLs go through, and redirects are not followed" do
      req =
        Req.new(url: "https://example.com/start", retry: false)
        |> Req.merge(
          plug: fn conn ->
            conn
            |> Plug.Conn.put_resp_header("location", "http://169.254.169.254/")
            |> Plug.Conn.resp(302, "")
          end
        )
        |> OutboundUrl.attach(:import)

      assert {:ok, %Req.Response{status: 302}} = Req.get(req)
    end
  end

  describe "call sites" do
    test "a GitHub discovery against a private API root is refused before any request" do
      test_pid = self()

      Application.put_env(:git_gud, :imports_req_options,
        plug: fn conn ->
          send(test_pid, :reached)
          Plug.Conn.resp(conn, 200, "[]")
        end
      )

      on_exit(fn -> Application.delete_env(:git_gud, :imports_req_options) end)

      assert {:error, {:outbound, {:blocked_address, "169.254.169.254"}}} =
               Imports.discover(%{
                 "provider" => "github",
                 "remote_owner" => "acme",
                 "base_url" => "http://169.254.169.254"
               })

      refute_received :reached
    end

    test "a provider can't hand back a clone URL on the private network" do
      Application.put_env(:git_gud, :imports_req_options,
        plug: fn conn ->
          conn
          |> Plug.Conn.put_resp_content_type("application/json")
          |> Plug.Conn.resp(
            200,
            Jason.encode!([
              %{"name" => "x", "full_name" => "acme/x", "clone_url" => "http://10.0.0.9/x.git"}
            ])
          )
        end
      )

      on_exit(fn -> Application.delete_env(:git_gud, :imports_req_options) end)

      assert {:error, {:outbound, {:blocked_address, "10.0.0.9"}}} =
               Imports.discover(%{"provider" => "github", "remote_owner" => "acme"})
    end

    test "a single-URL import of an internal host is refused at discovery" do
      assert {:error, {:outbound, {:blocked_address, _}}} =
               Imports.discover(%{
                 "provider" => "git",
                 "remote_owner" => "https://git.internal.test/team/x.git"
               })
    end

    test "start_import refuses a private clone URL even if discovery was skipped" do
      user = user_fixture()

      repo = %{
        name: "x",
        full_name: "acme/x",
        clone_url: "http://169.254.169.254/x.git",
        description: nil,
        default_branch: nil,
        private: true,
        fork: false,
        archived: false
      }

      assert {:error, {:outbound, _}} =
               Imports.start_import(
                 user,
                 user,
                 %{"provider" => "git", "remote_owner" => repo.clone_url},
                 [repo],
                 nil
               )
    end

    test "clone_addr can't name another repository on this server's disk", %{prev: prev} do
      put_config(prev, allow_local_paths: false)
      {_owner, victim} = repository_fixture()
      user = user_fixture()

      for addr <- [victim.disk_path, "file://" <> victim.disk_path, "http://127.0.0.1:1/x.git"] do
        assert {:error, {:git_init, {:git_clone, _}}} =
                 Repositories.create_repository(user, %{
                   "name" => "steal#{System.unique_integer([:positive])}",
                   "clone_addr" => addr
                 }),
               addr
      end
    end

    test "upstream sync refuses a private upstream", %{prev: prev} do
      put_config(prev, allow_local_paths: false)
      {_owner, repo} = repository_fixture()

      repo =
        repo
        |> Ecto.Changeset.change(upstream_url: "http://169.254.169.254/x.git")
        |> GitGud.Repo.update!()

      assert {:error, {:git_fetch, msg}} = Repositories.sync_upstream(repo)
      assert msg =~ "private or reserved"
    end

    test "a webhook to a private address is logged as failed without being sent" do
      {_user, repo} = repository_fixture()
      {:ok, hook} = Webhooks.create_webhook(repo, %{"url" => "http://169.254.169.254/latest"})

      assert {:error, _} =
               perform_job(DeliverWebhook, %{
                 "webhook_id" => hook.id,
                 "event" => "push",
                 "payload" => %{}
               })

      [delivery] = GitGud.Repo.all(WebhookDelivery)
      assert delivery.status == "failure"
      assert delivery.status_code == nil
      assert delivery.response_body =~ "Blocked"
    end

    test "an ActivityPub fetch of a private address is refused" do
      assert {:error, %OutboundUrl.Blocked{}} =
               RemoteActors.fetch("http://169.254.169.254/users/alice")
    end
  end
end