neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Outbound URLs stay off this server's own network
49fdeb3 · Gabriel Morell · 2026-10-18 19:19
Message
{commit_body(@commit)}
Files changed
modified
config/runtime.exs
+16
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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