neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
zot webhook fails closed; the v1 API doesn't confirm private repositories
fea96e8 · Gabriel Morell · 2026-10-18 19:44
Message
{commit_body(@commit)}
Files changed
modified
config/config.exs
+2
−2
@@ -88,8 +88,8 @@ config :git_gud, GitGud.Federation.Workers.Retention,
| 88 | 88 | # dimension; setting both gives "age-then-size" pruning. |
| 89 | 89 | # zot → Packages mirror. The shared secret guards the webhook |
| 90 | 90 | # endpoint at `POST /_webhooks/zot`. Configure zot's notification |
| 91 | −# block with `Authorization: Bearer <same value>`. Leave as `nil` for | |
| 92 | −# loopback-only dev where the URL is the only thing keeping it private. | |
| 91 | +# block with `Authorization: Bearer <same value>`. With `nil` the | |
| 92 | +# endpoint refuses every request (fail closed). | |
| 93 | 93 | config :git_gud, GitGudWeb.PackagesWebhookController, shared_secret: nil |
| 94 | 94 | |
| 95 | 95 | # OCI registry Bearer-token signing key. The private PEM is wired in |
modified
config/runtime.exs
+2
−2
@@ -161,8 +161,8 @@ if config_env() == :prod do
| 161 | 161 | |
| 162 | 162 | # zot → Packages mirror webhook shared secret. zot sends it as |
| 163 | 163 | # `Authorization: Bearer <secret>`; `PackagesWebhookController` |
| 164 | − # secure-compares against this value. Unset = no auth (open | |
| 165 | − # endpoint); fine on a private cluster network, otherwise set it. | |
| 164 | + # secure-compares against this value. Unset = the endpoint refuses | |
| 165 | + # every request (logged at boot). | |
| 166 | 166 | if zot_secret = System.get_env("ZOT_WEBHOOK_SECRET") do |
| 167 | 167 | config :git_gud, GitGudWeb.PackagesWebhookController, shared_secret: zot_secret |
| 168 | 168 | end |
modified
config/test.exs
+3
−0
@@ -73,3 +73,6 @@ config :git_gud, metrics_port: :disabled
| 73 | 73 | config :git_gud, GitGud.OutboundUrl, |
| 74 | 74 | allow_local_paths: true, |
| 75 | 75 | resolver: &GitGud.TestResolver.resolve/1 |
| 76 | + | |
| 77 | +# The zot webhook fails closed without a secret; tests send this one. | |
| 78 | +config :git_gud, GitGudWeb.PackagesWebhookController, shared_secret: "test-zot-secret" |
modified
lib/git_gud/application.ex
+1
−0
@@ -17,6 +17,7 @@ defmodule GitGud.Application do
| 17 | 17 | # Before anything can spawn git: its hooks inherit the secret that |
| 18 | 18 | # lets them call back into /_hooks and /_ssh. |
| 19 | 19 | :ok = GitGudWeb.Plugs.HookAuth.put_secret() |
| 20 | + :ok = GitGudWeb.PackagesWebhookController.warn_if_unconfigured() | |
| 20 | 21 | |
| 21 | 22 | topologies = Application.get_env(:libcluster, :topologies, []) |
| 22 | 23 |
modified
lib/git_gud_web/controllers/api/v1/org_controller.ex
+3
−0
@@ -57,6 +57,9 @@ defmodule GitGudWeb.Api.V1.OrgController do
| 57 | 57 | with {:ok, user} <- authenticate(conn, "repo"), |
| 58 | 58 | %_{} = org <- Organizations.get_organization_by_handle(handle), |
| 59 | 59 | %_{} = repo <- get_repo(handle, name), |
| 60 | + # Someone who can't read the repository learns nothing about it: | |
| 61 | + # a private repo they can't see is the same 404 as no repo. | |
| 62 | + true <- Organizations.can?(user, repo, :read) == true || nil, | |
| 60 | 63 | true <- Organizations.org_admin?(org, user) do |
| 61 | 64 | _ = Repositories.delete_repository(repo) |
| 62 | 65 | send_resp(conn, 204, "") |
modified
lib/git_gud_web/controllers/api/v1/repository_controller.ex
+3
−2
@@ -43,7 +43,9 @@ defmodule GitGudWeb.Api.V1.RepositoryController do
| 43 | 43 | defp with_repo(conn, owner, name, fun) do |
| 44 | 44 | case fetch_repo(owner, name) do |
| 45 | 45 | nil -> not_found(conn) |
| 46 | − repo -> if authorized?(conn, repo), do: fun.(repo), else: unauthorized(conn) | |
| 46 | + # A repository the caller may not read is the same 404 as one that | |
| 47 | + # doesn't exist, so the answer doesn't confirm a private name. | |
| 48 | + repo -> if authorized?(conn, repo), do: fun.(repo), else: not_found(conn) | |
| 47 | 49 | end |
| 48 | 50 | end |
| 49 | 51 |
@@ -128,5 +130,4 @@ defmodule GitGudWeb.Api.V1.RepositoryController do
| 128 | 130 | defp repo_url(owner, name), do: GitGudWeb.Endpoint.url() <> "/r/" <> owner <> "/" <> name |
| 129 | 131 | |
| 130 | 132 | defp not_found(conn), do: conn |> put_status(404) |> json(%{"message" => "Not Found"}) |
| 131 | − defp unauthorized(conn), do: conn |> put_status(401) |> json(%{"message" => "Unauthorized"}) | |
| 132 | 133 | end |
modified
lib/git_gud_web/controllers/packages_webhook_controller.ex
+35
−18
@@ -199,27 +199,44 @@ defmodule GitGudWeb.PackagesWebhookController do
| 199 | 199 | |
| 200 | 200 | defp parse_timestamp(_), do: nil |
| 201 | 201 | |
| 202 | + @doc """ | |
| 203 | + Logged once at boot when no `ZOT_WEBHOOK_SECRET` is configured: the | |
| 204 | + endpoint then refuses every call, so zot's notifications are dropped | |
| 205 | + until the operator sets one (on both sides). | |
| 206 | + """ | |
| 207 | + def warn_if_unconfigured do | |
| 208 | + if is_nil(shared_secret()) do | |
| 209 | + Logger.warning( | |
| 210 | + "ZOT_WEBHOOK_SECRET is not set: POST /_webhooks/zot refuses every request " <> | |
| 211 | + "until it is (and zot sends it as `Authorization: Bearer <secret>`)." | |
| 212 | + ) | |
| 213 | + end | |
| 214 | + | |
| 215 | + :ok | |
| 216 | + end | |
| 217 | + | |
| 218 | + defp shared_secret do | |
| 219 | + case Application.get_env(:git_gud, __MODULE__, []) |> Keyword.get(:shared_secret) do | |
| 220 | + s when is_binary(s) and s != "" -> s | |
| 221 | + _ -> nil | |
| 222 | + end | |
| 223 | + end | |
| 224 | + | |
| 225 | + # Fail closed: with no secret configured nothing gets in. An open | |
| 226 | + # endpoint let anyone on the network write package versions (and | |
| 227 | + # their digests) into any repository's package list. | |
| 202 | 228 | defp authenticate(conn) do |
| 203 | − expected = | |
| 204 | − Application.get_env(:git_gud, __MODULE__, []) | |
| 205 | − |> Keyword.get(:shared_secret) | |
| 229 | + case {shared_secret(), get_req_header(conn, "authorization")} do | |
| 230 | + {nil, _} -> | |
| 231 | + {:error, :unauthorized} | |
| 206 | 232 | |
| 207 | − cond do | |
| 208 | − is_nil(expected) or expected == "" -> | |
| 209 | − # No secret configured = open. Useful in dev with zot on | |
| 210 | − # localhost only. Operators should set one in prod. | |
| 211 | − :ok | |
| 233 | + {expected, ["Bearer " <> token]} -> | |
| 234 | + if Plug.Crypto.secure_compare(token, expected), | |
| 235 | + do: :ok, | |
| 236 | + else: {:error, :unauthorized} | |
| 212 | 237 | |
| 213 | − true -> | |
| 214 | − case get_req_header(conn, "authorization") do | |
| 215 | − ["Bearer " <> token] -> | |
| 216 | − if Plug.Crypto.secure_compare(token, expected), | |
| 217 | − do: :ok, | |
| 218 | − else: {:error, :unauthorized} | |
| 219 | − | |
| 220 | − _ -> | |
| 221 | − {:error, :unauthorized} | |
| 222 | − end | |
| 238 | + _ -> | |
| 239 | + {:error, :unauthorized} | |
| 223 | 240 | end |
| 224 | 241 | end |
| 225 | 242 | end |
added
test/git_gud_web/controllers/api/v1/org_controller_test.exs
+65
−0
@@ -0,0 +1,65 @@
| 1 | +defmodule GitGudWeb.Api.V1.OrgControllerTest do | |
| 2 | + use GitGudWeb.ConnCase, async: false | |
| 3 | + | |
| 4 | + import GitGud.AccountsFixtures | |
| 5 | + | |
| 6 | + alias GitGud.Accounts | |
| 7 | + alias GitGud.Organizations | |
| 8 | + alias GitGud.Repositories | |
| 9 | + | |
| 10 | + @moduletag :git_required | |
| 11 | + | |
| 12 | + setup do | |
| 13 | + admin = user_fixture() | |
| 14 | + | |
| 15 | + {:ok, org} = | |
| 16 | + Organizations.create_organization(admin, %{ | |
| 17 | + "handle" => "o#{System.unique_integer([:positive])}", | |
| 18 | + "name" => "O" | |
| 19 | + }) | |
| 20 | + | |
| 21 | + {:ok, secret} = | |
| 22 | + Repositories.create_repository_for_org(org, admin, %{ | |
| 23 | + "name" => "secret", | |
| 24 | + "visibility" => "private" | |
| 25 | + }) | |
| 26 | + | |
| 27 | + {:ok, open} = | |
| 28 | + Repositories.create_repository_for_org(org, admin, %{ | |
| 29 | + "name" => "open", | |
| 30 | + "visibility" => "public" | |
| 31 | + }) | |
| 32 | + | |
| 33 | + %{admin: admin, org: org, secret: secret, open: open} | |
| 34 | + end | |
| 35 | + | |
| 36 | + defp token_conn(user) do | |
| 37 | + {:ok, _t, plaintext} = Accounts.create_personal_access_token(user, "t", ["repo"]) | |
| 38 | + build_conn() |> put_req_header("authorization", "token " <> plaintext) | |
| 39 | + end | |
| 40 | + | |
| 41 | + describe "DELETE /api/v1/orgs/:org/repos/:name" do | |
| 42 | + test "a private repository the caller can't read is a 404, like a missing one", | |
| 43 | + %{org: org} do | |
| 44 | + outsider = token_conn(user_fixture()) | |
| 45 | + | |
| 46 | + missing = delete(outsider, "/api/v1/orgs/#{org.handle}/repos/nope") | |
| 47 | + hidden = delete(token_conn(user_fixture()), "/api/v1/orgs/#{org.handle}/repos/secret") | |
| 48 | + | |
| 49 | + assert missing.status == 404 | |
| 50 | + assert hidden.status == 404 | |
| 51 | + assert hidden.resp_body == missing.resp_body | |
| 52 | + assert Repositories.get_repository_by_path!(org.handle, "secret") | |
| 53 | + end | |
| 54 | + | |
| 55 | + test "a repository the caller can read but not administer is a 403", %{org: org} do | |
| 56 | + conn = delete(token_conn(user_fixture()), "/api/v1/orgs/#{org.handle}/repos/open") | |
| 57 | + assert conn.status == 403 | |
| 58 | + end | |
| 59 | + | |
| 60 | + test "an org admin deletes", %{admin: admin, org: org} do | |
| 61 | + conn = delete(token_conn(admin), "/api/v1/orgs/#{org.handle}/repos/secret") | |
| 62 | + assert conn.status == 204 | |
| 63 | + end | |
| 64 | + end | |
| 65 | +end |
modified
test/git_gud_web/packages_webhook_test.exs
+33
−7
@@ -6,10 +6,11 @@ defmodule GitGudWeb.PackagesWebhookTest do
| 6 | 6 | |
| 7 | 7 | import GitGud.ForgeFixtures |
| 8 | 8 | |
| 9 | − setup do | |
| 9 | + setup %{conn: conn} do | |
| 10 | 10 | {user, repo} = repository_fixture() |
| 11 | 11 | owner = Storage.owner_handle(user) |
| 12 | − {:ok, user: user, repo: repo, owner: owner} | |
| 12 | + conn = put_req_header(conn, "authorization", "Bearer test-zot-secret") | |
| 13 | + {:ok, conn: conn, user: user, repo: repo, owner: owner} | |
| 13 | 14 | end |
| 14 | 15 | |
| 15 | 16 | defp push_payload(owner, repo_name, image_path, version, opts \\ []) do |
@@ -17,11 +18,9 @@ defmodule GitGudWeb.PackagesWebhookTest do
| 17 | 18 | "event" => "PUSH", |
| 18 | 19 | "repository" => Enum.join([owner, repo_name, image_path], "/"), |
| 19 | 20 | "reference" => version, |
| 20 | − "digest" => | |
| 21 | − Keyword.get(opts, :digest, "sha256:" <> String.duplicate("a", 64)), | |
| 21 | + "digest" => Keyword.get(opts, :digest, "sha256:" <> String.duplicate("a", 64)), | |
| 22 | 22 | "size" => Keyword.get(opts, :size, 12_345), |
| 23 | − "mediaType" => | |
| 24 | − Keyword.get(opts, :media_type, "application/vnd.oci.image.manifest.v1+json"), | |
| 23 | + "mediaType" => Keyword.get(opts, :media_type, "application/vnd.oci.image.manifest.v1+json"), | |
| 25 | 24 | "timestamp" => "2026-05-13T12:00:00Z" |
| 26 | 25 | } |
| 27 | 26 | end |
@@ -115,18 +114,45 @@ defmodule GitGudWeb.PackagesWebhookTest do
| 115 | 114 | assert conn.status == 422 |
| 116 | 115 | end |
| 117 | 116 | |
| 117 | + test "with no secret configured, every request is refused", %{owner: owner, repo: repo} do | |
| 118 | + prev = Application.get_env(:git_gud, GitGudWeb.PackagesWebhookController) | |
| 119 | + Application.put_env(:git_gud, GitGudWeb.PackagesWebhookController, shared_secret: nil) | |
| 120 | + | |
| 121 | + on_exit(fn -> | |
| 122 | + Application.put_env(:git_gud, GitGudWeb.PackagesWebhookController, prev) | |
| 123 | + end) | |
| 124 | + | |
| 125 | + payload = push_payload(owner, repo.name, "web", "v1.0.0") | |
| 126 | + | |
| 127 | + assert post(Phoenix.ConnTest.build_conn(), ~p"/_webhooks/zot", payload).status == 401 | |
| 128 | + | |
| 129 | + conn = | |
| 130 | + Phoenix.ConnTest.build_conn() | |
| 131 | + |> put_req_header("authorization", "Bearer ") | |
| 132 | + |> post(~p"/_webhooks/zot", payload) | |
| 133 | + | |
| 134 | + assert conn.status == 401 | |
| 135 | + assert Packages.list_packages(repo) == [] | |
| 136 | + end | |
| 137 | + | |
| 118 | 138 | test "respects shared_secret when configured", %{conn: conn, owner: owner, repo: repo} do |
| 139 | + prev = Application.get_env(:git_gud, GitGudWeb.PackagesWebhookController) | |
| 140 | + | |
| 119 | 141 | Application.put_env(:git_gud, GitGudWeb.PackagesWebhookController, |
| 120 | 142 | shared_secret: "topsecret" |
| 121 | 143 | ) |
| 122 | 144 | |
| 123 | 145 | on_exit(fn -> |
| 124 | − Application.delete_env(:git_gud, GitGudWeb.PackagesWebhookController) | |
| 146 | + Application.put_env(:git_gud, GitGudWeb.PackagesWebhookController, prev) | |
| 125 | 147 | end) |
| 126 | 148 | |
| 127 | 149 | payload = push_payload(owner, repo.name, "web", "v1.0.0") |
| 128 | 150 | |
| 129 | 151 | # Without the bearer |
| 152 | + conn1 = post(Phoenix.ConnTest.build_conn(), ~p"/_webhooks/zot", payload) | |
| 153 | + assert conn1.status == 401 | |
| 154 | + | |
| 155 | + # With the wrong one | |
| 130 | 156 | conn1 = post(conn, ~p"/_webhooks/zot", payload) |
| 131 | 157 | assert conn1.status == 401 |
| 132 | 158 |
Parents: 96a5dc6