Federation: only public repositories federate; remotes act only on what's theirs

4d7e44f · Gabriel Morell · 2026-10-18 19:44

25 files +661 -74
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/federation.ex
+23 −3
@@ -245,17 +245,24 @@ defmodule GitGud.Federation do
245 245 so audit and replay remain possible.
246 246 """
247 247 def screen_inbound(actor_url) when is_binary(actor_url) do
248 − host = URI.parse(actor_url).host || ""
248 + host = normalize_host(URI.parse(actor_url).host)
249 249
250 250 cond do
251 251 Repo.exists?(from b in ActorBlock, where: b.actor_url == ^actor_url) ->
252 252 {:drop, "actor_blocked"}
253 253
254 − Repo.exists?(from b in InstanceBlock, where: b.host == ^host) ->
254 + # Hosts compare case-insensitively (DNS does), and a block covers
255 + # the host's subdomains too: blocking evil.example must not be
256 + # undone by EVIL.example or a.evil.example.
257 + Repo.exists?(
258 + from b in InstanceBlock, where: fragment("lower(?)", b.host) in ^host_and_parents(host)
259 + ) ->
255 260 {:drop, "instance_blocked"}
256 261
257 262 federation_mode() == :allowlist and
258 − not Repo.exists?(from a in InstanceAllowlist, where: a.host == ^host) ->
263 + not Repo.exists?(
264 + from a in InstanceAllowlist, where: fragment("lower(?)", a.host) == ^host
265 + ) ->
259 266 {:drop, "not_on_allowlist"}
260 267
261 268 true ->
@@ -263,6 +270,19 @@ defmodule GitGud.Federation do
263 270 end
264 271 end
265 272
273 + @doc "A host as federation compares it: lowercased, without a trailing dot."
274 + def normalize_host(nil), do: ""
275 +
276 + def normalize_host(host) when is_binary(host),
277 + do: host |> String.trim() |> String.trim_trailing(".") |> String.downcase()
278 +
279 + defp host_and_parents(""), do: [""]
280 +
281 + defp host_and_parents(host) do
282 + labels = String.split(host, ".")
283 + for i <- 0..(length(labels) - 1), do: labels |> Enum.drop(i) |> Enum.join(".")
284 + end
285 +
266 286 # ── moderation CRUD ──────────────────────────────────────────────────
267 287
268 288 def block_instance(host, opts \\ []) do
modified lib/git_gud/federation/instance_allowlist.ex
+1 −0
@@ -12,6 +12,7 @@ defmodule GitGud.Federation.InstanceAllowlist do
12 12 def changeset(a, attrs) do
13 13 a
14 14 |> cast(attrs, [:host, :note])
15 + |> update_change(:host, &GitGud.Federation.normalize_host/1)
15 16 |> validate_required([:host])
16 17 |> validate_length(:host, max: 250)
17 18 |> unique_constraint(:host)
modified lib/git_gud/federation/instance_block.ex
+1 −0
@@ -15,6 +15,7 @@ defmodule GitGud.Federation.InstanceBlock do
15 15 def changeset(b, attrs) do
16 16 b
17 17 |> cast(attrs, [:host, :severity, :reason])
18 + |> update_change(:host, &GitGud.Federation.normalize_host/1)
18 19 |> validate_required([:host])
19 20 |> validate_length(:host, max: 250)
20 21 |> validate_inclusion(:severity, @severities)
modified lib/git_gud/federation/publishing.ex
+26 −0
@@ -95,6 +95,14 @@ defmodule GitGud.Federation.Publishing do
95 95 alias GitGud.PullRequests.PullRequest
96 96 alias GitGud.Repositories.Repository
97 97
98 + # Only public repositories and organizations federate. A follower of
99 + # a repository that is (or has since become) internal or private gets
100 + # nothing: these activities carry commit messages and author emails,
101 + # issue and PR titles and bodies, and comments.
102 + def publish_org_repo_created(%Organization{visibility: ov}, %Repository{visibility: rv})
103 + when ov != "public" or rv != "public",
104 + do: :ok
105 +
98 106 def publish_org_repo_created(%Organization{} = org, %Repository{} = repo) do
99 107 with {:ok, org_actor} <- Federation.ensure_local_actor(org),
100 108 {:ok, repo_actor} <- Federation.ensure_local_actor(repo) do
@@ -103,6 +111,9 @@ defmodule GitGud.Federation.Publishing do
103 111 end
104 112 end
105 113
114 + def publish_org_member_added(%Organization{visibility: v}, _user, _role) when v != "public",
115 + do: :ok
116 +
106 117 def publish_org_member_added(%Organization{} = org, %GitGud.Accounts.User{} = user, role) do
107 118 with {:ok, org_actor} <- Federation.ensure_local_actor(org),
108 119 {:ok, member_actor} <- Federation.ensure_local_actor(user) do
@@ -111,12 +122,17 @@ defmodule GitGud.Federation.Publishing do
111 122 end
112 123 end
113 124
125 + def publish_push(%Repository{visibility: v}, _params) when v != "public", do: :ok
126 +
114 127 def publish_push(%Repository{} = repo, params) do
115 128 {:ok, repo_actor} = Federation.ensure_local_actor(repo)
116 129 doc = ActivityBuilders.push(repo_actor, params)
117 130 publish(repo_actor, doc)
118 131 end
119 132
133 + def publish_issue_opened(%Repository{visibility: v}, _issue, _author) when v != "public",
134 + do: :ok
135 +
120 136 def publish_issue_opened(%Repository{} = repo, %Issue{} = issue, author) do
121 137 with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
122 138 {:ok, author_actor} <- maybe_actor_for(author) do
@@ -126,6 +142,9 @@ defmodule GitGud.Federation.Publishing do
126 142 end
127 143 end
128 144
145 + def publish_issue_comment(%Repository{visibility: v}, _issue, _comment, _author)
146 + when v != "public", do: :ok
147 +
129 148 def publish_issue_comment(%Repository{} = repo, %Issue{} = issue, comment, author) do
130 149 with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
131 150 {:ok, author_actor} <- maybe_actor_for(author) do
@@ -139,6 +158,8 @@ defmodule GitGud.Federation.Publishing do
139 158 end
140 159 end
141 160
161 + def publish_pr_opened(%Repository{visibility: v}, _pr, _author) when v != "public", do: :ok
162 +
142 163 def publish_pr_opened(%Repository{} = repo, %PullRequest{} = pr, author) do
143 164 with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
144 165 {:ok, author_actor} <- maybe_actor_for(author) do
@@ -148,6 +169,9 @@ defmodule GitGud.Federation.Publishing do
148 169 end
149 170 end
150 171
172 + def publish_pr_comment(%Repository{visibility: v}, _pr, _comment, _author) when v != "public",
173 + do: :ok
174 +
151 175 def publish_pr_comment(%Repository{} = repo, %PullRequest{} = pr, comment, author) do
152 176 with {:ok, repo_actor} <- Federation.ensure_local_actor(repo),
153 177 {:ok, author_actor} <- maybe_actor_for(author) do
@@ -167,6 +191,8 @@ defmodule GitGud.Federation.Publishing do
167 191 end
168 192 end
169 193
194 + def publish_pr_merged(%Repository{visibility: v}, _pr) when v != "public", do: :ok
195 +
170 196 def publish_pr_merged(%Repository{} = repo, %PullRequest{} = pr) do
171 197 {:ok, repo_actor} = Federation.ensure_local_actor(repo)
172 198 doc = ActivityBuilders.pr_merged(repo_actor, pr)
modified lib/git_gud/federation/rate_limiter.ex
+2 −0
@@ -73,6 +73,8 @@ defmodule GitGud.Federation.RateLimiter do
73 73 def check(nil), do: :ok
74 74
75 75 def check(host) when is_binary(host) do
76 + # One bucket per host however it's spelled (EVIL.example, evil.example.).
77 + host = GitGud.Federation.normalize_host(host)
76 78 threshold = effective_threshold(host)
77 79 count = :ets.update_counter(@table, host, {2, 1}, {host, 0})
78 80
modified lib/git_gud/federation/remote_actors.ex
+57 −8

Click to load diff…

modified lib/git_gud/federation/workers/process_inbox.ex
+42 −8

Click to load diff…

modified lib/git_gud/pull_requests.ex
+16 −2

Click to load diff…

modified lib/git_gud_web/controllers/ap_actor_controller.ex
+79 −38

Click to load diff…

modified test/git_gud/federation/interaction_policy_test.exs
+1 −1

Click to load diff…

modified test/git_gud/federation/publishing_test.exs
+3 −3

Click to load diff…

modified test/git_gud/pull_requests_federated_review_test.exs
+15 −9

Click to load diff…

modified test/git_gud/pull_requests_federated_test.exs
+159 −2

Click to load diff…

added test/git_gud_web/controllers/ap_federation_hardening_test.exs
+236 −0

Click to load diff…

Parents: fea96e8