44# This module is part of GitPython and is released under
55# the BSD License: http://www.opensource.org/licenses/bsd-license.php
66
7- from contextlib import contextmanager
7+ import contextlib
8+ import re
9+
810import io
911import logging
1012import os
1921import threading
2022from collections import OrderedDict
2123from textwrap import dedent
24+ import mock
2225
2326from git .compat import (
2427 string_types ,
3134 is_posix ,
3235 is_win ,
3336)
34- from git .exc import CommandError
37+ from git .exc import CommandError , UnsafeOptionError , UnsafeProtocolError
3538from git .util import is_cygwin_git , cygpath , expand_path
3639
3740from .exc import (
5962__all__ = ('Git' ,)
6063
6164
65+ @contextlib .contextmanager
66+ def nullcontext (enter_result = None ):
67+ yield enter_result
68+
69+
6270# ==============================================================================
6371## @name Utilities
6472# ------------------------------------------------------------------------------
@@ -124,6 +132,59 @@ def pump_stream(cmdline, name, stream, is_decode, handler):
124132 return finalizer (process )
125133
126134
135+ def _safer_popen_windows (command , shell , env = None , ** kwargs ):
136+ """Call :class:`subprocess.Popen` on Windows but don't include a CWD in the search.
137+ This avoids an untrusted search path condition where a file like ``git.exe`` in a
138+ malicious repository would be run when GitPython operates on the repository. The
139+ process using GitPython may have an untrusted repository's working tree as its
140+ current working directory. Some operations may temporarily change to that directory
141+ before running a subprocess. In addition, while by default GitPython does not run
142+ external commands with a shell, it can be made to do so, in which case the CWD of
143+ the subprocess, which GitPython usually sets to a repository working tree, can
144+ itself be searched automatically by the shell. This wrapper covers all those cases.
145+ :note: This currently works by setting the ``NoDefaultCurrentDirectoryInExePath``
146+ environment variable during subprocess creation. It also takes care of passing
147+ Windows-specific process creation flags, but that is unrelated to path search.
148+ :note: The current implementation contains a race condition on :attr:`os.environ`.
149+ GitPython isn't thread-safe, but a program using it on one thread should ideally
150+ be able to mutate :attr:`os.environ` on another, without unpredictable results.
151+ See comments in https://github.com/gitpython-developers/GitPython/pull/1650.
152+ """
153+ # CREATE_NEW_PROCESS_GROUP is needed for some ways of killing it afterwards. See:
154+ # https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
155+ # https://docs.python.org/3/library/subprocess.html#subprocess.CREATE_NEW_PROCESS_GROUP
156+ creationflags = subprocess .CREATE_NO_WINDOW | subprocess .CREATE_NEW_PROCESS_GROUP
157+
158+ # When using a shell, the shell is the direct subprocess, so the variable must be
159+ # set in its environment, to affect its search behavior. (The "1" can be any value.)
160+ if shell :
161+ safer_env = {} if env is None else dict (env )
162+ safer_env ["NoDefaultCurrentDirectoryInExePath" ] = "1"
163+ else :
164+ safer_env = env
165+
166+ # When not using a shell, the current process does the search in a CreateProcessW
167+ # API call, so the variable must be set in our environment. With a shell, this is
168+ # unnecessary, in versions where https://github.com/python/cpython/issues/101283 is
169+ # patched. If not, in the rare case the ComSpec environment variable is unset, the
170+ # shell is searched for unsafely. Setting NoDefaultCurrentDirectoryInExePath in all
171+ # cases, as here, is simpler and protects against that. (The "1" can be any value.)
172+ with patch_env ("NoDefaultCurrentDirectoryInExePath" , "1" ):
173+ return Popen (
174+ command ,
175+ shell = shell ,
176+ env = safer_env ,
177+ creationflags = creationflags ,
178+ ** kwargs
179+ )
180+
181+
182+ if os .name == "nt" :
183+ safer_popen = _safer_popen_windows
184+ else :
185+ safer_popen = Popen
186+
187+
127188def dashify (string ):
128189 return string .replace ('_' , '-' )
129190
@@ -144,11 +205,6 @@ def dict_to_slots_and__excluded_are_none(self, d, excluded=()):
144205# value of Windows process creation flag taken from MSDN
145206CREATE_NO_WINDOW = 0x08000000
146207
147- ## CREATE_NEW_PROCESS_GROUP is needed to allow killing it afterwards,
148- # see https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
149- PROC_CREATIONFLAGS = (CREATE_NO_WINDOW | subprocess .CREATE_NEW_PROCESS_GROUP
150- if is_win else 0 )
151-
152208
153209class Git (LazyMixin ):
154210
@@ -171,9 +227,49 @@ class Git(LazyMixin):
171227
172228 _excluded_ = ('cat_file_all' , 'cat_file_header' , '_version_info' )
173229
230+ re_unsafe_protocol = re .compile ("(.+)::.+" )
231+
174232 def __getstate__ (self ):
175233 return slots_to_dict (self , exclude = self ._excluded_ )
176234
235+ @classmethod
236+ def check_unsafe_protocols (cls , url ):
237+ """
238+ Check for unsafe protocols.
239+ Apart from the usual protocols (http, git, ssh),
240+ Git allows "remote helpers" that have the form `<transport>::<address>`,
241+ one of these helpers (`ext::`) can be used to invoke any arbitrary command.
242+ See:
243+ - https://git-scm.com/docs/gitremote-helpers
244+ - https://git-scm.com/docs/git-remote-ext
245+ """
246+ match = cls .re_unsafe_protocol .match (url )
247+ if match :
248+ protocol = match .group (1 )
249+ raise UnsafeProtocolError (
250+ "The `" + protocol + "::` protocol looks suspicious, use `allow_unsafe_protocols=True` to allow it."
251+ )
252+
253+ @classmethod
254+ def check_unsafe_options (cls , options , unsafe_options ):
255+ """
256+ Check for unsafe options.
257+ Some options that are passed to `git <command>` can be used to execute
258+ arbitrary commands, this are blocked by default.
259+ """
260+ # Options can be of the form `foo` or `--foo bar` `--foo=bar`,
261+ # so we need to check if they start with "--foo" or if they are equal to "foo".
262+ bare_unsafe_options = [
263+ option .lstrip ("-" )
264+ for option in unsafe_options
265+ ]
266+ for option in options :
267+ for unsafe_option , bare_option in zip (unsafe_options , bare_unsafe_options ):
268+ if option .startswith (unsafe_option ) or option == bare_option :
269+ raise UnsafeOptionError (
270+ unsafe_option + " is not allowed, use `allow_unsafe_options=True` to allow it."
271+ )
272+
177273 def __setstate__ (self , d ):
178274 dict_to_slots_and__excluded_are_none (self , d , excluded = self ._excluded_ )
179275
@@ -705,11 +801,15 @@ def execute(self, command,
705801 cmd_not_found_exception = OSError
706802 if kill_after_timeout :
707803 raise GitCommandError (command , '"kill_after_timeout" feature is not supported on Windows.' )
804+
805+ # Only search PATH, not CWD. This must be in the *caller* environment. The "1" can be any value.
806+ patch_caller_env = unittest .mock .patch .dict (os .environ , {"NoDefaultCurrentDirectoryInExePath" : "1" })
708807 else :
709808 if sys .version_info [0 ] > 2 :
710809 cmd_not_found_exception = FileNotFoundError # NOQA # exists, flake8 unknown @UndefinedVariable
711810 else :
712811 cmd_not_found_exception = OSError
812+ patch_caller_env = nullcontext ()
713813 # end handle
714814
715815 stdout_sink = (PIPE
@@ -721,19 +821,18 @@ def execute(self, command,
721821 log .debug ("Popen(%s, cwd=%s, universal_newlines=%s, shell=%s, istream=%s)" ,
722822 command , cwd , universal_newlines , shell , istream_ok )
723823 try :
724- proc = Popen (command ,
725- env = env ,
726- cwd = cwd ,
727- bufsize = - 1 ,
728- stdin = istream ,
729- stderr = PIPE ,
730- stdout = stdout_sink ,
731- shell = shell is not None and shell or self .USE_SHELL ,
732- close_fds = is_posix , # unsupported on windows
733- universal_newlines = universal_newlines ,
734- creationflags = PROC_CREATIONFLAGS ,
735- ** subprocess_kwargs
736- )
824+ proc = safer_popen (
825+ command ,
826+ env = env ,
827+ cwd = cwd ,
828+ bufsize = - 1 ,
829+ stdin = istream ,
830+ stderr = PIPE ,
831+ stdout = stdout_sink ,
832+ shell = shell is not None and shell or self .USE_SHELL ,
833+ universal_newlines = universal_newlines ,
834+ ** subprocess_kwargs
835+ )
737836 except cmd_not_found_exception as err :
738837 raise GitCommandNotFound (command , err )
739838
@@ -862,7 +961,7 @@ def update_environment(self, **kwargs):
862961 del self ._environment [key ]
863962 return old_env
864963
865- @contextmanager
964+ @contextlib . contextmanager
866965 def custom_environment (self , ** kwargs ):
867966 """
868967 A context manager around the above ``update_environment`` method to restore the
@@ -1082,7 +1181,7 @@ def get_object_data(self, ref):
10821181 :note: not threadsafe"""
10831182 hexsha , typename , size , stream = self .stream_object_data (ref )
10841183 data = stream .read (size )
1085- del (stream )
1184+ del (stream )
10861185 return (hexsha , typename , size , data )
10871186
10881187 def stream_object_data (self , ref ):
0 commit comments