From ca6d257f30c1102df6bff7d6370d0cc2ec9bd1a0 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 3 Jun 2026 14:45:56 +0000 Subject: [PATCH] test(git-gate): add shell-escaping regression tests (issue #159) Cover all six pathological character classes (single-quote, double-quote, space, semicolon, newline, backtick) in both upstream URL and name positions. Each case validates rendered output via `sh -n` and asserts the original value is preserved verbatim after shlex.quote encoding. Also add `sh -n` smoke tests for the static pre-receive and access-hook scripts. --- tests/unit/test_git_gate.py | 98 +++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index f3f2759..4f4a7ea 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -282,5 +282,103 @@ class TestPrepare(unittest.TestCase): self.assertIn("exec git daemon", content) +class TestShellEscaping(unittest.TestCase): + """Regression tests: all three render functions must produce syntactically + valid sh code even when names and upstream URLs contain shell-special + characters. Tests construct GitGateUpstream directly — bypassing manifest + name validation — so the rendering layer is exercised in isolation.""" + + _MALICIOUS_URL_CASES = [ + ("single_quote", "ssh://git@host/path'with'quotes.git"), + ("double_quote", 'ssh://git@host/path"with"quotes.git'), + ("space", "ssh://git@host/path with spaces.git"), + ("semicolon", "ssh://git@host/path;evil.git"), + ("newline", "ssh://git@host/path\nwith\nnewlines.git"), + ("backtick", "ssh://git@host/path`whoami`.git"), + ] + + _MALICIOUS_NAME_CASES = [ + ("single_quote", "repo'name"), + ("double_quote", 'repo"name'), + ("space", "repo name"), + ("semicolon", "repo;name"), + ("newline", "repo\nname"), + ("backtick", "repo`name"), + ] + + def _make_upstream(self, url: str, name: str = "myrepo") -> GitGateUpstream: + return GitGateUpstream( + name=name, + upstream_url=url, + upstream_host="host", + upstream_port="22", + identity_file="/key", + known_host_key="", + ) + + def _assert_valid_sh(self, script: str, label: str = "") -> None: + import subprocess + fd, path = tempfile.mkstemp(suffix=".sh") + try: + with os.fdopen(fd, "w") as f: + f.write(script) + result = subprocess.run( + ["sh", "-n", path], capture_output=True, text=True, + ) + self.assertEqual( + 0, result.returncode, + f"sh -n failed{(' for ' + label) if label else ''}: {result.stderr}", + ) + finally: + os.unlink(path) + + def test_hook_renders_valid_sh(self): + self._assert_valid_sh(git_gate_render_hook(), "pre-receive hook") + + def test_access_hook_renders_valid_sh(self): + self._assert_valid_sh(git_gate_render_access_hook(), "access hook") + + def test_entrypoint_with_pathological_upstream_url_renders_valid_sh(self): + for label, url in self._MALICIOUS_URL_CASES: + with self.subTest(char=label): + script = git_gate_render_entrypoint((self._make_upstream(url),)) + self._assert_valid_sh(script, label) + + def test_entrypoint_upstream_url_value_preserved_after_quoting(self): + import shlex as _shlex + for label, url in self._MALICIOUS_URL_CASES: + with self.subTest(char=label): + script = git_gate_render_entrypoint((self._make_upstream(url),)) + # The quoted form of the URL must appear verbatim in the script so + # the shell reconstructs exactly the original value at runtime. + expected = f"init_repo {_shlex.quote('myrepo')} {_shlex.quote(url)}" + self.assertIn( + expected, script, + f"{label}: expected quoted form not found in script", + ) + + def test_entrypoint_with_pathological_name_renders_valid_sh(self): + for label, name in self._MALICIOUS_NAME_CASES: + with self.subTest(char=label): + script = git_gate_render_entrypoint(( + self._make_upstream("ssh://git@github.com/foo/bar.git", name=name), + )) + self._assert_valid_sh(script, label) + + def test_entrypoint_name_value_preserved_after_quoting(self): + import shlex as _shlex + url = "ssh://git@github.com/foo/bar.git" + for label, name in self._MALICIOUS_NAME_CASES: + with self.subTest(char=label): + script = git_gate_render_entrypoint(( + self._make_upstream(url, name=name), + )) + expected = f"init_repo {_shlex.quote(name)} {_shlex.quote(url)}" + self.assertIn( + expected, script, + f"{label}: expected quoted form not found in script", + ) + + if __name__ == "__main__": unittest.main() -- 2.52.0