From ec9f30a82b488c3627529947d8bbf624d1d6f420 Mon Sep 17 00:00:00 2001 From: Juniper Bevensee Date: Fri, 24 Jul 2026 10:13:37 +1200 Subject: [PATCH] fix(git-auth): refuse non-GitHub hosts in the App credential helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the host-allowlist our upstream contribution ships (NousResearch/hermes-agent#69972, commit 99fe8c7): git invokes credential helpers for EVERY https remote, so a helper that ignores the request's host= field hands the installation token to any URL a repo, submodule, or redirect points at. The boot-written gitconfig already scopes the helper to https://github.com; this enforces the same boundary inside the helper itself. get-credential now parses git's stdin description block and answers only for github.com / gist.github.com over https — anything else emits nothing and exits 0 so git falls through to the next helper. An empty request (manual smoke test) still answers, so existing invocations are unaffected. Co-Authored-By: Claude Fable 5 --- hermes_cli/github_app_token.py | 35 +++++++++++++++ tests/hermes_cli/test_github_app_token.py | 52 +++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/hermes_cli/github_app_token.py b/hermes_cli/github_app_token.py index 4c8df5c3c..acdf33823 100644 --- a/hermes_cli/github_app_token.py +++ b/hermes_cli/github_app_token.py @@ -232,6 +232,30 @@ def _default_home() -> Path: return Path(os.environ.get("HOME", "/opt/data/home")).parent +# Hosts this helper will answer for. Git invokes credential helpers for EVERY +# https remote, so an unscoped helper that ignored the request's host would +# hand the installation token to any URL a repo (or a malicious submodule/ +# redirect) points at. The boot-written gitconfig already scopes the helper to +# https://github.com; this allowlist enforces the same boundary inside the +# helper itself, so a mis-scoped or globally-wired config can't leak the token. +ALLOWED_HOSTS = frozenset({"github.com", "gist.github.com"}) + + +def _read_credential_request() -> dict: + """Parse git's credential description block (key=value lines) from stdin.""" + request: dict = {} + try: + if sys.stdin is None or sys.stdin.isatty(): + return request + for line in sys.stdin.read().splitlines(): + key, sep, value = line.partition("=") + if sep: + request[key.strip()] = value.strip() + except OSError: + pass + return request + + def main(argv: Optional[list] = None) -> int: parser = argparse.ArgumentParser(prog="hermes_cli.github_app_token") sub = parser.add_subparsers(dest="cmd", required=True) @@ -253,6 +277,17 @@ def main(argv: Optional[list] = None) -> int: if args.cmd in ("get-credential", "get"): if args.op not in (None, "get"): return 0 # store/erase are no-ops for a mint-on-demand helper + # Only answer for GitHub over https. For anything else, emit nothing + # and exit 0 so git falls through to other helpers / prompts — never + # send the token toward a host we don't recognize. An empty request + # (no stdin, e.g. a manual smoke-test invocation) still answers. + request = _read_credential_request() + host = request.get("host", "") + protocol = request.get("protocol", "") + if host and host not in ALLOWED_HOSTS: + return 0 + if protocol and protocol != "https": + return 0 home = args.home or _default_home() token = get_installation_token(home) if not token: diff --git a/tests/hermes_cli/test_github_app_token.py b/tests/hermes_cli/test_github_app_token.py index 56e21dc6e..98564bf46 100644 --- a/tests/hermes_cli/test_github_app_token.py +++ b/tests/hermes_cli/test_github_app_token.py @@ -283,3 +283,55 @@ def test_unknown_subcommand_names_valid_choices(capsys): gat.main(["mint"]) err = capsys.readouterr().err assert "get-credential" in err + + +# --------------------------------------------------------------------------- +# get-credential host allowlist — never answer for a host we don't recognize +# --------------------------------------------------------------------------- + + +def _pipe_stdin(monkeypatch, text: str) -> None: + import io + + monkeypatch.setattr(gat.sys, "stdin", io.StringIO(text)) + + +def test_get_credential_refuses_foreign_host(app_env, monkeypatch, capsys): + def boom(home, force=False): + raise AssertionError("must not mint for a non-GitHub host") + + monkeypatch.setattr(gat, "get_installation_token", boom) + _pipe_stdin(monkeypatch, "protocol=https\nhost=evil.example.com\n") + rc = gat.main(["get-credential", "--home", str(app_env)]) + assert rc == 0 # silent fall-through, git tries the next helper + assert capsys.readouterr().out == "" + + +def test_get_credential_refuses_non_https(app_env, monkeypatch, capsys): + def boom(home, force=False): + raise AssertionError("must not mint for a non-https request") + + monkeypatch.setattr(gat, "get_installation_token", boom) + _pipe_stdin(monkeypatch, "protocol=http\nhost=github.com\n") + rc = gat.main(["get-credential", "--home", str(app_env)]) + assert rc == 0 + assert capsys.readouterr().out == "" + + +@pytest.mark.parametrize("host", ["github.com", "gist.github.com"]) +def test_get_credential_answers_for_allowed_hosts(app_env, monkeypatch, capsys, host): + monkeypatch.setattr(gat, "get_installation_token", lambda home, force=False: "ghs_ok") + _pipe_stdin(monkeypatch, f"protocol=https\nhost={host}\n") + rc = gat.main(["get-credential", "--home", str(app_env)]) + out = capsys.readouterr().out + assert rc == 0 + assert "password=ghs_ok\n" in out + + +def test_get_credential_answers_on_empty_request(app_env, monkeypatch, capsys): + """No stdin description (manual smoke test / boot-style call) still answers.""" + monkeypatch.setattr(gat, "get_installation_token", lambda home, force=False: "ghs_ok") + _pipe_stdin(monkeypatch, "") + rc = gat.main(["get-credential", "--home", str(app_env)]) + assert rc == 0 + assert "password=ghs_ok\n" in capsys.readouterr().out