Skip to content

Commit 56e3440

Browse files
committed
safe mode to disable executing any external programs except git
1 parent 5a294a6 commit 56e3440

4 files changed

Lines changed: 273 additions & 14 deletions

File tree

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
)
@@ -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,

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)