6.8 KiB · text History 3d1b0af
defmodule GitGudWeb.RateLimitsTest do
  @moduledoc """
  Where `GitGud.RateLimit` is applied: wrong passwords on the login
  form and over git HTTPS, sign-in emails, and runner polls.

  Each test comes from its own client address so the shared counters
  of other tests (all from 127.0.0.1) don't leak in.
  """
  use GitGudWeb.ConnCase, async: false

  @moduletag :capture_log

  import Ecto.Query
  import Phoenix.LiveViewTest
  import GitGud.AccountsFixtures

  alias GitGud.RateLimit
  alias GitGud.Repositories.Storage
  alias Runner.V1.{FetchTaskRequest, FetchTaskResponse}

  setup %{conn: conn} do
    n = System.unique_integer([:positive])
    ip = {198, 18, div(rem(n, 65_536), 256), rem(n, 256)}
    {:ok, conn: %{conn | remote_ip: ip}, ip: ip}
  end

  defp login(conn, email, password) do
    post(conn, ~p"/users/log-in", %{"user" => %{"email" => email, "password" => password}})
  end

  describe "login form" do
    test "after too many wrong passwords even the right one is refused", %{conn: conn} do
      user = set_password(user_fixture())
      {limit, _window} = RateLimit.limits(:login_account)

      for _ <- 1..limit do
        conn = login(conn, user.email, "wrong password!")
        assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
      end

      conn = login(conn, user.email, valid_user_password())
      refute get_session(conn, :user_token)
      assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Too many wrong passwords"
    end

    test "an unknown address is limited the same, giving nothing away", %{conn: conn} do
      {limit, _window} = RateLimit.limits(:login_account)
      email = "nobody-#{System.unique_integer([:positive])}@example.com"

      for _ <- 1..limit, do: login(conn, email, "wrong password!")

      assert Phoenix.Flash.get(login(conn, email, "x").assigns.flash, :error) =~
               "Too many wrong passwords"
    end

    test "a right password clears the account's count", %{conn: conn} do
      user = set_password(user_fixture())
      {limit, _window} = RateLimit.limits(:login_account)

      for _ <- 1..(limit - 1), do: login(conn, user.email, "wrong password!")
      assert get_session(login(conn, user.email, valid_user_password()), :user_token)

      for _ <- 1..(limit - 1), do: login(conn, user.email, "wrong password!")
      assert get_session(login(conn, user.email, valid_user_password()), :user_token)
    end

    test "one address guessing across accounts runs out", %{conn: conn} do
      {limit, _window} = RateLimit.limits(:login_ip)

      for i <- 1..limit, do: login(conn, "walk-#{i}-#{System.unique_integer()}@example.com", "x")

      user = set_password(user_fixture())
      conn = login(conn, user.email, valid_user_password())
      refute get_session(conn, :user_token)

      # Another address is unaffected.
      conn =
        login(%{build_conn() | remote_ip: {198, 19, 0, 1}}, user.email, valid_user_password())

      assert get_session(conn, :user_token)
    end
  end

  describe "git over HTTPS" do
    @tag :git_required
    test "a password guessed too often is answered 429", %{conn: conn} do
      {user, repo} = GitGud.ForgeFixtures.repository_fixture(%{visibility: "private"})
      user = set_password(user)
      path = "/#{Storage.owner_handle(user)}/#{repo.name}.git/info/refs?service=git-upload-pack"
      {limit, _window} = RateLimit.limits(:login_account)

      fetch = fn password ->
        conn
        |> put_req_header("authorization", "Basic " <> Base.encode64("#{user.email}:#{password}"))
        |> get(path)
      end

      assert fetch.(valid_user_password()).status == 200
      for _ <- 1..limit, do: assert(fetch.("wrong password!").status == 401)

      resp = fetch.(valid_user_password())
      assert resp.status == 429
      assert [_] = get_resp_header(resp, "retry-after")
    end
  end

  describe "sign-in emails" do
    test "past the limit for an address, no more links are made", %{conn: conn, ip: ip} do
      # A LiveView finds the client from the socket's peer.
      conn = Plug.Test.put_peer_data(conn, %{address: ip, port: 4000, ssl_cert: nil})
      user = user_fixture()
      {limit, _window} = RateLimit.limits(:email_address)

      ask = fn ->
        {:ok, lv, _html} = live(conn, ~p"/users/log-in")

        {:ok, _lv, html} =
          form(lv, "#login_form_magic", user: %{email: user.email})
          |> render_submit()
          |> follow_redirect(conn, ~p"/users/log-in")

        # The page says the same either way.
        assert html =~ "If your email is in our system"
      end

      for _ <- 1..(limit + 2), do: ask.()

      tokens =
        GitGud.Repo.all(
          from t in GitGud.Accounts.UserToken,
            where: t.user_id == ^user.id and t.context == "login"
        )

      assert length(tokens) == limit
    end
  end

  describe "runner polls" do
    setup do
      previous = Application.get_env(:git_gud, RateLimit, [])
      Application.put_env(:git_gud, RateLimit, Keyword.put(previous, :runner_fetch, {2, 60}))
      on_exit(fn -> Application.put_env(:git_gud, RateLimit, previous) end)

      {:ok, _runner, token} =
        GitGud.Workflows.Runners.register(%{
          name: "poller",
          version: "v1",
          labels: %{"labels" => ["ubuntu-latest"]}
        })

      {:ok, token: token}
    end

    test "a runner polling too fast is told there's no work", %{conn: conn, token: token} do
      poll = fn ->
        body =
          FetchTaskRequest.encode(%FetchTaskRequest{tasks_version: 4}) |> IO.iodata_to_binary()

        resp =
          conn
          |> put_req_header("content-type", "application/protobuf")
          |> put_req_header("authorization", "Bearer " <> token)
          |> post("/api/actions/_apis/runner.v1.RunnerService/FetchTask", body)

        assert resp.status == 200
        FetchTaskResponse.decode(resp.resp_body)
      end

      poll.()
      poll.()
      queue_job()

      # Over the limit: not an error, and the queued job stays queued.
      assert %FetchTaskResponse{task: nil, tasks_version: 5} = poll.()

      assert GitGud.Repo.one!(from j in GitGud.Workflows.WorkflowJob, select: j.status) ==
               "queued"
    end
  end

  defp queue_job do
    {_user, repo} = GitGud.ForgeFixtures.repository_fixture()

    run =
      %GitGud.Workflows.WorkflowRun{repository_id: repo.id}
      |> GitGud.Workflows.WorkflowRun.create_changeset(%{
        number: 1,
        workflow_path: ".forgejo/workflows/ci.yml",
        trigger: "push",
        head_sha: :crypto.strong_rand_bytes(20)
      })
      |> GitGud.Repo.insert!()

    %GitGud.Workflows.WorkflowJob{workflow_run_id: run.id}
    |> GitGud.Workflows.WorkflowJob.create_changeset(%{
      job_id: "build",
      runs_on: %{"labels" => ["ubuntu-latest"]},
      definition: %{"runs-on" => "ubuntu-latest", "steps" => [%{"run" => "true"}]}
    })
    |> GitGud.Repo.insert!()
  end
end