2626 CommandError ,
2727 GitCommandError ,
2828 GitCommandNotFound ,
29+ UnsafeExecutionError ,
2930 UnsafeOptionError ,
3031 UnsafeProtocolError ,
3132)
@@ -631,6 +632,7 @@ class Git(metaclass=_GitMeta):
631632
632633 __slots__ = (
633634 "_working_dir" ,
635+ "_safe" ,
634636 "cat_file_all" ,
635637 "cat_file_header" ,
636638 "_version_info" ,
@@ -977,17 +979,56 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
977979
978980 CatFileContentStream : TypeAlias = _CatFileContentStream
979981
980- def __init__ (self , working_dir : Union [None , PathLike ] = None ) -> None :
982+ def __init__ (self , working_dir : Union [None , PathLike ] = None , safe : bool = False ) -> None :
981983 """Initialize this instance with:
982984
983985 :param working_dir:
984986 Git directory we should work in. If ``None``, we always work in the current
985987 directory as returned by :func:`os.getcwd`.
986988 This is meant to be the working tree directory if available, or the
987989 ``.git`` directory in case of bare repositories.
990+
991+ :param safe:
992+ Lock down the configuration to make it as safe as possible
993+ when working with publicly accessible, untrusted
994+ repositories. This disables all known options that can run
995+ external programs and limits networking to the HTTP protocol
996+ via ``https://`` URLs. This might not cover Git config
997+ options that were added since this was implemented, or
998+ options that have unknown exploit vectors. It is a best
999+ effort defense rather than an exhaustive protection measure.
1000+
1001+ In order to make this more likely to work with submodules,
1002+ some attempts are made to rewrite remote URLs to ``https://``
1003+ using `insteadOf` in the config. This might not work on all
1004+ projects, so submodules should always use ``https://`` URLs.
1005+
1006+ :envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these
1007+ environment variables are forced to `/bin/true`:
1008+ :envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`,
1009+ :envvar:`GIT_PAGER`, :envvar:`GIT_SSH`,
1010+ :envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`.
1011+
1012+ Git config options are supplied via the command line to set
1013+ up key parts of safe mode.
1014+
1015+ - Direct options for executing external commands are set to ``/bin/true``:
1016+ ``core.askpass``, ``core.sshCommand`` and ``credential.helper``.
1017+
1018+ - External password prompts are disabled by skipping authentication using
1019+ ``http.emptyAuth=true``.
1020+
1021+ - Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``.
1022+
1023+ - Hook scripts are disabled using ``core.hooksPath=/dev/null``.
1024+
1025+ It was not possible to cover all config items that might execute an external
1026+ command, for example, ``receive.procReceiveRefs``,
1027+ ``uploadpack.packObjectsHook`` and ``remote.<name>.vcs``.
9881028 """
9891029 super ().__init__ ()
9901030 self ._working_dir = expand_path (working_dir )
1031+ self ._safe = safe
9911032 self ._git_options : Union [List [str ], Tuple [str , ...]] = ()
9921033 self ._persistent_git_options : List [str ] = []
9931034
@@ -1234,6 +1275,8 @@ def execute(
12341275
12351276 :raise git.exc.GitCommandError:
12361277
1278+ :raise git.exc.UnsafeExecutionError:
1279+
12371280 :note:
12381281 If you add additional keyword arguments to the signature of this method, you
12391282 must update the ``execute_kwargs`` variable housed in this module.
@@ -1243,6 +1286,64 @@ def execute(
12431286 if self .GIT_PYTHON_TRACE and (self .GIT_PYTHON_TRACE != "full" or as_process ):
12441287 _logger .info (" " .join (redacted_command ))
12451288
1289+ if shell is None :
1290+ # Get the value of USE_SHELL with no deprecation warning. Do this without
1291+ # warnings.catch_warnings, to avoid a race condition with application code
1292+ # configuring warnings. The value could be looked up in type(self).__dict__
1293+ # or Git.__dict__, but those can break under some circumstances. This works
1294+ # the same as self.USE_SHELL in more situations; see Git.__getattribute__.
1295+ shell = super ().__getattribute__ ("USE_SHELL" )
1296+
1297+ if self ._safe :
1298+ if shell :
1299+ raise UnsafeExecutionError (
1300+ redacted_command ,
1301+ "Command cannot be executed in a shell when in safe mode." ,
1302+ )
1303+ if not isinstance (command , Sequence ):
1304+ raise UnsafeExecutionError (
1305+ redacted_command ,
1306+ "Command must be a Sequence to be executed in safe mode." ,
1307+ )
1308+ if command [0 ] != self .GIT_PYTHON_GIT_EXECUTABLE :
1309+ raise UnsafeExecutionError (
1310+ redacted_command ,
1311+ f'Only "{ self .GIT_PYTHON_GIT_EXECUTABLE } " can be executed when in safe mode.' ,
1312+ )
1313+ config_args = [
1314+ "-c" ,
1315+ "core.askpass=/bin/true" ,
1316+ "-c" ,
1317+ "core.fsmonitor=false" ,
1318+ "-c" ,
1319+ "core.hooksPath=/dev/null" ,
1320+ "-c" ,
1321+ "core.sshCommand=/bin/true" ,
1322+ "-c" ,
1323+ "credential.helper=/bin/true" ,
1324+ "-c" ,
1325+ "http.emptyAuth=true" ,
1326+ "-c" ,
1327+ "protocol.allow=never" ,
1328+ "-c" ,
1329+ "protocol.https.allow=always" ,
1330+ "-c" ,
1331+ "url.https://bitbucket.org/.insteadOf=git@bitbucket.org:" ,
1332+ "-c" ,
1333+ "url.https://codeberg.org/.insteadOf=git@codeberg.org:" ,
1334+ "-c" ,
1335+ "url.https://github.com/.insteadOf=git@github.com:" ,
1336+ "-c" ,
1337+ "url.https://gitlab.com/.insteadOf=git@gitlab.com:" ,
1338+ "-c" ,
1339+ "url.https://.insteadOf=git://" ,
1340+ "-c" ,
1341+ "url.https://.insteadOf=http://" ,
1342+ "-c" ,
1343+ "url.https://.insteadOf=ssh://" ,
1344+ ]
1345+ command = [command .pop (0 )] + config_args + command
1346+
12461347 # Allow the user to have the command executed in their working dir.
12471348 try :
12481349 cwd = self ._working_dir or os .getcwd () # type: Union[None, str]
@@ -1260,6 +1361,15 @@ def execute(
12601361 # just to be sure.
12611362 env ["LANGUAGE" ] = "C"
12621363 env ["LC_ALL" ] = "C"
1364+ # Globally disable things that can execute commands, including password prompts.
1365+ if self ._safe :
1366+ env ["GIT_ASKPASS" ] = "/bin/true"
1367+ env ["GIT_EDITOR" ] = "/bin/true"
1368+ env ["GIT_PAGER" ] = "/bin/true"
1369+ env ["GIT_SSH" ] = "/bin/true"
1370+ env ["GIT_SSH_COMMAND" ] = "/bin/true"
1371+ env ["GIT_TERMINAL_PROMPT" ] = "false"
1372+ env ["SSH_ASKPASS" ] = "/bin/true"
12631373 env .update (self ._environment )
12641374 if inline_env is not None :
12651375 env .update (inline_env )
@@ -1276,13 +1386,6 @@ def execute(
12761386 # END handle
12771387
12781388 stdout_sink = PIPE if with_stdout else getattr (subprocess , "DEVNULL" , None ) or open (os .devnull , "wb" )
1279- if shell is None :
1280- # Get the value of USE_SHELL with no deprecation warning. Do this without
1281- # warnings.catch_warnings, to avoid a race condition with application code
1282- # configuring warnings. The value could be looked up in type(self).__dict__
1283- # or Git.__dict__, but those can break under some circumstances. This works
1284- # the same as self.USE_SHELL in more situations; see Git.__getattribute__.
1285- shell = super ().__getattribute__ ("USE_SHELL" )
12861389 _logger .debug (
12871390 "Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)" ,
12881391 redacted_command ,
0 commit comments