Files
Juan DenisandTyler Boykin 9fb0a6cab5 fix(security): close git argument injection in repo_url/app_path/branch (GHSA-8vx6-432p-h62q)
An authenticated user of any role could inject arguments into git
subprocesses running as the backend user (root under systemd):

- repo_url was passed unvalidated to git ls-remote/clone, enabling the
  ext:: transport (host command execution on git < 2.52 defaults) and
  file:// local-repository disclosure (all git versions).
- app_path on POST /deploy/clone was passed without a '--' terminator,
  enabling --upload-pack=<cmd> option injection independent of git
  version and protocol.*.allow policy.
- branch flowed into 'git fetch origin <branch>' as an unterminated
  refspec.

Fix (new app/utils/git_security.py applied at every affected sink):

- repo_url scheme allowlist (https/http/git/ssh + scp-like SSH);
  rejects ext::/file://, local paths, leading '-', whitespace.
- '-c protocol.ext.allow=never -c protocol.file.allow=never' pinned on
  every remote-touching git call, plus a restrictive GIT_ALLOW_PROTOCOL
  in the subprocess environment.
- '--' positional terminator before URL/path/refspec arguments —
  co-load-bearing with protocol pinning, which alone does not stop
  option injection such as --upload-pack=<cmd>.
- branch/ref validation (leading '-', ref-format metacharacters) for
  clone --branch, fetch refspecs and stored deploy configs.
- POST /deploy/clone now confines app_path to the managed apps root,
  mirroring how /apps/from-repository derives it server-side.

Adds 31 regression tests; bumps version to 1.7.86.

Reported-by: CaptBoykin
Co-authored-by: Tyler Boykin <25166954+CaptBoykin@users.noreply.github.com>
2026-08-11 19:43:46 -04:00

239 lines
10 KiB
Python

