neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Hooks and the SSH shim prove they're this node's own
fe05dfe · Gabriel Morell · 2026-10-18 18:50
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