From 92afc14f83a9996cd6b9ee39a7fd7d2b24def94e Mon Sep 17 00:00:00 2001 From: maksimtech <268127343+maksimtech@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:20:39 +0200 Subject: [PATCH] fix(release): trust origin by its host, not by a substring of its URL bump_version.py opens the release pull request with auto-merge only when origin is this repository on GitHub. It asked whether "github.com" and "maksimtech/patchradar" occurred anywhere in the URL, so a host of anyone's choosing passed (https://evil.example/github.com/maksimtech/patchradar), and an upper-case host that is GitHub did not. CodeQL alert #60, py/incomplete-url-substring-sanitization, high, has kept Security Posture red on main since the change landed. The URL is now split into host and path (urlsplit, or the scp-like user@host:path form), the host must be github.com and the path must be the repository, whole. The new cases run against a real clone whose origin is set with `git remote set-url`; they failed 7 of 13 before the fix. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/bump_version.py | 27 +++++++++++++++++++- tests/test_release_through_pr.py | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 94db003..23b946f 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -8,6 +8,7 @@ import shutil import subprocess import sys +import urllib.parse from datetime import datetime from pathlib import Path @@ -153,12 +154,36 @@ def git_commit(version: str, files: list[str] | None = None, cwd: Path | None = print(f"✅ Git commit on {release_branch(version)}: chore: bump version to {version}") +def _github_repo(url: str) -> str | None: + """owner/name when the remote URL points at github.com, otherwise None. + + The host and the path are read apart and compared whole: a substring test lets + any host that mentions github.com in its path or its own name through (CodeQL + py/incomplete-url-substring-sanitization, alert #60). + """ + url = url.strip() + if "://" in url: + parts = urllib.parse.urlsplit(url) + host, path = parts.hostname or "", parts.path + else: + # scp-like syntax: [user@]host:path + scp = re.fullmatch(r"(?:[^@/:]+@)?([^@/:]+):(.+)", url) + if not scp: + return None + host, path = scp.group(1).lower(), scp.group(2) + if host != "github.com": + return None + path = path.strip("/") + return path.removesuffix(".git") or None + + def _origin_is_github(cwd: Path | None) -> bool: url = subprocess.run( ["git", "remote", "get-url", "origin"], capture_output=True, text=True, encoding="utf-8", check=True, cwd=cwd, ).stdout - return "github.com" in url and RELEASE_REPO in url + repo = _github_repo(url) + return repo is not None and repo.lower() == RELEASE_REPO.lower() def git_push(version: str, cwd: Path | None = None) -> None: diff --git a/tests/test_release_through_pr.py b/tests/test_release_through_pr.py index 5832e13..c204fc1 100644 --- a/tests/test_release_through_pr.py +++ b/tests/test_release_through_pr.py @@ -114,3 +114,47 @@ def test_the_bump_is_committed_on_a_release_branch_and_main_is_left_alone(repo): assert _git(remote, "log", "-1", "--format=%s", "release/2026.47") == "chore: bump version to 2026.47" assert _git(remote, "log", "-1", "--format=%s", "main") == "initial", "main was pushed" assert _git(work, "rev-parse", "--abbrev-ref", "HEAD") == "main" + + +# --- who opens the pull request ---------------------------------------------------- +# +# The pull request is opened with gh, with auto-merge, only when origin is this +# repository on GitHub. CodeQL alert #60 (py/incomplete-url-substring-sanitization) +# was right about the first version: it asked whether "github.com" and the repository +# name occurred anywhere in the URL, which a host of anyone's choosing satisfies. +# The URLs below are set on a real clone with `git remote set-url`; git does not +# contact them to answer `git remote get-url`. + + +@pytest.mark.parametrize("url", [ + "https://github.com/maksimtech/patchradar.git", + "https://github.com/maksimtech/patchradar", + "https://GitHub.com/maksimtech/patchradar.git", + "git@github.com:maksimtech/patchradar.git", + "ssh://git@github.com/maksimtech/patchradar.git", +]) +def test_origin_on_github_is_recognised(repo, url): + work, _ = repo + _git(work, "remote", "set-url", "origin", url) + assert bump._origin_is_github(work) + + +@pytest.mark.parametrize("url", [ + "https://evil.example/github.com/maksimtech/patchradar.git", + "https://github.com.evil.example/maksimtech/patchradar.git", + "https://evil.example/?github.com/maksimtech/patchradar", + "git@evil.example:github.com/maksimtech/patchradar.git", + "https://github.com/maksimtech/patchradar-fork.git", + "https://github.com/someone/maksimtech/patchradar.git", + "https://github.com/someone-else/patchradar.git", +]) +def test_origin_that_only_mentions_github_is_not_trusted(repo, url): + work, _ = repo + _git(work, "remote", "set-url", "origin", url) + assert not bump._origin_is_github(work) + + +def test_a_local_origin_is_not_github(repo): + work, remote = repo + assert _git(work, "remote", "get-url", "origin") == str(remote) + assert not bump._origin_is_github(work)