"""Regression tests for GHSA-8vx6-432p-h62q — authenticated argument injection
into git subprocesses (repo_url transport selection, --upload-pack option
injection via app_path, branch refspec injection).
Pure unit tests: validators are exercised directly, and the git services are
tested with subprocess.run mocked so no git binary or network is needed.
"""
from unittest import mock
from app.utils.git_security import (
ALLOWED_REPO_SCHEMES,
git_argv,
git_env,
validate_clone_path,
validate_ref_name,
validate_repo_url,
)
from app.services.git_service import GitService
from app.services.git_deploy_service import GitDeployService
# --------------------------------------------------------------------------- #
# validate_repo_url
# --------------------------------------------------------------------------- #
class TestValidateRepoUrl:
def test_accepts_https(self):
assert validate_repo_url('https://github.com/org/repo.git') is None
def test_accepts_http_git_ssh_schemes(self):
assert validate_repo_url('http://git.example.com/org/repo.git') is None
assert validate_repo_url('git://git.example.com/org/repo.git') is None
assert validate_repo_url('ssh://git@example.com/org/repo.git') is None
def test_accepts_scp_like_ssh(self):
assert validate_repo_url('git@github.com:org/repo.git') is None
def test_rejects_ext_transport(self):
assert validate_repo_url('ext::sh -c id>&2') is not None
assert validate_repo_url('fd::17/foo') is not None
def test_rejects_file_scheme(self):
assert validate_repo_url('file:///etc/secret-repo') is not None
def test_rejects_local_paths(self):
assert validate_repo_url('/var/serverkit/apps/seed') is not None
assert validate_repo_url('../outside') is not None
assert validate_repo_url('relative/path') is not None
def test_rejects_leading_dash(self):
assert validate_repo_url('--upload-pack=evil') is not None
def test_rejects_whitespace_and_control_chars(self):
assert validate_repo_url('https://example.com/a b.git') is not None
assert validate_repo_url('https://example.com/a\nb.git') is not None
def test_rejects_empty(self):
assert validate_repo_url('') is not None
assert validate_repo_url(None) is not None
assert validate_repo_url(' ') is not None
# --------------------------------------------------------------------------- #
# validate_ref_name
# --------------------------------------------------------------------------- #
class TestValidateRefName:
def test_accepts_common_branch_names(self):
assert validate_ref_name('main') is None
assert validate_ref_name('feature/login-page') is None
assert validate_ref_name('release-1.2.3') is None
def test_absent_ref_is_allowed(self):
assert validate_ref_name(None) is None
assert validate_ref_name('') is None
def test_rejects_option_injection(self):
assert validate_ref_name('--upload-pack=sh -c id') is not None
assert validate_ref_name('-oFoo') is not None
def test_rejects_ref_metacharacters(self):
assert validate_ref_name('foo..bar') is not None
assert validate_ref_name('foo bar') is not None
assert validate_ref_name('foo:bar') is not None
assert validate_ref_name('foo@{bar') is not None
# --------------------------------------------------------------------------- #
# validate_clone_path
# --------------------------------------------------------------------------- #
class TestValidateClonePath:
def test_accepts_absolute_path(self):
assert validate_clone_path('/var/serverkit/apps/myapp') is None
def test_rejects_option_injection(self):
assert validate_clone_path('--upload-pack=sh -c "id>&2"') is not None
def test_rejects_relative_path(self):
assert validate_clone_path('apps/myapp') is not None
def test_rejects_empty(self):
assert validate_clone_path('') is not None
assert validate_clone_path(None) is not None
# --------------------------------------------------------------------------- #
# argv/env helpers
# --------------------------------------------------------------------------- #
class TestHelpers:
def test_git_argv_pins_protocols(self):
argv = git_argv('ls-remote', '--heads', '--', 'https://x/y.git')
assert argv[:5] == [
'git',
'-c', 'protocol.ext.allow=never',
'-c', 'protocol.file.allow=never',
]
assert argv[-4:] == ['ls-remote', '--heads', '--', 'https://x/y.git']
def test_git_env_restricts_protocols(self):
env = git_env()
assert env['GIT_ALLOW_PROTOCOL'] == ':'.join(ALLOWED_REPO_SCHEMES)
assert 'ext' not in env['GIT_ALLOW_PROTOCOL'].split(':')
assert 'file' not in env['GIT_ALLOW_PROTOCOL'].split(':')
# --------------------------------------------------------------------------- #
# GitService.clone_repository
# --------------------------------------------------------------------------- #
class TestCloneRepository:
def _run_clone(self, app_path='/tmp/sk-clone-target', repo_url='https://x/y.git',
branch='main'):
completed = mock.Mock(returncode=0, stdout='', stderr='')
with mock.patch('app.services.git_service.subprocess.run',
return_value=completed) as run_mock, \
mock.patch('app.services.git_service.os.path.exists', return_value=False):
result = GitService.clone_repository(app_path, repo_url, branch)
return result, run_mock
def test_valid_clone_invocation_is_hardened(self):
result, run_mock = self._run_clone()
assert result['success'] is True
argv = run_mock.call_args[0][0]
# Protocol pinning present.
assert 'protocol.ext.allow=never' in argv
assert 'protocol.file.allow=never' in argv
# '--' terminator separates options from positionals.
terminator = argv.index('--')
assert argv[terminator + 1] == 'https://x/y.git'
assert argv[terminator + 2] == '/tmp/sk-clone-target'
# Restrictive protocol env.
assert run_mock.call_args[1]['env']['GIT_ALLOW_PROTOCOL'] == 'https:http:git:ssh'
def test_ext_transport_rejected_without_subprocess(self):
result, run_mock = self._run_clone(repo_url='ext::sh -c id>&2')
assert result['success'] is False
run_mock.assert_not_called()
def test_file_scheme_rejected_without_subprocess(self):
result, run_mock = self._run_clone(repo_url='file:///etc/repo')
assert result['success'] is False
run_mock.assert_not_called()
def test_local_path_repo_rejected_without_subprocess(self):
result, run_mock = self._run_clone(repo_url='/var/serverkit/apps/seed')
assert result['success'] is False
run_mock.assert_not_called()
def test_upload_pack_option_in_app_path_rejected_without_subprocess(self):
result, run_mock = self._run_clone(
app_path='--upload-pack=sh -c "id>&2"')
assert result['success'] is False
run_mock.assert_not_called()
def test_malicious_branch_rejected_without_subprocess(self):
result, run_mock = self._run_clone(branch='--upload-pack=evil')
assert result['success'] is False
run_mock.assert_not_called()
# --------------------------------------------------------------------------- #
# GitService.get_remote_branches_from_url
# --------------------------------------------------------------------------- #
class TestGetRemoteBranchesFromUrl:
def _run_ls_remote(self, repo_url):
completed = mock.Mock(returncode=0, stdout='abc123\trefs/heads/main\n',
stderr='')
with mock.patch('app.services.git_service.subprocess.run',
return_value=completed) as run_mock:
result = GitService.get_remote_branches_from_url(repo_url)
return result, run_mock
def test_valid_url_uses_terminator_and_pinning(self):
result, run_mock = self._run_ls_remote('https://github.com/org/repo.git')
assert result['success'] is True
assert result['branches'] == ['main']
argv = run_mock.call_args[0][0]
assert 'protocol.ext.allow=never' in argv
assert argv[-2:] == ['--', 'https://github.com/org/repo.git']
def test_ext_transport_rejected_without_subprocess(self):
result, run_mock = self._run_ls_remote('ext::sh -c id>&2')
assert result['success'] is False
run_mock.assert_not_called()
def test_file_scheme_rejected_without_subprocess(self):
result, run_mock = self._run_ls_remote('file:///etc/repo')
assert result['success'] is False
run_mock.assert_not_called()
def test_leading_dash_rejected_without_subprocess(self):
result, run_mock = self._run_ls_remote('--upload-pack=evil')
assert result['success'] is False
run_mock.assert_not_called()
# --------------------------------------------------------------------------- #
# GitDeployService._git_pull
# --------------------------------------------------------------------------- #
class TestGitPull:
def _run_pull(self, branch='main'):
completed = mock.Mock(returncode=0, stdout='', stderr='')
with mock.patch('app.services.git_deploy_service.subprocess.run',
return_value=completed) as run_mock, \
mock.patch('app.services.git_deploy_service.os.path.exists',
return_value=True):
result = GitDeployService._git_pull('/srv/app', branch)
return result, run_mock
def test_fetch_uses_terminator(self):
result, run_mock = self._run_pull('main')
assert result['success'] is True
fetch_argv = run_mock.call_args_list[0][0][0]
assert fetch_argv[-3:] == ['origin', '--', 'main']
assert 'protocol.ext.allow=never' in fetch_argv
def test_malicious_branch_rejected_without_subprocess(self):
result, run_mock = self._run_pull('--upload-pack=evil')
assert result['success'] is False
run_mock.assert_not_called()