7.6 KiB · text History b868907
defmodule GitGudWeb.ApFederationHardeningTest do
  @moduledoc """
  What a remote server can't get out of (or into) this instance:
  non-public repositories and orgs don't federate, blocks hold whatever
  the host's spelling, an actor document must be the URL it was fetched
  from, and a signature has to cover the request it came with.
  """
  use GitGudWeb.ConnCase, async: false

  import GitGud.AccountsFixtures
  import GitGud.ForgeFixtures

  alias GitGud.Federation
  alias GitGud.Federation.Activity
  alias GitGud.Federation.Actor
  alias GitGud.Federation.HttpSignatures
  alias GitGud.Federation.InboxDelivery
  alias GitGud.Federation.Publishing
  alias GitGud.Federation.RemoteActors
  alias GitGud.Organizations
  alias GitGud.Repo
  alias GitGud.Repositories
  alias GitGud.Repositories.Storage

  @moduletag :git_required

  defp ap(conn), do: put_req_header(conn, "accept", "application/activity+json")

  describe "non-public repositories and organizations" do
    test "have no actor, no inbox and aren't listed", %{conn: conn} do
      admin = user_fixture()

      {:ok, org} =
        Organizations.create_organization(admin, %{
          "handle" => "fed#{System.unique_integer([:positive])}",
          "name" => "Fed"
        })

      {:ok, _secret} =
        Repositories.create_repository_for_org(org, admin, %{
          "name" => "secret",
          "visibility" => "private"
        })

      {:ok, _open} =
        Repositories.create_repository_for_org(org, admin, %{
          "name" => "open",
          "visibility" => "public"
        })

      assert get(ap(conn), "/r/#{org.handle}/secret").status == 404
      assert get(ap(build_conn()), "/r/#{org.handle}/open").status == 200

      inbox =
        post(ap(build_conn()), "/r/#{org.handle}/secret/inbox", %{
          "actor" => "https://x.example/u",
          "type" => "Follow"
        })

      assert inbox.status == 404

      listing = get(ap(build_conn()), "/orgs/#{org.handle}/repositories")
      items = Jason.decode!(listing.resp_body)["orderedItems"]
      assert Enum.any?(items, &String.ends_with?(&1, "/open"))
      refute Enum.any?(items, &String.ends_with?(&1, "/secret"))
    end

    test "publish nothing to their followers" do
      {user, repo} = repository_fixture()
      assert repo.visibility == "private"

      before = Repo.aggregate(Activity, :count)
      assert :ok = Publishing.publish_push(repo, %{ref: "refs/heads/main", commits: []})
      issue = issue_fixture(repo, user)
      assert :ok = Publishing.publish_issue_opened(repo, issue, user)
      assert Repo.aggregate(Activity, :count) == before
    end
  end

  describe "instance blocks" do
    test "hold whatever the host's case, and cover subdomains" do
      {:ok, _} = Federation.block_instance("evil.example")

      for url <- [
            "https://evil.example/u",
            "https://EVIL.Example/u",
            "https://evil.example./u",
            "https://a.evil.example/u"
          ] do
        assert {:drop, "instance_blocked"} = Federation.screen_inbound(url), url
      end

      assert :ok = Federation.screen_inbound("https://notevil.example/u")
    end
  end

  describe "fetched actor documents" do
    setup do
      on_exit(fn -> Application.delete_env(:git_gud, :federation_req_options) end)
      :ok
    end

    defp serve(docs) do
      Application.put_env(:git_gud, :federation_req_options,
        plug: fn conn ->
          url = "https://#{conn.host}#{conn.request_path}"

          case Map.fetch(docs, url) do
            {:ok, doc} ->
              conn
              |> Plug.Conn.put_resp_content_type("application/activity+json")
              |> Plug.Conn.resp(200, Jason.encode!(doc))

            :error ->
              Plug.Conn.resp(conn, 404, "")
          end
        end
      )
    end

    test "must name the URL they were fetched from" do
      serve(%{
        "https://peer.example/raw/evil.json" => %{
          "id" => "https://peer.example/users/bob",
          "type" => "Person",
          "publicKey" => %{
            "id" => "https://peer.example/users/bob#main-key",
            "publicKeyPem" => "x"
          }
        },
        "https://peer.example/users/bob" => %{
          "id" => "https://peer.example/users/bob",
          "type" => "Person",
          "publicKey" => %{
            "id" => "https://peer.example/users/bob#main-key",
            "owner" => "https://peer.example/users/bob",
            "publicKeyPem" => "real"
          }
        },
        "https://peer.example/users/mallory" => %{
          "id" => "https://elsewhere.example/users/mallory",
          "type" => "Person"
        },
        "https://peer.example/users/eve" => %{
          "id" => "https://peer.example/users/eve",
          "type" => "Person",
          "publicKey" => %{"owner" => "https://peer.example/users/bob", "publicKeyPem" => "x"}
        }
      })

      # A document naming another id on the same host is fetched by that
      # id, and what we keep is that one's.
      assert {:ok, %Actor{actor_url: "https://peer.example/users/bob", public_key_pem: "real"}} =
               RemoteActors.fetch("https://peer.example/raw/evil.json")

      assert {:error, :actor_id_mismatch} =
               RemoteActors.fetch("https://peer.example/users/mallory")

      assert {:error, :key_owner_mismatch} = RemoteActors.fetch("https://peer.example/users/eve")
    end

    test "a keyId naming a local actor never rewrites it" do
      user = user_fixture()
      {:ok, local} = Federation.ensure_local_actor(user)
      serve(%{})

      assert {:ok, %Actor{id: id, kind: "local"}} =
               RemoteActors.fetch(local.actor_url <> "#main-key")

      assert id == local.id
      assert Repo.get!(Actor, id).public_key_pem == local.public_key_pem
    end
  end

  test "a signature that doesn't cover the request line, host and digest is rejected", %{
    conn: conn
  } do
    user = user_fixture()
    handle = Storage.owner_handle(user)
    {:ok, _} = Federation.ensure_local_actor(user)
    {pub_pem, priv_pem} = Federation.generate_keypair()
    remote_url = "https://other.example/users/alice"

    {:ok, _} =
      %Actor{kind: "remote"}
      |> Actor.remote_changeset(%{
        actor_type: "Person",
        actor_url: remote_url,
        preferred_username: "alice",
        inbox_url: remote_url <> "/inbox",
        public_key_pem: pub_pem,
        public_key_id: remote_url <> "#main-key",
        last_fetched_at: DateTime.utc_now(:second)
      })
      |> Repo.insert()

    body =
      Jason.encode!(%{
        "id" => remote_url <> "/activities/follow-date-only",
        "type" => "Follow",
        "actor" => remote_url,
        "object" => Federation.base_url() <> "/@" <> handle
      })

    # A valid signature — over the date alone.
    date =
      HttpSignatures.sign_request("post", "/", "x", body, priv_pem, "k")
      |> List.keyfind("date", 0)
      |> elem(1)

    [entry] = :public_key.pem_decode(priv_pem)

    sig =
      :public_key.sign("date: #{date}", :sha256, :public_key.pem_entry_decode(entry))
      |> Base.encode64()

    digest = "SHA-256=" <> Base.encode64(:crypto.hash(:sha256, body))

    resp =
      %{conn | host: "localhost"}
      |> put_req_header("content-type", "application/activity+json")
      |> put_req_header("accept", "application/activity+json")
      |> put_req_header("date", date)
      |> put_req_header("digest", digest)
      |> put_req_header(
        "signature",
        ~s(keyId="#{remote_url}#main-key",algorithm="rsa-sha256",headers="date",signature="#{sig}")
      )
      |> post("/@#{handle}/inbox", body)

    assert resp.status == 202

    assert [%InboxDelivery{status: "rejected", signature_verified: false}] =
             Repo.all(InboxDelivery)
  end
end