Hooks and the SSH shim prove they're this node's own

fe05dfe · Gabriel Morell · 2026-10-18 18:50

20 files +228 -15
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/application.ex
+4 −0
@@ -14,6 +14,10 @@ defmodule GitGud.Application do
14 14 config: %{metadata: [:file, :line], capture_log_messages: true}
15 15 })
16 16
17 + # Before anything can spawn git: its hooks inherit the secret that
18 + # lets them call back into /_hooks and /_ssh.
19 + :ok = GitGudWeb.Plugs.HookAuth.put_secret()
20 +
17 21 topologies = Application.get_env(:libcluster, :topologies, [])
18 22
19 23 children =
modified lib/git_gud_web/controllers/hook_controller.ex
+1 −1
@@ -6,7 +6,7 @@ defmodule GitGudWeb.HookController do
6 6 the hook (`<old> <new> <ref>\\n…`). The repo is identified via the
7 7 `x-repo-path: <absolute path>` header.
8 8
9 − Loopback-only — bind to 127.0.0.1 in production.
9 + Only this node's own hooks get in: see `GitGudWeb.Plugs.HookAuth`.
10 10 """
11 11
12 12 use GitGudWeb, :controller
modified lib/git_gud_web/controllers/ssh_controller.ex
+3 −4
@@ -5,16 +5,15 @@ defmodule GitGudWeb.SshController do
5 5 authorization decision.
6 6
7 7 Request:
8 − POST /_ssh/resolve (loopback only — bind in runtime.exs)
8 + POST /_ssh/resolve (with the node's hook secret — see GitGudWeb.Plugs.HookAuth)
9 9 {"key_id": "12", "repo": "owner/name.git", "cmd": "git-upload-pack"}
10 10
11 11 Response is a single ASCII line consumed by `sh`:
12 12 OK /absolute/path/to/owner/name.git
13 13 DENY <reason>
14 14
15 − Bind this endpoint to 127.0.0.1 in production (separate `Bandit`
16 − instance, or use a reverse proxy) so it's never exposed to the
17 − internet.
15 + The listener is public; `GitGudWeb.Plugs.HookAuth` refuses any caller
16 + without the secret.
18 17 """
19 18
20 19 use GitGudWeb, :controller
added lib/git_gud_web/plugs/hook_auth.ex
+96 −0
@@ -0,0 +1,96 @@
1 +defmodule GitGudWeb.Plugs.HookAuth do
2 + @moduledoc """
3 + Guards the endpoints only this node's own git hooks and SSH shim may
4 + call: `/_hooks/pre-receive`, `/_hooks/post-receive` and `/_ssh/resolve`.
5 +
6 + They used to be guarded by nothing but a comment saying "bind to
7 + 127.0.0.1", while the endpoint listens on every interface. Anyone could
8 + then ask `/_ssh/resolve` which key may push where, or post a fake
9 + post-receive naming any pusher and start workflows as them.
10 +
11 + A request must carry `x-gitgud-hook-token` equal to a secret this node
12 + holds. `put_secret/0` makes it at boot (or takes `GITGUD_HOOK_SECRET`
13 + when the operator sets one) and exports it into the node's own OS
14 + environment, so every `git` it spawns — `git http-backend` for smart
15 + HTTP, `git-receive-pack` for the embedded SSH daemon — hands it down
16 + to the hooks, the way `GITGUD_PUSHER_ID` already travels. The scripts
17 + in `priv/scripts` send it.
18 +
19 + A secret rather than a loopback-address check: behind a reverse proxy
20 + on the same host (the usual way to terminate TLS for the .deb install)
21 + or a mesh sidecar, every outside request arrives from 127.0.0.1 too,
22 + so the peer address proves nothing; and a hook pointed at the node by
23 + name through `GITGUD_HOOK_URL` would stop working.
24 +
25 + The OpenSSH `authorized_keys` shim (`gitgud-ssh`) runs under sshd, not
26 + under this node, so with that setup set `GITGUD_HOOK_SECRET` to the
27 + same value in both environments.
28 + """
29 +
30 + @behaviour Plug
31 +
32 + import Plug.Conn
33 +
34 + require Logger
35 +
36 + @env "GITGUD_HOOK_SECRET"
37 + @header "x-gitgud-hook-token"
38 + @key {__MODULE__, :secret}
39 +
40 + @doc "The header the hook scripts send the secret in."
41 + def header, do: @header
42 +
43 + @doc """
44 + Settle this node's secret and export it to the OS environment the
45 + node's children inherit. Called once from `GitGud.Application.start/2`.
46 + """
47 + def put_secret do
48 + secret =
49 + case System.get_env(@env) do
50 + s when is_binary(s) and byte_size(s) >= 16 -> s
51 + _ -> 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
52 + end
53 +
54 + System.put_env(@env, secret)
55 + :persistent_term.put(@key, secret)
56 + :ok
57 + end
58 +
59 + @doc "This node's secret."
60 + def secret do
61 + case :persistent_term.get(@key, nil) do
62 + nil ->
63 + :ok = put_secret()
64 + :persistent_term.get(@key)
65 +
66 + s ->
67 + s
68 + end
69 + end
70 +
71 + @impl true
72 + def init(opts), do: opts
73 +
74 + @impl true
75 + def call(conn, _opts) do
76 + case get_req_header(conn, @header) do
77 + [token] when is_binary(token) ->
78 + if Plug.Crypto.secure_compare(token, secret()), do: conn, else: deny(conn)
79 +
80 + _ ->
81 + deny(conn)
82 + end
83 + end
84 +
85 + defp deny(conn) do
86 + Logger.warning(
87 + "refused #{conn.method} #{conn.request_path} from #{:inet.ntoa(conn.remote_ip)}: " <>
88 + "no valid #{@header}"
89 + )
90 +
91 + conn
92 + |> put_resp_content_type("text/plain")
93 + |> send_resp(403, "DENY forbidden\n")
94 + |> halt()
95 + end
96 +end
modified lib/git_gud_web/router.ex
+9 −7
@@ -25,9 +25,11 @@ defmodule GitGudWeb.Router do
25 25 plug GitGudWeb.Api.Mobile.Auth
26 26 end
27 27
28 − # No accepts gate — used by the SSH wrapper and post-receive hook,
29 − # which speak plain text.
30 − pipeline :loopback do
28 + # No accepts gate — used by the SSH wrapper and the git hooks, which
29 + # speak plain text. They prove they're this node's own with the secret
30 + # it handed their environment (see `GitGudWeb.Plugs.HookAuth`).
31 + pipeline :local_hook do
32 + plug GitGudWeb.Plugs.HookAuth
31 33 end
32 34
33 35 # Raw pass-through for bytes. Used for the disk-backed storage
@@ -124,15 +126,15 @@ defmodule GitGudWeb.Router do
124 126 get "/nodeinfo/2.1", WellKnownController, :nodeinfo_2_1
125 127 end
126 128
127 − # Local-only endpoints used by the SSH wrapper and post-receive hook.
128 − # Bind to 127.0.0.1 in production; never expose externally.
129 + # Endpoints for this node's SSH wrapper and git hooks only. The
130 + # listener is public, so `:local_hook` checks the caller's secret.
129 131 scope "/_ssh", GitGudWeb do
130 − pipe_through :loopback
132 + pipe_through :local_hook
131 133 post "/resolve", SshController, :resolve
132 134 end
133 135
134 136 scope "/_hooks", GitGudWeb do
135 − pipe_through :loopback
137 + pipe_through :local_hook
136 138 post "/post-receive", HookController, :post_receive
137 139 post "/pre-receive", PreReceiveController, :pre_receive
138 140 end
modified priv/scripts/gitgud-hook
+5 −1
@@ -34,8 +34,12 @@ if [ -n "${GITGUD_JOB_ID:-}" ]; then
34 34 JOB_HEADER="x-job-id: ${GITGUD_JOB_ID}"
35 35 fi
36 36
37 +# The node's hook secret (GitGudWeb.Plugs.HookAuth), inherited from the
38 +# git process that runs this hook. Fed to curl on stdin rather than as
39 +# an argument, so it doesn't show up in `ps`.
37 40 # Use --data-binary so we preserve exact bytes (newlines etc).
38 −curl -sf -X POST \
41 +printf 'x-gitgud-hook-token: %s\n' "${GITGUD_HOOK_SECRET:-}" | curl -sf -X POST \
42 + -H @- \
39 43 -H "content-type: text/plain" \
40 44 -H "x-repo-path: ${REPO_PATH}" \
41 45 ${PUSHER_HEADER:+-H "${PUSHER_HEADER}"} \
modified priv/scripts/gitgud-prereceive
+5 −1
@@ -30,7 +30,11 @@ if ! command -v curl >/dev/null 2>&1; then
30 30 exit 1
31 31 fi
32 32
33 −RESP=$(curl -sf -X POST \
33 +# The node's hook secret (GitGudWeb.Plugs.HookAuth), inherited from the
34 +# git process that runs this hook. Fed to curl on stdin rather than as
35 +# an argument, so it doesn't show up in `ps`.
36 +RESP=$(printf 'x-gitgud-hook-token: %s\n' "${GITGUD_HOOK_SECRET:-}" | curl -sf -X POST \
37 + -H @- \
34 38 -H "content-type: text/plain" \
35 39 -H "x-repo-path: ${REPO_PATH}" \
36 40 --data-binary "${STDIN_PAYLOAD}" \
modified priv/scripts/gitgud-ssh
+7 −1
@@ -44,7 +44,13 @@ esac
44 44 # OK <absolute_repo_path>
45 45 # or:
46 46 # DENY <reason>
47 −RESP=$(curl -sf -X POST \
47 +#
48 +# Authenticated with the node's hook secret (GitGudWeb.Plugs.HookAuth):
49 +# sshd doesn't inherit it from the node, so set GITGUD_HOOK_SECRET to the
50 +# same value for both. Fed to curl on stdin, not argv, to keep it out of
51 +# `ps`.
52 +RESP=$(printf 'x-gitgud-hook-token: %s\n' "${GITGUD_HOOK_SECRET:-}" | curl -sf -X POST \
53 + -H @- \
48 54 -H 'content-type: application/json' \
49 55 --data-binary "{\"key_id\":\"${KEY_ID}\",\"repo\":\"${REPO_ARG}\",\"cmd\":\"${GIT_CMD}\"}" \
50 56 "${GITGUD_AUTH_URL}") || {
modified test/git_gud_web/git_job_token_test.exs
+3 −0
@@ -121,6 +121,8 @@ defmodule GitGudWeb.GitJobTokenTest do
121 121 hook_env = File.read!(env_file)
122 122 assert hook_env =~ "GITGUD_JOB_ID=#{job.id}\n"
123 123 assert hook_env =~ "GITGUD_PUSHER_ID=#{user.id}\n"
124 + # And the secret its call back into /_hooks needs.
125 + assert hook_env =~ "GITGUD_HOOK_SECRET=#{GitGudWeb.Plugs.HookAuth.secret()}\n"
124 126 end
125 127
126 128 test "branch protection judges it like any direct push", %{repo: repo} do
@@ -133,6 +135,7 @@ defmodule GitGudWeb.GitJobTokenTest do
133 135 build_conn()
134 136 |> put_req_header("x-repo-path", repo.disk_path)
135 137 |> put_req_header("content-type", "text/plain")
138 + |> put_req_header("x-gitgud-hook-token", GitGudWeb.Plugs.HookAuth.secret())
136 139 |> post("/_hooks/pre-receive", "#{@zero} #{sha} refs/heads/main\n")
137 140
138 141 assert "DENY\n" <> reason = conn.resp_body
added test/git_gud_web/plugs/hook_auth_test.exs
+95 −0
@@ -0,0 +1,95 @@
1 +defmodule GitGudWeb.Plugs.HookAuthTest do
2 + @moduledoc """
3 + The hook and SSH-wrapper endpoints answer only this node's own hooks:
4 + the listener is public, so a caller has to show the secret the node
5 + handed its git processes.
6 + """
7 +
8 + use GitGudWeb.ConnCase, async: false
9 +
10 + import GitGud.AccountsFixtures
11 + import GitGud.ForgeFixtures
12 +
13 + alias GitGudWeb.Plugs.HookAuth
14 +
15 + @zero String.duplicate("0", 40)
16 +
17 + defp hook_post(conn, path, body, token) do
18 + conn =
19 + if token, do: put_req_header(conn, HookAuth.header(), token), else: conn
20 +
21 + conn
22 + |> put_req_header("content-type", "text/plain")
23 + |> post(path, body)
24 + end
25 +
26 + setup do
27 + {_owner, repo} = repository_fixture()
28 + %{repo: repo}
29 + end
30 +
31 + for path <- ["/_hooks/post-receive", "/_hooks/pre-receive"] do
32 + test "#{path} refuses a caller without the secret", %{conn: conn, repo: repo} do
33 + conn = put_req_header(conn, "x-repo-path", repo.disk_path)
34 + line = "#{@zero} #{String.duplicate("a", 40)} refs/heads/main\n"
35 +
36 + assert hook_post(conn, unquote(path), line, nil).status == 403
37 + assert hook_post(conn, unquote(path), line, "guess").status == 403
38 + end
39 + end
40 +
41 + test "a forged post-receive starts nothing", %{conn: conn, repo: repo} do
42 + victim = user_fixture()
43 +
44 + resp =
45 + conn
46 + |> put_req_header("x-repo-path", repo.disk_path)
47 + |> put_req_header("x-pusher-id", Integer.to_string(victim.id))
48 + |> hook_post(
49 + "/_hooks/post-receive",
50 + "#{@zero} #{String.duplicate("b", 40)} refs/heads/x\n",
51 + nil
52 + )
53 +
54 + assert resp.status == 403
55 + assert resp.halted
56 + end
57 +
58 + test "/_ssh/resolve refuses a caller without the secret", %{conn: conn, repo: repo} do
59 + body = Jason.encode!(%{"key_id" => "1", "repo" => repo.name, "cmd" => "git-receive-pack"})
60 +
61 + resp =
62 + conn
63 + |> put_req_header("content-type", "application/json")
64 + |> post("/_ssh/resolve", body)
65 +
66 + assert resp.status == 403
67 + refute resp.resp_body =~ "OK"
68 + end
69 +
70 + test "the node's own hook gets through", %{conn: conn, repo: repo} do
71 + resp =
72 + conn
73 + |> put_req_header("x-repo-path", repo.disk_path)
74 + |> hook_post("/_hooks/pre-receive", "", HookAuth.secret())
75 +
76 + assert resp.status == 200
77 + assert resp.resp_body =~ "OK"
78 +
79 + body = Jason.encode!(%{"key_id" => "999999999", "repo" => "a/b", "cmd" => "git-upload-pack"})
80 +
81 + resp =
82 + build_conn()
83 + |> put_req_header(HookAuth.header(), HookAuth.secret())
84 + |> put_req_header("content-type", "application/json")
85 + |> post("/_ssh/resolve", body)
86 +
87 + assert resp.status == 200
88 + assert resp.resp_body =~ "DENY"
89 + end
90 +
91 + test "the secret is in the environment git inherits" do
92 + assert System.get_env("GITGUD_HOOK_SECRET") == HookAuth.secret()
93 + assert byte_size(HookAuth.secret()) >= 32
94 + end
95 +end

Parents: dcb7d88