fix(git-gate): make the gateway access-hook executable regardless of copy transport #403

Merged
didericis merged 1 commits from fix-git-gate-access-hook-exec-bit into main 2026-07-17 22:00:57 -04:00
5 changed files with 82 additions and 8 deletions
@@ -94,6 +94,12 @@ def provision_git_gate(
transport.exec(["mkdir", "-p", "/etc/git-gate"])
transport.cp_into(str(plan.hook_script), "/etc/git-gate/pre-receive")
transport.cp_into(str(plan.access_hook_script), "/etc/git-gate/access-hook")
# The access-hook is exec'd directly (not via `sh`), so it needs the x bit.
# Set it here rather than trusting the copy to carry the staged 0o700:
# `docker cp` preserves source mode, but the Apple `container cp` does not,
# landing the hook 0o644 → EACCES when the git-http handler tries to exec it.
# chmod on the gateway side is backend-neutral and fixes every transport.
transport.exec(["chmod", "+x", "/etc/git-gate/access-hook"])
creds = _creds_dir(bottle_id)
transport.exec(["mkdir", "-p", creds])
for u in plan.upstreams:
+4 -2
View File
@@ -112,8 +112,10 @@ class GitGate(ABC):
access_hook = stage_dir / "git_gate_access_hook.sh"
access_hook.write_text(git_gate_render_access_hook())
# 0o700 (not 0o600): git daemon execs --access-hook directly,
# not via `sh`, so the script needs the x bit. docker cp
# preserves source mode into the container.
# not via `sh`, so the script needs the x bit. The gateway copy
# does not necessarily preserve this mode (`docker cp` does, the
# Apple `container cp` does not), so provision_git_gate re-applies
# +x on the gateway side — see backend/docker/gateway_provision.py.
access_hook.chmod(0o700)
upstreams_with_files: list[GitGateUpstream] = []
for u in upstreams:
+18 -6
View File
@@ -148,12 +148,24 @@ class GitHttpHandler(BaseHTTPRequestHandler):
"GIT_GATE_ACCESS_HOOK", "/etc/git-gate/access-hook",
)
peer = self.client_address[0]
hook = subprocess.run(
[hook_path, "upload-pack", str(repo_dir), peer, peer],
capture_output=True,
check=False,
timeout=GIT_GATE_TIMEOUT_SECS,
)
try:
hook = subprocess.run(
[hook_path, "upload-pack", str(repo_dir), peer, peer],
capture_output=True,
check=False,
timeout=GIT_GATE_TIMEOUT_SECS,
)
except (OSError, subprocess.SubprocessError) as exc:
# The access-hook couldn't be run (missing, not executable,
# timed out, …). Fail closed with a real HTTP error rather
# than letting the exception kill the handler thread — an
# unhandled exception closes the socket with no response, which
# the client sees as an opaque "empty reply from server".
self.log_message(
"access-hook could not run for %s: %s", parsed.path, exc,
)
self.send_error(503, "git-gate access-hook unavailable")
return
if hook.returncode != 0:
detail = (hook.stderr or hook.stdout).decode(
"utf-8", errors="replace",
+12
View File
@@ -69,6 +69,18 @@ class TestProvisionGitGate(unittest.TestCase):
self.assertEqual(1, len(exec_scripts))
self.assertIn("repo=/git/bottle1/${name}.git", exec_scripts[0][-1])
def test_makes_access_hook_executable_on_the_gateway(self) -> None:
# Regression: the access-hook is exec'd directly, so it needs the x
# bit. The copy alone can't be trusted to carry the staged 0o700
# (`docker cp` preserves mode, the Apple `container cp` does not),
# so provisioning must re-apply +x on the gateway side.
calls: list[list[str]] = []
with patch(_RUN, side_effect=_recorder(calls)):
provision_git_gate(DockerGatewayTransport("gw"), "bottle1", _plan(_up("foo")))
self.assertIn(
["docker", "exec", "gw", "chmod", "+x", "/etc/git-gate/access-hook"], calls,
)
def test_omits_known_hosts_copy_when_absent(self) -> None:
calls: list[list[str]] = []
with patch(_RUN, side_effect=_recorder(calls)):
+42
View File
@@ -364,6 +364,48 @@ class TestGitHttpBackend(unittest.TestCase):
self.assertIn("access-hook denied", logged)
self.assertIn("exit=2", logged)
def test_access_hook_that_cannot_run_fails_closed_503(self):
"""Regression: when the access-hook can't be exec'd (missing / not
executable — a PermissionError from subprocess.run), the handler must
fail closed with a real HTTP status instead of letting the exception
kill the thread, which closes the socket with no response and the
client sees an opaque "empty reply from server"."""
from http.server import ThreadingHTTPServer
import io
import sys
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "repo.git").mkdir()
old_root = os.environ.get("GIT_PROJECT_ROOT")
os.environ["GIT_PROJECT_ROOT"] = str(root)
self.addCleanup(self._restore_env, old_root)
server = ThreadingHTTPServer(("127.0.0.1", 0), GitHttpHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
self.addCleanup(server.shutdown)
self.addCleanup(server.server_close)
with mock.patch(
"bot_bottle.git_http_backend.subprocess.run",
side_effect=PermissionError(13, "Permission denied"),
):
buf = io.StringIO()
with mock.patch.object(sys, "stdout", buf):
req = urllib.request.Request(
f"http://127.0.0.1:{server.server_port}"
"/repo.git/info/refs?service=git-upload-pack",
method="GET",
)
try:
urllib.request.urlopen(req, timeout=5)
self.fail("expected HTTPError 503")
except urllib.error.HTTPError as e: # type: ignore
self.assertEqual(503, e.code)
self.assertIn("access-hook could not run", buf.getvalue())
@staticmethod
def _restore_env(value: str | None) -> None:
if value is None: