Skip to content

Commit 91fdc6c

Browse files
committed
safe mode to disable executing any external programs except git
1 parent 85c8155 commit 91fdc6c

File tree

4 files changed

+273
-13
lines changed

4 files changed

+273
-13
lines changed

git/cmd.py

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
CommandError,
2727
GitCommandError,
2828
GitCommandNotFound,
29+
UnsafeExecutionError,
2930
UnsafeOptionError,
3031
UnsafeProtocolError,
3132
)
@@ -627,6 +628,7 @@ class Git(metaclass=_GitMeta):
627628

628629
__slots__ = (
629630
"_working_dir",
631+
"_safe",
630632
"cat_file_all",
631633
"cat_file_header",
632634
"_version_info",
@@ -961,17 +963,56 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
961963

962964
CatFileContentStream: TypeAlias = _CatFileContentStream
963965

964-
def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
966+
def __init__(self, working_dir: Union[None, PathLike] = None, safe: bool = False) -> None:
965967
"""Initialize this instance with:
966968
967969
:param working_dir:
968970
Git directory we should work in. If ``None``, we always work in the current
969971
directory as returned by :func:`os.getcwd`.
970972
This is meant to be the working tree directory if available, or the
971973
``.git`` directory in case of bare repositories.
974+
975+
:param safe:
976+
Lock down the configuration to make it as safe as possible
977+
when working with publicly accessible, untrusted
978+
repositories. This disables all known options that can run
979+
external programs and limits networking to the HTTP protocol
980+
via ``https://`` URLs. This might not cover Git config
981+
options that were added since this was implemented, or
982+
options that have unknown exploit vectors. It is a best
983+
effort defense rather than an exhaustive protection measure.
984+
985+
In order to make this more likely to work with submodules,
986+
some attempts are made to rewrite remote URLs to ``https://``
987+
using `insteadOf` in the config. This might not work on all
988+
projects, so submodules should always use ``https://`` URLs.
989+
990+
:envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these
991+
environment variables are forced to `/bin/true`:
992+
:envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`,
993+
:envvar:`GIT_PAGER`, :envvar:`GIT_SSH`,
994+
:envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`.
995+
996+
Git config options are supplied via the command line to set
997+
up key parts of safe mode.
998+
999+
- Direct options for executing external commands are set to ``/bin/true``:
1000+
``core.askpass``, ``core.sshCommand`` and ``credential.helper``.
1001+
1002+
- External password prompts are disabled by skipping authentication using
1003+
``http.emptyAuth=true``.
1004+
1005+
- Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``.
1006+
1007+
- Hook scripts are disabled using ``core.hooksPath=/dev/null``.
1008+
1009+
It was not possible to cover all config items that might execute an external
1010+
command, for example, ``receive.procReceiveRefs``,
1011+
``uploadpack.packObjectsHook`` and ``remote.<name>.vcs``.
9721012
"""
9731013
super().__init__()
9741014
self._working_dir = expand_path(working_dir)
1015+
self._safe = safe
9751016
self._git_options: Union[List[str], Tuple[str, ...]] = ()
9761017
self._persistent_git_options: List[str] = []
9771018

@@ -1218,6 +1259,8 @@ def execute(
12181259
12191260
:raise git.exc.GitCommandError:
12201261
1262+
:raise git.exc.UnsafeExecutionError:
1263+
12211264
:note:
12221265
If you add additional keyword arguments to the signature of this method, you
12231266
must update the ``execute_kwargs`` variable housed in this module.
@@ -1227,6 +1270,64 @@ def execute(
12271270
if self.GIT_PYTHON_TRACE and (self.GIT_PYTHON_TRACE != "full" or as_process):
12281271
_logger.info(" ".join(redacted_command))
12291272

1273+
if shell is None:
1274+
# Get the value of USE_SHELL with no deprecation warning. Do this without
1275+
# warnings.catch_warnings, to avoid a race condition with application code
1276+
# configuring warnings. The value could be looked up in type(self).__dict__
1277+
# or Git.__dict__, but those can break under some circumstances. This works
1278+
# the same as self.USE_SHELL in more situations; see Git.__getattribute__.
1279+
shell = super().__getattribute__("USE_SHELL")
1280+
1281+
if self._safe:
1282+
if shell:
1283+
raise UnsafeExecutionError(
1284+
redacted_command,
1285+
"Command cannot be executed in a shell when in safe mode.",
1286+
)
1287+
if not isinstance(command, Sequence):
1288+
raise UnsafeExecutionError(
1289+
redacted_command,
1290+
"Command must be a Sequence to be executed in safe mode.",
1291+
)
1292+
if command[0] != self.GIT_PYTHON_GIT_EXECUTABLE:
1293+
raise UnsafeExecutionError(
1294+
redacted_command,
1295+
f'Only "{self.GIT_PYTHON_GIT_EXECUTABLE}" can be executed when in safe mode.',
1296+
)
1297+
config_args = [
1298+
"-c",
1299+
"core.askpass=/bin/true",
1300+
"-c",
1301+
"core.fsmonitor=false",
1302+
"-c",
1303+
"core.hooksPath=/dev/null",
1304+
"-c",
1305+
"core.sshCommand=/bin/true",
1306+
"-c",
1307+
"credential.helper=/bin/true",
1308+
"-c",
1309+
"http.emptyAuth=true",
1310+
"-c",
1311+
"protocol.allow=never",
1312+
"-c",
1313+
"protocol.https.allow=always",
1314+
"-c",
1315+
"url.https://bitbucket.org/.insteadOf=git@bitbucket.org:",
1316+
"-c",
1317+
"url.https://codeberg.org/.insteadOf=git@codeberg.org:",
1318+
"-c",
1319+
"url.https://github.com/.insteadOf=git@github.com:",
1320+
"-c",
1321+
"url.https://gitlab.com/.insteadOf=git@gitlab.com:",
1322+
"-c",
1323+
"url.https://.insteadOf=git://",
1324+
"-c",
1325+
"url.https://.insteadOf=http://",
1326+
"-c",
1327+
"url.https://.insteadOf=ssh://",
1328+
]
1329+
command = [command.pop(0)] + config_args + command
1330+
12301331
# Allow the user to have the command executed in their working dir.
12311332
try:
12321333
cwd = self._working_dir or os.getcwd() # type: Union[None, str]
@@ -1244,6 +1345,15 @@ def execute(
12441345
# just to be sure.
12451346
env["LANGUAGE"] = "C"
12461347
env["LC_ALL"] = "C"
1348+
# Globally disable things that can execute commands, including password prompts.
1349+
if self._safe:
1350+
env["GIT_ASKPASS"] = "/bin/true"
1351+
env["GIT_EDITOR"] = "/bin/true"
1352+
env["GIT_PAGER"] = "/bin/true"
1353+
env["GIT_SSH"] = "/bin/true"
1354+
env["GIT_SSH_COMMAND"] = "/bin/true"
1355+
env["GIT_TERMINAL_PROMPT"] = "false"
1356+
env["SSH_ASKPASS"] = "/bin/true"
12471357
env.update(self._environment)
12481358
if inline_env is not None:
12491359
env.update(inline_env)
@@ -1260,13 +1370,6 @@ def execute(
12601370
# END handle
12611371

12621372
stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
1263-
if shell is None:
1264-
# Get the value of USE_SHELL with no deprecation warning. Do this without
1265-
# warnings.catch_warnings, to avoid a race condition with application code
1266-
# configuring warnings. The value could be looked up in type(self).__dict__
1267-
# or Git.__dict__, but those can break under some circumstances. This works
1268-
# the same as self.USE_SHELL in more situations; see Git.__getattribute__.
1269-
shell = super().__getattribute__("USE_SHELL")
12701373
_logger.debug(
12711374
"Popen(%s, cwd=%s, stdin=%s, shell=%s, universal_newlines=%s)",
12721375
redacted_command,

git/exc.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ def __init__(
159159
super().__init__(command, status, stderr, stdout)
160160

161161

162+
class UnsafeExecutionError(CommandError):
163+
"""Thrown if anything but git is executed when in safe mode."""
164+
165+
162166
class CheckoutError(GitError):
163167
"""Thrown if a file could not be checked out from the index as it contained
164168
changes.

0 commit comments

Comments
 (0)