Outbound URLs stay off this server's own network

49fdeb3 · Gabriel Morell · 2026-10-18 19:19

35 files +815 -67
Message
{commit_body(@commit)}

Files changed

modified config/runtime.exs
+16 −0
@@ -23,6 +23,22 @@ end
23 23 config :git_gud, GitGudWeb.Endpoint,
24 24 http: [port: String.to_integer(System.get_env("PORT", "4000"))]
25 25
26 +# Outbound URL guard (GitGud.OutboundUrl): imports, mirrors, webhooks and
27 +# federation refuse private/loopback/link-local addresses. Self-hosters
28 +# importing from (or delivering to) their own LAN lift it per purpose.
29 +outbound_allow_private =
30 + for {env, purpose} <- [
31 + {"GITGUD_IMPORT_ALLOW_PRIVATE", :import},
32 + {"GITGUD_WEBHOOK_ALLOW_PRIVATE", :webhook},
33 + {"GITGUD_FEDERATION_ALLOW_PRIVATE", :federation}
34 + ],
35 + System.get_env(env) in ~w(1 true yes),
36 + do: purpose
37 +
38 +if outbound_allow_private != [] do
39 + config :git_gud, GitGud.OutboundUrl, allow_private: outbound_allow_private
40 +end
41 +
26 42 if config_env() == :prod do
27 43 # Two ways to point at Postgres:
28 44 #
modified config/test.exs
+7 −2
@@ -58,8 +58,7 @@ config :git_gud, GitGud.Storage, impl: GitGud.Storage.Disk
58 58 # `Application.put_env` overrides this for the explicit gating tests.
59 59 config :git_gud, registration_mode: :open
60 60
61 −config :git_gud, GitGud.Storage.Disk,
62 − root: Path.expand("../tmp/test/storage", __DIR__)
61 +config :git_gud, GitGud.Storage.Disk, root: Path.expand("../tmp/test/storage", __DIR__)
63 62
64 63 # Tests assert against an open federation graph; production defaults
65 64 # to allowlist (see GitGud.Federation moduledoc).
@@ -68,3 +67,9 @@ config :git_gud, GitGud.Federation, federation_mode: :open
68 67 # No metrics listener under test — it would collide with a dev server (or a
69 68 # second test run) already holding the port.
70 69 config :git_gud, metrics_port: :disabled
70 +
71 +# Outbound URL guard: no real DNS under test, and on-disk fixture repos
72 +# stand in for remote upstreams (see GitGud.OutboundUrl).
73 +config :git_gud, GitGud.OutboundUrl,
74 + allow_local_paths: true,
75 + resolver: &GitGud.TestResolver.resolve/1
modified lib/git_gud/federation/remote_actors.ex
+17 −8
@@ -100,14 +100,23 @@ defmodule GitGud.Federation.RemoteActors do
100 100 # ── HTTP fetch ───────────────────────────────────────────────────────
101 101
102 102 defp fetch_doc(url) do
103 − case Req.get(url,
104 − headers: [
105 − {"accept", "application/activity+json"},
106 − {"user-agent", @user_agent}
107 − ],
108 − receive_timeout: 10_000,
109 − retry: false
110 − ) do
103 + # The URL is a remote's (or a user's) choice: `OutboundUrl` keeps it
104 + # off this server's own network.
105 + req =
106 + GitGud.OutboundUrl.req(
107 + [
108 + url: url,
109 + headers: [
110 + {"accept", "application/activity+json"},
111 + {"user-agent", @user_agent}
112 + ],
113 + receive_timeout: 10_000,
114 + retry: false
115 + ],
116 + :federation
117 + )
118 +
119 + case Req.get(req) do
111 120 {:ok, %Req.Response{status: 200, body: body}} when is_map(body) ->
112 121 {:ok, body}
113 122
modified lib/git_gud/federation/workers/deliver_outbound.ex
+28 −8
@@ -74,20 +74,40 @@ defmodule GitGud.Federation.Workers.DeliverOutbound do
74 74 {"user-agent", "gitgud-federation"}
75 75 ] ++ sig_headers
76 76
77 − case Req.post(target_inbox_url,
78 − headers: headers,
79 − body: body,
80 − retry: false,
81 − receive_timeout: 15_000
82 − ) do
77 + # Inbox URLs come from remote actor documents; `OutboundUrl` keeps a
78 + # remote from aiming our signed POSTs at this server's own network.
79 + req =
80 + GitGud.OutboundUrl.req(
81 + [
82 + url: target_inbox_url,
83 + headers: headers,
84 + body: body,
85 + retry: false,
86 + receive_timeout: 15_000
87 + ],
88 + :federation
89 + )
90 +
91 + case Req.post(req) do
83 92 {:ok, %Req.Response{status: status}} when status in 200..299 ->
84 93 record(activity, target_inbox_url, attempt, "success", status, nil)
85 94 :ok
86 95
87 96 {:ok, %Req.Response{status: status, body: body}} ->
88 97 msg = if(is_binary(body), do: String.slice(body, 0, 500), else: inspect(body))
89 − record(activity, target_inbox_url, attempt, terminal_status(attempt, "failure"), status, msg)
90 − if attempt >= @max_attempts, do: {:discard, "http #{status}"}, else: {:error, "http #{status}"}
98 +
99 + record(
100 + activity,
101 + target_inbox_url,
102 + attempt,
103 + terminal_status(attempt, "failure"),
104 + status,
105 + msg
106 + )
107 +
108 + if attempt >= @max_attempts,
109 + do: {:discard, "http #{status}"},
110 + else: {:error, "http #{status}"}
91 111
92 112 {:error, reason} ->
93 113 msg = inspect(reason)
modified lib/git_gud/imports.ex
+15 −1
@@ -58,11 +58,24 @@ defmodule GitGud.Imports do
58 58
59 59 with {:ok, adapter} <- Provider.adapter(provider),
60 60 :ok <- require_present(owner, :remote_owner),
61 − {:ok, repos} <- adapter.list_repositories(owner, base_url: base_url, token: token) do
61 + {:ok, repos} <- adapter.list_repositories(owner, base_url: base_url, token: token),
62 + :ok <- check_clone_urls(repos) do
62 63 {:ok, Enum.sort_by(repos, &String.downcase(&1.full_name || &1.name || ""))}
63 64 end
64 65 end
65 66
67 + # Every clone URL is fetched later by a worker, and a provider API (or
68 + # the operator, for a `git` import) picks them: refuse up front any
69 + # that `GitGud.OutboundUrl` would refuse at clone time.
70 + defp check_clone_urls(repos) do
71 + Enum.reduce_while(repos, :ok, fn repo, :ok ->
72 + case GitGud.OutboundUrl.check(repo.clone_url || "", :import) do
73 + {:ok, _} -> {:cont, :ok}
74 + {:error, reason} -> {:halt, {:error, {:outbound, reason}}}
75 + end
76 + end)
77 + end
78 +
66 79 @doc """
67 80 Filter a discovered list by the batch's fork/archive preferences.
68 81 """
@@ -89,6 +102,7 @@ defmodule GitGud.Imports do
89 102 """
90 103 def start_import(%User{} = actor, destination, attrs, repos, token \\ nil) do
91 104 with :ok <- authorize_destination(destination, actor),
105 + :ok <- check_clone_urls(repos),
92 106 {:ok, base} <- build_import(actor, destination, attrs, token) do
93 107 Repo.transaction(fn ->
94 108 import_row = Repo.insert!(base)
modified lib/git_gud/imports/providers/github.ex
+4 −0

Click to load diff…

modified lib/git_gud/imports/providers/gitlab.ex
+4 −0

Click to load diff…

added lib/git_gud/outbound_url.ex
+308 −0

Click to load diff…

added lib/git_gud/outbound_url/blocked.ex
+12 −0

Click to load diff…

modified lib/git_gud/pull_requests.ex
+22 −9

Click to load diff…

modified lib/git_gud/repositories.ex
+67 −37

Click to load diff…

modified lib/git_gud/webhooks/workers/deliver_webhook.ex
+8 −2

Click to load diff…

modified lib/git_gud_web/live/import_live/new.ex
+5 −0

Click to load diff…

modified lib/mix/tasks/gitgud.seed.conflicts.ex
+8 −0

Click to load diff…

modified test/git_gud/imports_sync_test.exs
+13 −0

Click to load diff…

added test/git_gud/outbound_url_test.exs
+263 −0

Click to load diff…

added test/support/test_resolver.ex
+18 −0

Click to load diff…

Parents: fe05dfe