zot webhook fails closed; the v1 API doesn't confirm private repositories

fea96e8 · Gabriel Morell · 2026-10-18 19:44

21 files +147 -31
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