@@ -26,6 +26,7 @@ | |||
| 26 | 26 | CommandError, | |
| 27 | 27 | GitCommandError, | |
| 28 | 28 | GitCommandNotFound, | |
| 29 | + UnsafeExecutionError, | ||
| 29 | 30 | UnsafeOptionError, | |
| 30 | 31 | UnsafeProtocolError, | |
| 31 | 32 | ) | |
@@ -631,6 +632,7 @@ class Git(metaclass=_GitMeta): | |||
| 631 | 632 | ||
| 632 | 633 | __slots__ = ( | |
| 633 | 634 | "_working_dir", | |
| 635 | + "_safe", | ||
| 634 | 636 | "cat_file_all", | |
| 635 | 637 | "cat_file_header", | |
| 636 | 638 | "_version_info", | |
@@ -977,17 +979,56 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> | |||
| 977 | 979 | ||
| 978 | 980 | CatFileContentStream: TypeAlias = _CatFileContentStream | |
| 979 | 981 | ||
| 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: | ||
| 981 | 983 | """Initialize this instance with: | |
| 982 | 984 | ||
| 983 | 985 | :param working_dir: | |
| 984 | 986 | Git directory we should work in. If ``None``, we always work in the current | |
| 985 | 987 | directory as returned by :func:`os.getcwd`. | |
| 986 | 988 | This is meant to be the working tree directory if available, or the | |
| 987 | 989 | ``.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``. | ||
| 988 | 1028 | """ | |
| 989 | 1029 | super().__init__() | |
| 990 | 1030 | self._working_dir = expand_path(working_dir) | |
| 1031 | + self._safe = safe | ||
| 991 | 1032 | self._git_options: Union[List[str], Tuple[str, ...]] = () | |
| 992 | 1033 | self._persistent_git_options: List[str] = [] | |
| 993 | 1034 | ||
@@ -1234,6 +1275,8 @@ def execute( | |||
| 1234 | 1275 | ||
| 1235 | 1276 | :raise git.exc.GitCommandError: | |
| 1236 | 1277 | ||
| 1278 | + :raise git.exc.UnsafeExecutionError: | ||
| 1279 | + | ||
| 1237 | 1280 | :note: | |
| 1238 | 1281 | If you add additional keyword arguments to the signature of this method, you | |
| 1239 | 1282 | must update the ``execute_kwargs`` variable housed in this module. | |
@@ -1243,6 +1286,64 @@ def execute( | |||
| 1243 | 1286 | if self.GIT_PYTHON_TRACE and (self.GIT_PYTHON_TRACE != "full" or as_process): | |
| 1244 | 1287 | _logger.info(" ".join(redacted_command)) | |
| 1245 | 1288 | ||
| 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 | + | ||
| 1246 | 1347 | # Allow the user to have the command executed in their working dir. | |
| 1247 | 1348 | try: | |
| 1248 | 1349 | cwd = self._working_dir or os.getcwd() # type: Union[None, str] | |
@@ -1260,6 +1361,15 @@ def execute( | |||
| 1260 | 1361 | # just to be sure. | |
| 1261 | 1362 | env["LANGUAGE"] = "C" | |
| 1262 | 1363 | 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" | ||
| 1263 | 1373 | env.update(self._environment) | |
| 1264 | 1374 | if inline_env is not None: | |
| 1265 | 1375 | env.update(inline_env) | |
@@ -1276,13 +1386,6 @@ def execute( | |||
| 1276 | 1386 | # END handle | |
| 1277 | 1387 | ||
| 1278 | 1388 | 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") | ||
| 1286 | 1389 | _logger.debug( | |
| 1287 | 1390 | "Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)", | |
| 1288 | 1391 | redacted_command, | |
@@ -159,6 +159,10 @@ def __init__( | |||
| 159 | 159 | super().__init__(command, status, stderr, stdout) | |
| 160 | 160 | ||
| 161 | 161 | ||
| 162 | + class UnsafeExecutionError(CommandError): | ||
| 163 | + """Thrown if anything but git is executed when in safe mode.""" | ||
| 164 | + | ||
| 165 | + | ||
| 162 | 166 | class CheckoutError(GitError): | |
| 163 | 167 | """Thrown if a file could not be checked out from the index as it contained | |
| 164 | 168 | changes. | |
0 commit comments