Pre-receive: fail-open is for an unreachable backend, never for a 403

3d64eb5 · Gabriel Morell · 2026-10-20 18:14

10 files +231 -19
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud_web/plugs/hook_auth.ex
+14 −0
@@ -53,9 +53,23 @@ defmodule GitGudWeb.Plugs.HookAuth do
53 53
54 54 System.put_env(@env, secret)
55 55 :persistent_term.put(@key, secret)
56 + warn_fail_open()
56 57 :ok
57 58 end
58 59
60 + # The hooks inherit this node's environment, so an operator who set
61 + # the pre-receive escape hatch here set it for every push. Say so at
62 + # boot: it's meant for an emergency, not to be forgotten in a unit file.
63 + defp warn_fail_open do
64 + if System.get_env("GITGUD_PREHOOK_FAIL_OPEN") == "1" do
65 + Logger.warning(
66 + "GITGUD_PREHOOK_FAIL_OPEN=1: pushes are let through WITHOUT branch protection " <>
67 + "whenever the pre-receive backend is unreachable or failing (5xx). " <>
68 + "Unset it once the emergency is over."
69 + )
70 + end
71 + end
72 +
59 73 @doc "This node's secret."
60 74 def secret do
61 75 case :persistent_term.get(@key, nil) do
modified priv/scripts/gitgud-prereceive
+61 −19
@@ -6,12 +6,19 @@
6 6 # we print the reason on stderr (visible to the pushing client) and
7 7 # exit non-zero so git aborts the push.
8 8 #
9 −# **Fail-closed**: if the protection backend is unreachable (curl
10 −# missing, Phoenix down, DNS broken, etc.), the push is REJECTED.
11 −# Better to refuse than to silently bypass branch protection. Operator
12 −# can override by setting `GITGUD_PREHOOK_FAIL_OPEN=1` if they
13 −# explicitly want fail-open behavior (e.g. during an emergency
14 −# unblock).
9 +# **Fail-closed**: if the protection backend can't give an answer, the
10 +# push is REJECTED. Better to refuse than to silently bypass branch
11 +# protection.
12 +#
13 +# `GITGUD_PREHOOK_FAIL_OPEN=1` is an emergency override, and it covers
14 +# exactly one case: the backend is *unreachable* — curl missing, the
15 +# connection refused or timed out, or the node answering 5xx. It never
16 +# covers an answer the backend gave on purpose: a 4xx (a missing or
17 +# wrong hook secret is a 403, an unknown repository a 404) is a
18 +# misconfiguration or someone probing, and letting the push through on
19 +# it would turn a broken install into one without branch protection.
20 +# Nor does it cover a hook with no GITGUD_HOOK_SECRET at all, which the
21 +# backend could only ever refuse.
15 22
16 23 set -eu
17 24
@@ -21,35 +28,70 @@ REPO_PATH="$(cd "$REPO_PATH" && pwd)"
21 28
22 29 STDIN_PAYLOAD="$(cat)"
23 30
24 −if ! command -v curl >/dev/null 2>&1; then
25 − echo "gitgud: curl not installed; cannot reach protection backend" >&2
31 +# The backend is unreachable: refuse, unless the operator opted into
32 +# fail-open, in which case say so as loudly as a hook can.
33 +unreachable() {
34 + echo "gitgud: $1" >&2
26 35 if [ "${GITGUD_PREHOOK_FAIL_OPEN:-}" = "1" ]; then
27 − echo "gitgud: GITGUD_PREHOOK_FAIL_OPEN=1 — allowing push anyway" >&2
36 + echo "gitgud: WARNING: GITGUD_PREHOOK_FAIL_OPEN=1 — allowing push WITHOUT branch protection" >&2
28 37 exit 0
29 38 fi
39 + echo "gitgud: refusing push (branch protection cannot be evaluated)" >&2
40 + exit 1
41 +}
42 +
43 +if ! command -v curl >/dev/null 2>&1; then
44 + unreachable "curl not installed; cannot reach protection backend"
45 +fi
46 +
47 +if [ -z "${GITGUD_HOOK_SECRET:-}" ]; then
48 + echo "gitgud: GITGUD_HOOK_SECRET is not set in the hook's environment;" >&2
49 + echo "gitgud: the protection backend would refuse this hook, so the push is refused" >&2
30 50 exit 1
31 51 fi
32 52
33 53 # The node's hook secret (GitGudWeb.Plugs.HookAuth), inherited from the
34 54 # git process that runs this hook. Fed to curl on stdin rather than as
35 55 # 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 \
56 +#
57 +# No `-f`: an HTTP error status has to stay distinguishable from a
58 +# connection that never happened. curl exits non-zero only for the
59 +# latter; the status code comes back as the last line of the output.
60 +set +e
61 +RESP=$(printf 'x-gitgud-hook-token: %s\n' "${GITGUD_HOOK_SECRET}" | curl -s -X POST \
62 + --connect-timeout 10 \
37 63 -H @- \
38 64 -H "content-type: text/plain" \
39 65 -H "x-repo-path: ${REPO_PATH}" \
40 66 --data-binary "${STDIN_PAYLOAD}" \
41 − "${GITGUD_PREHOOK_URL}" 2>/dev/null) || {
42 − echo "gitgud: protection backend unreachable at ${GITGUD_PREHOOK_URL}" >&2
43 − if [ "${GITGUD_PREHOOK_FAIL_OPEN:-}" = "1" ]; then
44 − echo "gitgud: GITGUD_PREHOOK_FAIL_OPEN=1 — allowing push anyway" >&2
45 − exit 0
67 + -w '\n%{http_code}' \
68 + "${GITGUD_PREHOOK_URL}" 2>/dev/null)
69 +CURL_EXIT=$?
70 +set -e
71 +
72 +if [ "$CURL_EXIT" -ne 0 ]; then
73 + unreachable "protection backend unreachable at ${GITGUD_PREHOOK_URL} (curl exit ${CURL_EXIT})"
74 +fi
75 +
76 +HTTP_CODE=$(printf '%s' "$RESP" | tail -n 1)
77 +BODY=$(printf '%s' "$RESP" | sed '$d')
78 +
79 +case "$HTTP_CODE" in
80 + 2??) ;;
81 + 5??)
82 + unreachable "protection backend at ${GITGUD_PREHOOK_URL} failed (HTTP ${HTTP_CODE})"
83 + ;;
84 + *)
85 + echo "gitgud: protection backend refused the hook (HTTP ${HTTP_CODE}); push refused" >&2
86 + if [ "$HTTP_CODE" = "403" ]; then
87 + echo "gitgud: check that GITGUD_HOOK_SECRET matches the node's" >&2
46 88 fi
47 − echo "gitgud: refusing push (branch protection cannot be evaluated)" >&2
48 89 exit 1
49 − }
90 + ;;
91 +esac
50 92
51 −STATUS=$(printf '%s' "$RESP" | head -n1 | awk '{print $1}')
52 −REASON=$(printf '%s' "$RESP" | tail -n +2)
93 +STATUS=$(printf '%s' "$BODY" | head -n1 | awk '{print $1}')
94 +REASON=$(printf '%s' "$BODY" | tail -n +2)
53 95
54 96 if [ "$STATUS" = "OK" ]; then
55 97 exit 0
added test/git_gud_web/prereceive_script_test.exs
+156 −0
@@ -0,0 +1,156 @@
1 +defmodule GitGudWeb.PrereceiveScriptTest do
2 + @moduledoc """
3 + `priv/scripts/gitgud-prereceive`, run for real against a stand-in
4 + backend: a socket that answers whatever status this test says.
5 +
6 + What it covers is when `GITGUD_PREHOOK_FAIL_OPEN=1` may let a push
7 + through. Only when the backend can't be reached (or fails with 5xx) —
8 + never on an answer it gave: a 403 from a missing or wrong hook secret
9 + used to read as "unreachable" (`curl -f`), so with fail-open on, a
10 + misconfigured install pushed straight past branch protection.
11 + """
12 + use ExUnit.Case, async: true
13 +
14 + @script Path.expand("../../priv/scripts/gitgud-prereceive", __DIR__)
15 + @zero String.duplicate("0", 40)
16 + @stdin "#{@zero} #{String.duplicate("a", 40)} refs/heads/main\n"
17 +
18 + setup do
19 + cond do
20 + System.find_executable("curl") == nil -> {:skip, "curl not on PATH"}
21 + System.find_executable("sh") == nil -> {:skip, "sh not on PATH"}
22 + true -> :ok
23 + end
24 + end
25 +
26 + describe "the backend answers" do
27 + test "OK lets the push through" do
28 + url = serve(200, "OK\n\n")
29 + assert {_, 0} = run(url)
30 + end
31 +
32 + test "DENY refuses it, with the reason" do
33 + url = serve(200, "DENY\nrefs/heads/main: force-push blocked by branch protection\n")
34 + assert {out, 1} = run(url)
35 + assert out =~ "force-push blocked"
36 + end
37 +
38 + test "a 403 refuses it, fail-open or not" do
39 + url = serve(403, "DENY forbidden\n")
40 + assert {out, 1} = run(url, %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
41 + assert out =~ "403"
42 + refute out =~ "allowing push"
43 + end
44 +
45 + test "a 404 refuses it, fail-open or not" do
46 + url = serve(404, "unknown repo\n")
47 + assert {_, 1} = run(url, %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
48 + end
49 +
50 + test "a 500 refuses it, unless fail-open is on" do
51 + assert {_, 1} = run(serve(500, "boom"))
52 + assert {out, 0} = run(serve(500, "boom"), %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
53 + assert out =~ "WITHOUT branch protection"
54 + end
55 + end
56 +
57 + describe "the backend can't be reached" do
58 + test "refuses the push, unless fail-open is on" do
59 + url = closed_url()
60 + assert {out, 1} = run(url)
61 + assert out =~ "unreachable"
62 + assert {_, 0} = run(url, %{"GITGUD_PREHOOK_FAIL_OPEN" => "1"})
63 + end
64 + end
65 +
66 + test "with no hook secret it refuses without asking, fail-open or not" do
67 + url = closed_url()
68 + assert {out, 1} = run(url, %{"GITGUD_HOOK_SECRET" => "", "GITGUD_PREHOOK_FAIL_OPEN" => "1"})
69 + assert out =~ "GITGUD_HOOK_SECRET"
70 + end
71 +
72 + # ── helpers ─────────────────────────────────────────────────────────
73 +
74 + defp run(url, env \\ %{}) do
75 + dir = Path.join(System.tmp_dir!(), "gitgud-prerecv-#{System.unique_integer([:positive])}")
76 + File.mkdir_p!(dir)
77 + on_exit(fn -> File.rm_rf!(dir) end)
78 +
79 + stdin = Path.join(dir, "stdin")
80 + File.write!(stdin, @stdin)
81 +
82 + env =
83 + Map.merge(
84 + %{
85 + "GITGUD_PREHOOK_URL" => url,
86 + "GITGUD_HOOK_SECRET" => "a-secret-of-some-length",
87 + "GITGUD_PREHOOK_FAIL_OPEN" => "",
88 + "GIT_DIR" => dir
89 + },
90 + env
91 + )
92 +
93 + System.cmd("sh", ["-c", ~s(exec "$0" < "$1"), @script, stdin],
94 + env: Enum.to_list(env),
95 + stderr_to_stdout: true
96 + )
97 + end
98 +
99 + # A one-shot HTTP server on a free port: reads the request (headers,
100 + # then content-length bytes of body) and answers `status` with `body`.
101 + defp serve(status, body) do
102 + {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true])
103 + {:ok, port} = :inet.port(listen)
104 +
105 + pid =
106 + spawn_link(fn ->
107 + {:ok, sock} = :gen_tcp.accept(listen, 10_000)
108 + _ = read_request(sock, "")
109 +
110 + :ok =
111 + :gen_tcp.send(
112 + sock,
113 + "HTTP/1.1 #{status} X\r\ncontent-length: #{byte_size(body)}\r\n" <>
114 + "connection: close\r\n\r\n" <> body
115 + )
116 +
117 + :gen_tcp.close(sock)
118 + :gen_tcp.close(listen)
119 + end)
120 +
121 + on_exit(fn -> Process.exit(pid, :kill) end)
122 + "http://127.0.0.1:#{port}/_hooks/pre-receive"
123 + end
124 +
125 + defp read_request(sock, acc) do
126 + case :binary.split(acc, "\r\n\r\n") do
127 + [head, rest] ->
128 + len =
129 + case Regex.run(~r/content-length:\s*(\d+)/i, head) do
130 + [_, n] -> String.to_integer(n)
131 + _ -> 0
132 + end
133 +
134 + read_body(sock, rest, len)
135 +
136 + [_] ->
137 + {:ok, data} = :gen_tcp.recv(sock, 0, 10_000)
138 + read_request(sock, acc <> data)
139 + end
140 + end
141 +
142 + defp read_body(_sock, body, len) when byte_size(body) >= len, do: body
143 +
144 + defp read_body(sock, body, len) do
145 + {:ok, data} = :gen_tcp.recv(sock, 0, 10_000)
146 + read_body(sock, body <> data, len)
147 + end
148 +
149 + # A port nothing listens on.
150 + defp closed_url do
151 + {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false])
152 + {:ok, port} = :inet.port(listen)
153 + :gen_tcp.close(listen)
154 + "http://127.0.0.1:#{port}/_hooks/pre-receive"
155 + end
156 +end

Parents: fdbca00