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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.6 KiB · text
History
b868907