Skip to content

Commit ef3192c

Browse files
authored
Merge pull request #1792 from EliahKagan/popen
Fix two remaining Windows untrusted search path cases
2 parents 32c02d1 + 1f3caa3 commit ef3192c

File tree

9 files changed

+289
-84
lines changed

9 files changed

+289
-84
lines changed

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ repos:
2929
hooks:
3030
- id: shellcheck
3131
args: [--color]
32-
exclude: ^git/ext/
32+
exclude: ^test/fixtures/polyglot$|^git/ext/
3333

3434
- repo: https://github.com/pre-commit/pre-commit-hooks
3535
rev: v4.4.0

‎git/cmd.py‎

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
Iterator,
4747
List,
4848
Mapping,
49+
Optional,
4950
Sequence,
5051
TYPE_CHECKING,
5152
TextIO,
@@ -102,7 +103,7 @@ def handle_process_output(
102103
Callable[[bytes, "Repo", "DiffIndex"], None],
103104
],
104105
stderr_handler: Union[None, Callable[[AnyStr], None], Callable[[List[AnyStr]], None]],
105-
finalizer: Union[None, Callable[[Union[subprocess.Popen, "Git.AutoInterrupt"]], None]] =None,
106+
finalizer: Union[None, Callable[[Union[Popen, "Git.AutoInterrupt"]], None]] =None,
106107
decode_streams: bool=True,
107108
kill_after_timeout: Union[None, float] =None,
108109
) ->None:
@@ -207,6 +208,68 @@ def pump_stream(
207208
finalizer(process)
208209

209210

211+
def_safer_popen_windows(
212+
command: Union[str, Sequence[Any]],
213+
*,
214+
shell: bool=False,
215+
env: Optional[Mapping[str, str]] =None,
216+
**kwargs: Any,
217+
) ->Popen:
218+
"""Call :class:`subprocess.Popen` on Windows but don't include a CWD in the search.
219+
220+
This avoids an untrusted search path condition where a file like ``git.exe`` in a
221+
malicious repository would be run when GitPython operates on the repository. The
222+
process using GitPython may have an untrusted repository's working tree as its
223+
current working directory. Some operations may temporarily change to that directory
224+
before running a subprocess. In addition, while by default GitPython does not run
225+
external commands with a shell, it can be made to do so, in which case the CWD of
226+
the subprocess, which GitPython usually sets to a repository working tree, can
227+
itself be searched automatically by the shell. This wrapper covers all those cases.
228+
229+
:note: This currently works by setting the ``NoDefaultCurrentDirectoryInExePath``
230+
environment variable during subprocess creation. It also takes care of passing
231+
Windows-specific process creation flags, but that is unrelated to path search.
232+
233+
:note: The current implementation contains a race condition on :attr:`os.environ`.
234+
GitPython isn't thread-safe, but a program using it on one thread should ideally
235+
be able to mutate :attr:`os.environ` on another, without unpredictable results.
236+
See comments in https://github.com/gitpython-developers/GitPython/pull/1650.
237+
"""
238+
# CREATE_NEW_PROCESS_GROUP is needed for some ways of killing it afterwards. See:
239+
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
240+
# https://docs.python.org/3/library/subprocess.html#subprocess.CREATE_NEW_PROCESS_GROUP
241+
creationflags=subprocess.CREATE_NO_WINDOW|subprocess.CREATE_NEW_PROCESS_GROUP
242+
243+
# When using a shell, the shell is the direct subprocess, so the variable must be
244+
# set in its environment, to affect its search behavior. (The "1" can be any value.)
245+
ifshell:
246+
safer_env={} ifenvisNoneelsedict(env)
247+
safer_env["NoDefaultCurrentDirectoryInExePath"] ="1"
248+
else:
249+
safer_env=env
250+
251+
# When not using a shell, the current process does the search in a CreateProcessW
252+
# API call, so the variable must be set in our environment. With a shell, this is
253+
# unnecessary, in versions where https://github.com/python/cpython/issues/101283 is
254+
# patched. If not, in the rare case the ComSpec environment variable is unset, the
255+
# shell is searched for unsafely. Setting NoDefaultCurrentDirectoryInExePath in all
256+
# cases, as here, is simpler and protects against that. (The "1" can be any value.)
257+
withpatch_env("NoDefaultCurrentDirectoryInExePath", "1"):
258+
returnPopen(
259+
command,
260+
shell=shell,
261+
env=safer_env,
262+
creationflags=creationflags,
263+
**kwargs,
264+
)
265+
266+
267+
ifos.name=="nt":
268+
safer_popen=_safer_popen_windows
269+
else:
270+
safer_popen=Popen
271+
272+
210273
defdashify(string: str) ->str:
211274
returnstring.replace("_", "-")
212275

@@ -225,14 +288,6 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc
225288
## -- End Utilities -- @}
226289

227290

228-
ifos.name=="nt":
229-
# CREATE_NEW_PROCESS_GROUP is needed to allow killing it afterwards. See:
230-
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
231-
PROC_CREATIONFLAGS=subprocess.CREATE_NO_WINDOW|subprocess.CREATE_NEW_PROCESS_GROUP
232-
else:
233-
PROC_CREATIONFLAGS=0
234-
235-
236291
classGit(LazyMixin):
237292
"""The Git class manages communication with the Git binary.
238293
@@ -992,11 +1047,8 @@ def execute(
9921047
redacted_command,
9931048
'"kill_after_timeout" feature is not supported on Windows.',
9941049
)
995-
# Only search PATH, not CWD. This must be in the *caller* environment. The "1" can be any value.
996-
maybe_patch_caller_env=patch_env("NoDefaultCurrentDirectoryInExePath", "1")
9971050
else:
9981051
cmd_not_found_exception=FileNotFoundError
999-
maybe_patch_caller_env=contextlib.nullcontext()
10001052
# END handle
10011053

10021054
stdout_sink=PIPEifwith_stdoutelsegetattr(subprocess, "DEVNULL", None) oropen(os.devnull, "wb")
@@ -1011,20 +1063,18 @@ def execute(
10111063
universal_newlines,
10121064
)
10131065
try:
1014-
withmaybe_patch_caller_env:
1015-
proc=Popen(
1016-
command,
1017-
env=env,
1018-
cwd=cwd,
1019-
bufsize=-1,
1020-
stdin=(istreamorDEVNULL),
1021-
stderr=PIPE,
1022-
stdout=stdout_sink,
1023-
shell=shell,
1024-
universal_newlines=universal_newlines,
1025-
creationflags=PROC_CREATIONFLAGS,
1026-
**subprocess_kwargs,
1027-
)
1066+
proc=safer_popen(
1067+
command,
1068+
env=env,
1069+
cwd=cwd,
1070+
bufsize=-1,
1071+
stdin=(istreamorDEVNULL),
1072+
stderr=PIPE,
1073+
stdout=stdout_sink,
1074+
shell=shell,
1075+
universal_newlines=universal_newlines,
1076+
**subprocess_kwargs,
1077+
)
10281078
exceptcmd_not_found_exceptionaserr:
10291079
raiseGitCommandNotFound(redacted_command, err) fromerr
10301080
else:

‎git/index/fun.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
importsubprocess
2020

21-
fromgit.cmdimportPROC_CREATIONFLAGS, handle_process_output
21+
fromgit.cmdimporthandle_process_output, safer_popen
2222
fromgit.compatimportdefenc, force_bytes, force_text, safe_decode
2323
fromgit.excimportHookExecutionError, UnmergedEntriesError
2424
fromgit.objects.funimport (
@@ -98,13 +98,12 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
9898
relative_hp=Path(hp).relative_to(index.repo.working_dir).as_posix()
9999
cmd= ["bash.exe", relative_hp]
100100

101-
process=subprocess.Popen(
101+
process=safer_popen(
102102
cmd+list(args),
103103
env=env,
104104
stdout=subprocess.PIPE,
105105
stderr=subprocess.PIPE,
106106
cwd=index.repo.working_dir,
107-
creationflags=PROC_CREATIONFLAGS,
108107
)
109108
exceptExceptionasex:
110109
raiseHookExecutionError(hp, ex) fromex

‎git/util.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,17 @@ def _get_exe_extensions() -> Sequence[str]:
327327

328328

329329
defpy_where(program: str, path: Optional[PathLike] =None) ->List[str]:
330+
"""Perform a path search to assist :func:`is_cygwin_git`.
331+
332+
This is not robust for general use. It is an implementation detail of
333+
:func:`is_cygwin_git`. When a search following all shell rules is needed,
334+
:func:`shutil.which` can be used instead.
335+
336+
:note: Neither this function nor :func:`shutil.which` will predict the effect of an
337+
executable search on a native Windows system due to a :class:`subprocess.Popen`
338+
call without ``shell=True``, because shell and non-shell executable search on
339+
Windows differ considerably.
340+
"""
330341
# From: http://stackoverflow.com/a/377028/548792
331342
winprog_exts=_get_exe_extensions()
332343

‎test/fixtures/polyglot‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env sh
2+
# Valid script in both Bash and Python, but with different behavior.
3+
""":"
4+
echo'Ran intended hook.'>output.txt
5+
exit
6+
""""
7+
from pathlib import Path
8+
Path('payload.txt').write_text('Ran impostor hook!', encoding='utf-8')

‎test/lib/helper.py‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
importtextwrap
1515
importtime
1616
importunittest
17+
importvenv
1718

1819
importgitdb
1920

@@ -36,6 +37,7 @@
3637
"with_rw_repo",
3738
"with_rw_and_rw_remote_repo",
3839
"TestBase",
40+
"VirtualEnvironment",
3941
"TestCase",
4042
"SkipTest",
4143
"skipIf",
@@ -88,11 +90,11 @@ def with_rw_directory(func):
8890
test succeeds, but leave it otherwise to aid additional debugging."""
8991

9092
@wraps(func)
91-
defwrapper(self):
93+
defwrapper(self, *args, **kwargs):
9294
path=tempfile.mkdtemp(prefix=func.__name__)
9395
keep=False
9496
try:
95-
returnfunc(self, path)
97+
returnfunc(self, path, *args, **kwargs)
9698
exceptException:
9799
log.info(
98100
"Test %s.%s failed, output is at %r\n",
@@ -390,3 +392,46 @@ def _make_file(self, rela_path, data, repo=None):
390392
withopen(abs_path, "w") asfp:
391393
fp.write(data)
392394
returnabs_path
395+
396+
397+
classVirtualEnvironment:
398+
"""A newly created Python virtual environment for use in a test."""
399+
400+
__slots__= ("_env_dir",)
401+
402+
def__init__(self, env_dir, *, with_pip):
403+
ifos.name=="nt":
404+
self._env_dir=osp.realpath(env_dir)
405+
venv.create(self.env_dir, symlinks=False, with_pip=with_pip)
406+
else:
407+
self._env_dir=env_dir
408+
venv.create(self.env_dir, symlinks=True, with_pip=with_pip)
409+
410+
@property
411+
defenv_dir(self):
412+
"""The top-level directory of the environment."""
413+
returnself._env_dir
414+
415+
@property
416+
defpython(self):
417+
"""Path to the Python executable in the environment."""
418+
returnself._executable("python")
419+
420+
@property
421+
defpip(self):
422+
"""Path to the pip executable in the environment, or RuntimeError if absent."""
423+
returnself._executable("pip")
424+
425+
@property
426+
defsources(self):
427+
"""Path to a src directory in the environment, which may not exist yet."""
428+
returnos.path.join(self.env_dir, "src")
429+
430+
def_executable(self, basename):
431+
ifos.name=="nt":
432+
path=osp.join(self.env_dir, "Scripts", basename+".exe")
433+
else:
434+
path=osp.join(self.env_dir, "bin", basename)
435+
ifosp.isfile(path) orosp.islink(path):
436+
returnpath
437+
raiseRuntimeError(f"no regular file or symlink {path!r}")

0 commit comments

Comments
(0)