Skip to content

Commit 07a3889

Browse files
committed
safe mode to disable executing any external programs except git
1 parent 751cb2d commit 07a3889

File tree

3 files changed

+236
-6
lines changed

3 files changed

+236
-6
lines changed

git/cmd.py

Lines changed: 98 additions & 1 deletion
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
)
@@ -398,6 +399,7 @@ class Git(metaclass=_GitMeta):
398399

399400
__slots__ = (
400401
"_working_dir",
402+
"_safe",
401403
"cat_file_all",
402404
"cat_file_header",
403405
"_version_info",
@@ -944,17 +946,56 @@ def __del__(self) -> None:
944946
self._stream.read(bytes_left + 1)
945947
# END handle incomplete read
946948

947-
def __init__(self, working_dir: Union[None, PathLike] = None) -> None:
949+
def __init__(self, working_dir: Union[None, PathLike] = None, safe: bool = False) -> None:
948950
"""Initialize this instance with:
949951
950952
:param working_dir:
951953
Git directory we should work in. If ``None``, we always work in the current
952954
directory as returned by :func:`os.getcwd`.
953955
This is meant to be the working tree directory if available, or the
954956
``.git`` directory in case of bare repositories.
957+
958+
:param safe:
959+
Lock down the configuration to make it as safe as possible
960+
when working with publicly accessible, untrusted
961+
repositories. This disables all known options that can run
962+
external programs and limits networking to the HTTP protocol
963+
via ``https://`` URLs. This might not cover Git config
964+
options that were added since this was implemented, or
965+
options that have unknown exploit vectors. It is a best
966+
effort defense rather than an exhaustive protection measure.
967+
968+
In order to make this more likely to work with submodules,
969+
some attempts are made to rewrite remote URLs to ``https://``
970+
using `insteadOf` in the config. This might not work on all
971+
projects, so submodules should always use ``https://`` URLs.
972+
973+
:envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these
974+
environment variables are forced to `/bin/true`:
975+
:envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`,
976+
:envvar:`GIT_PAGER`, :envvar:`GIT_SSH`,
977+
:envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`.
978+
979+
Git config options are supplied via the command line to set
980+
up key parts of safe mode.
981+
982+
- Direct options for executing external commands are set to ``/bin/true``:
983+
``core.askpass``, ``core.sshCommand`` and ``credential.helper``.
984+
985+
- External password prompts are disabled by skipping authentication using
986+
``http.emptyAuth=true``.
987+
988+
- Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``.
989+
990+
- Hook scripts are disabled using ``core.hooksPath=/dev/null``.
991+
992+
It was not possible to cover all config items that might execute an external
993+
command, for example, ``receive.procReceiveRefs``,
994+
``uploadpack.packObjectsHook`` and ``remote.<name>.vcs``.
955995
"""
956996
super().__init__()
957997
self._working_dir = expand_path(working_dir)
998+
self._safe = safe
958999
self._git_options: Union[List[str], Tuple[str, ...]] = ()
9591000
self._persistent_git_options: List[str] = []
9601001

@@ -1201,6 +1242,8 @@ def execute(
12011242
12021243
:raise git.exc.GitCommandError:
12031244
1245+
:raise git.exc.UnsafeExecutionError:
1246+
12041247
:note:
12051248
If you add additional keyword arguments to the signature of this method, you
12061249
must update the ``execute_kwargs`` variable housed in this module.
@@ -1210,6 +1253,51 @@ def execute(
12101253
if self.GIT_PYTHON_TRACE and (self.GIT_PYTHON_TRACE != "full" or as_process):
12111254
_logger.info(" ".join(redacted_command))
12121255

1256+
if self._safe:
1257+
if isinstance(command, str) or command[0] != self.GIT_PYTHON_GIT_EXECUTABLE:
1258+
raise UnsafeExecutionError(
1259+
redacted_command,
1260+
f"Only {self.GIT_PYTHON_GIT_EXECUTABLE} can be executed when in safe mode.",
1261+
)
1262+
if shell:
1263+
raise UnsafeExecutionError(
1264+
redacted_command,
1265+
"Command cannot be executed in a shell when in safe mode.",
1266+
)
1267+
config_args = [
1268+
"-c",
1269+
"core.askpass=/bin/true",
1270+
"-c",
1271+
"core.fsmonitor=false",
1272+
"-c",
1273+
"core.hooksPath=/dev/null",
1274+
"-c",
1275+
"core.sshCommand=/bin/true",
1276+
"-c",
1277+
"credential.helper=/bin/true",
1278+
"-c",
1279+
"http.emptyAuth=true",
1280+
"-c",
1281+
"protocol.allow=never",
1282+
"-c",
1283+
"protocol.https.allow=always",
1284+
"-c",
1285+
"url.https://bitbucket.org/[email protected]:",
1286+
"-c",
1287+
"url.https://codeberg.org/[email protected]:",
1288+
"-c",
1289+
"url.https://github.com/[email protected]:",
1290+
"-c",
1291+
"url.https://gitlab.com/[email protected]:",
1292+
"-c",
1293+
"url.https://.insteadOf=git://",
1294+
"-c",
1295+
"url.https://.insteadOf=http://",
1296+
"-c",
1297+
"url.https://.insteadOf=ssh://",
1298+
]
1299+
command = [command.pop(0)] + config_args + command
1300+
12131301
# Allow the user to have the command executed in their working dir.
12141302
try:
12151303
cwd = self._working_dir or os.getcwd() # type: Union[None, str]
@@ -1227,6 +1315,15 @@ def execute(
12271315
# just to be sure.
12281316
env["LANGUAGE"] = "C"
12291317
env["LC_ALL"] = "C"
1318+
# Globally disable things that can execute commands, including password prompts.
1319+
if self._safe:
1320+
env["GIT_ASKPASS"] = "/bin/true"
1321+
env["GIT_EDITOR"] = "/bin/true"
1322+
env["GIT_PAGER"] = "/bin/true"
1323+
env["GIT_SSH"] = "/bin/true"
1324+
env["GIT_SSH_COMMAND"] = "/bin/true"
1325+
env["GIT_TERMINAL_PROMPT"] = "false"
1326+
env["SSH_ASKPASS"] = "/bin/true"
12301327
env.update(self._environment)
12311328
if inline_env is not None:
12321329
env.update(inline_env)

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.

git/repo/base.py

Lines changed: 134 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ class Repo:
131131
git_dir: PathLike
132132
"""The ``.git`` repository directory."""
133133

134+
safe: None
135+
"""Whether this is operating using restricted protocol and execution access."""
136+
134137
_common_dir: PathLike = ""
135138

136139
# Precompiled regex
@@ -175,6 +178,7 @@ def __init__(
175178
odbt: Type[LooseObjectDB] = GitCmdObjectDB,
176179
search_parent_directories: bool = False,
177180
expand_vars: bool = True,
181+
safe: bool = False,
178182
) -> None:
179183
R"""Create a new :class:`Repo` instance.
180184
@@ -204,6 +208,44 @@ def __init__(
204208
Please note that this was the default behaviour in older versions of
205209
GitPython, which is considered a bug though.
206210
211+
:param safe:
212+
Lock down the configuration to make it as safe as possible
213+
when working with publicly accessible, untrusted
214+
repositories. This disables all known options that can run
215+
external programs and limits networking to the HTTP protocol
216+
via ``https://`` URLs. This might not cover Git config
217+
options that were added since this was implemented, or
218+
options that have unknown exploit vectors. It is a best
219+
effort defense rather than an exhaustive protection measure.
220+
221+
In order to make this more likely to work with submodules,
222+
some attempts are made to rewrite remote URLs to ``https://``
223+
using `insteadOf` in the config. This might not work on all
224+
projects, so submodules should always use ``https://`` URLs.
225+
226+
:envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these
227+
environment variables are forced to `/bin/true`:
228+
:envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`,
229+
:envvar:`GIT_PAGER`, :envvar:`GIT_SSH`,
230+
:envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`.
231+
232+
Git config options are supplied via the command line to set
233+
up key parts of safe mode.
234+
235+
- Direct options for executing external commands are set to ``/bin/true``:
236+
``core.askpass``, ``core.sshCommand`` and ``credential.helper``.
237+
238+
- External password prompts are disabled by skipping authentication using
239+
``http.emptyAuth=true``.
240+
241+
- Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``.
242+
243+
- Hook scripts are disabled using ``core.hooksPath=/dev/null``.
244+
245+
It was not possible to cover all config items that might execute an external
246+
command, for example, ``receive.procReceiveRefs``,
247+
``uploadpack.packObjectsHook`` and ``remote.<name>.vcs``.
248+
207249
:raise git.exc.InvalidGitRepositoryError:
208250
209251
:raise git.exc.NoSuchPathError:
@@ -235,6 +277,8 @@ def __init__(
235277
if not os.path.exists(epath):
236278
raise NoSuchPathError(epath)
237279

280+
self.safe = safe
281+
238282
# Walk up the path to find the `.git` dir.
239283
curpath = epath
240284
git_dir = None
@@ -309,7 +353,7 @@ def __init__(
309353
# END working dir handling
310354

311355
self.working_dir: PathLike = self._working_tree_dir or self.common_dir
312-
self.git = self.GitCommandWrapperType(self.working_dir)
356+
self.git = self.GitCommandWrapperType(self.working_dir, safe)
313357

314358
# Special handling, in special times.
315359
rootpath = osp.join(self.common_dir, "objects")
@@ -1305,6 +1349,7 @@ def init(
13051349
mkdir: bool = True,
13061350
odbt: Type[GitCmdObjectDB] = GitCmdObjectDB,
13071351
expand_vars: bool = True,
1352+
safe: bool = False,
13081353
**kwargs: Any,
13091354
) -> "Repo":
13101355
"""Initialize a git repository at the given path if specified.
@@ -1329,6 +1374,44 @@ def init(
13291374
information disclosure, allowing attackers to access the contents of
13301375
environment variables.
13311376
1377+
:param safe:
1378+
Lock down the configuration to make it as safe as possible
1379+
when working with publicly accessible, untrusted
1380+
repositories. This disables all known options that can run
1381+
external programs and limits networking to the HTTP protocol
1382+
via ``https://`` URLs. This might not cover Git config
1383+
options that were added since this was implemented, or
1384+
options that have unknown exploit vectors. It is a best
1385+
effort defense rather than an exhaustive protection measure.
1386+
1387+
In order to make this more likely to work with submodules,
1388+
some attempts are made to rewrite remote URLs to ``https://``
1389+
using `insteadOf` in the config. This might not work on all
1390+
projects, so submodules should always use ``https://`` URLs.
1391+
1392+
:envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these
1393+
environment variables are forced to `/bin/true`:
1394+
:envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`,
1395+
:envvar:`GIT_PAGER`, :envvar:`GIT_SSH`,
1396+
:envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`.
1397+
1398+
Git config options are supplied via the command line to set
1399+
up key parts of safe mode.
1400+
1401+
- Direct options for executing external commands are set to ``/bin/true``:
1402+
``core.askpass``, ``core.sshCommand`` and ``credential.helper``.
1403+
1404+
- External password prompts are disabled by skipping authentication using
1405+
``http.emptyAuth=true``.
1406+
1407+
- Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``.
1408+
1409+
- Hook scripts are disabled using ``core.hooksPath=/dev/null``.
1410+
1411+
It was not possible to cover all config items that might execute an external
1412+
command, for example, ``receive.procReceiveRefs``,
1413+
``uploadpack.packObjectsHook`` and ``remote.<name>.vcs``.
1414+
13321415
:param kwargs:
13331416
Keyword arguments serving as additional options to the
13341417
:manpage:`git-init(1)` command.
@@ -1342,9 +1425,9 @@ def init(
13421425
os.makedirs(path, 0o755)
13431426

13441427
# git command automatically chdir into the directory
1345-
git = cls.GitCommandWrapperType(path)
1428+
git = cls.GitCommandWrapperType(path, safe)
13461429
git.init(**kwargs)
1347-
return cls(path, odbt=odbt)
1430+
return cls(path, odbt=odbt, safe=safe)
13481431

13491432
@classmethod
13501433
def _clone(
@@ -1357,6 +1440,7 @@ def _clone(
13571440
multi_options: Optional[List[str]] = None,
13581441
allow_unsafe_protocols: bool = False,
13591442
allow_unsafe_options: bool = False,
1443+
safe: Union[bool, None] = None,
13601444
**kwargs: Any,
13611445
) -> "Repo":
13621446
odbt = kwargs.pop("odbt", odb_default_type)
@@ -1418,7 +1502,11 @@ def _clone(
14181502
if not osp.isabs(path):
14191503
path = osp.join(git._working_dir, path) if git._working_dir is not None else path
14201504

1421-
repo = cls(path, odbt=odbt)
1505+
# if safe is not explicitly defined, then the new Repo instance should inherit the safe value
1506+
if safe is None:
1507+
safe = git._safe
1508+
1509+
repo = cls(path, odbt=odbt, safe=safe)
14221510

14231511
# Retain env values that were passed to _clone().
14241512
repo.git.update_environment(**git.environment())
@@ -1501,6 +1589,7 @@ def clone_from(
15011589
multi_options: Optional[List[str]] = None,
15021590
allow_unsafe_protocols: bool = False,
15031591
allow_unsafe_options: bool = False,
1592+
safe: bool = False,
15041593
**kwargs: Any,
15051594
) -> "Repo":
15061595
"""Create a clone from the given URL.
@@ -1531,13 +1620,52 @@ def clone_from(
15311620
:param allow_unsafe_options:
15321621
Allow unsafe options to be used, like ``--upload-pack``.
15331622
1623+
:param safe:
1624+
Lock down the configuration to make it as safe as possible
1625+
when working with publicly accessible, untrusted
1626+
repositories. This disables all known options that can run
1627+
external programs and limits networking to the HTTP protocol
1628+
via ``https://`` URLs. This might not cover Git config
1629+
options that were added since this was implemented, or
1630+
options that have unknown exploit vectors. It is a best
1631+
effort defense rather than an exhaustive protection measure.
1632+
1633+
In order to make this more likely to work with submodules,
1634+
some attempts are made to rewrite remote URLs to ``https://``
1635+
using `insteadOf` in the config. This might not work on all
1636+
projects, so submodules should always use ``https://`` URLs.
1637+
1638+
:envvar:`GIT_TERMINAL_PROMPT` is set to `false` and these
1639+
environment variables are forced to `/bin/true`:
1640+
:envvar:`GIT_ASKPASS`, :envvar:`GIT_EDITOR`,
1641+
:envvar:`GIT_PAGER`, :envvar:`GIT_SSH`,
1642+
:envvar:`GIT_SSH_COMMAND`, and :envvar:`SSH_ASKPASS`.
1643+
1644+
Git config options are supplied via the command line to set
1645+
up key parts of safe mode.
1646+
1647+
- Direct options for executing external commands are set to ``/bin/true``:
1648+
``core.askpass``, ``core.sshCommand`` and ``credential.helper``.
1649+
1650+
- External password prompts are disabled by skipping authentication using
1651+
``http.emptyAuth=true``.
1652+
1653+
- Any use of an fsmonitor daemon is disabled using ``core.fsmonitor=false``.
1654+
1655+
- Hook scripts are disabled using ``core.hooksPath=/dev/null``.
1656+
1657+
It was not possible to cover all config items that might execute an external
1658+
command, for example, ``receive.procReceiveRefs``,
1659+
``uploadpack.packObjectsHook`` and ``remote.<name>.vcs``.
1660+
15341661
:param kwargs:
15351662
See the :meth:`clone` method.
15361663
15371664
:return:
15381665
:class:`Repo` instance pointing to the cloned directory.
1666+
15391667
"""
1540-
git = cls.GitCommandWrapperType(os.getcwd())
1668+
git = cls.GitCommandWrapperType(os.getcwd(), safe)
15411669
if env is not None:
15421670
git.update_environment(**env)
15431671
return cls._clone(
@@ -1549,6 +1677,7 @@ def clone_from(
15491677
multi_options,
15501678
allow_unsafe_protocols=allow_unsafe_protocols,
15511679
allow_unsafe_options=allow_unsafe_options,
1680+
safe=safe,
15521681
**kwargs,
15531682
)
15541683

0 commit comments

Comments
 (0)