Skip to content

Commit 0d88094

Browse files
codexByron
authored andcommitted
fix: block unsafe long-option prefixes (GHSA-2f96-g7mh-g2hx) [ruff-format]
1 parent 20c5e27 commit 0d88094

5 files changed

Lines changed: 145 additions & 8 deletions

File tree

git/cmd.py

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -960,18 +960,89 @@ def _canonicalize_option_name(cls, option: str) -> str:
960960
return dashify(option_tokens[0])
961961

962962
@classmethod
963-
def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) -> None:
963+
def _option_token(cls, option: str) -> str:
964+
option_name = option.split("=", 1)[0]
965+
option_tokens = option_name.split(None, 1)
966+
if not option_tokens:
967+
return ""
968+
return option_tokens[0]
969+
970+
@classmethod
971+
def _is_long_option(cls, option: str, bare_options_are_long: bool = True) -> bool:
972+
"""Return whether an option is a long option before canonicalization."""
973+
option_token = cls._option_token(option)
974+
if not option_token:
975+
return False
976+
977+
if option_token.startswith("--"):
978+
return True
979+
if option_token.startswith("-"):
980+
return False
981+
return bare_options_are_long and len(option_token) > 1
982+
983+
@classmethod
984+
def _matches_unsafe_short_option(
985+
cls, option: str, canonical_unsafe_option: str, unsafe_option_is_long: bool
986+
) -> bool:
987+
"""Return whether a single-dash option matches an unsafe short option."""
988+
if unsafe_option_is_long or len(canonical_unsafe_option) != 1:
989+
return False
990+
991+
option_token = cls._option_token(option)
992+
if not option_token:
993+
return False
994+
995+
if not option_token.startswith("-") or option_token.startswith("--"):
996+
return False
997+
998+
clusterable_flags = frozenset("46flnqsv")
999+
for option_char in option_token[1:]:
1000+
if option_char == canonical_unsafe_option:
1001+
return True
1002+
if option_char not in clusterable_flags:
1003+
return False
1004+
return False
1005+
1006+
@classmethod
1007+
def check_unsafe_options(
1008+
cls, options: List[str], unsafe_options: List[str], bare_options_are_long: bool = True
1009+
) -> None:
9641010
"""Check for unsafe options.
9651011
9661012
Some options that are passed to ``git <command>`` can be used to execute
9671013
arbitrary commands. These are blocked by default.
9681014
"""
9691015
# Options can be of the form `foo`, `--foo`, `--foo bar`, or `--foo=bar`.
970-
canonical_unsafe_options = {cls._canonicalize_option_name(option): option for option in unsafe_options}
1016+
# We also treat long-option prefix forms as unsafe, matching Git's option
1017+
# parser behavior for long options.
1018+
canonical_unsafe_options = [
1019+
(cls._canonicalize_option_name(option), option, cls._is_long_option(option)) for option in unsafe_options
1020+
]
9711021
for option in options:
972-
unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option))
973-
if unsafe_option is not None:
974-
raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")
1022+
option_token = cls._option_token(option)
1023+
if not bare_options_are_long and not option_token.startswith("-"):
1024+
continue
1025+
canonical_option = cls._canonicalize_option_name(option)
1026+
if not canonical_option:
1027+
continue
1028+
option_is_long = cls._is_long_option(option, bare_options_are_long=bare_options_are_long)
1029+
for (
1030+
canonical_unsafe_option,
1031+
unsafe_option,
1032+
unsafe_option_is_long,
1033+
) in canonical_unsafe_options:
1034+
if (
1035+
canonical_option == canonical_unsafe_option
1036+
or (
1037+
option_is_long
1038+
and unsafe_option_is_long
1039+
and canonical_unsafe_option.startswith(canonical_option)
1040+
)
1041+
or cls._matches_unsafe_short_option(option, canonical_unsafe_option, unsafe_option_is_long)
1042+
):
1043+
raise UnsafeOptionError(
1044+
f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it."
1045+
)
9751046

9761047
AutoInterrupt: TypeAlias = _AutoInterrupt
9771048

git/repo/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1410,7 +1410,9 @@ def _clone(
14101410
if not allow_unsafe_options:
14111411
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options)
14121412
if not allow_unsafe_options and multi:
1413-
Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options)
1413+
Git.check_unsafe_options(
1414+
options=multi, unsafe_options=cls.unsafe_git_clone_options, bare_options_are_long=False
1415+
)
14141416

14151417
proc = git.clone(
14161418
multi,

test/test_clone.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,13 @@ def test_clone_unsafe_options(self, rw_repo):
118118
unsafe_options = [
119119
f"--upload-pack='touch {tmp_file}'",
120120
f"-u 'touch {tmp_file}'",
121+
f"-utouch {tmp_file}; false",
122+
f"-futouch${{IFS}}{tmp_file}; false",
123+
f"-qutouch${{IFS}}{tmp_file}; false",
121124
"--config=protocol.ext.allow=always",
122125
"-c protocol.ext.allow=always",
126+
"-cprotocol.ext.allow=always",
127+
"-vcprotocol.ext.allow=always",
123128
]
124129
for unsafe_option in unsafe_options:
125130
with self.assertRaises(UnsafeOptionError):
@@ -129,6 +134,7 @@ def test_clone_unsafe_options(self, rw_repo):
129134
unsafe_options = [
130135
{"upload-pack": f"touch {tmp_file}"},
131136
{"upload_pack": f"touch {tmp_file}"},
137+
{"upload_p": f"touch {tmp_file}"},
132138
{"u": f"touch {tmp_file}"},
133139
{"config": "protocol.ext.allow=always"},
134140
{"c": "protocol.ext.allow=always"},
@@ -191,7 +197,9 @@ def test_clone_safe_options(self, rw_repo):
191197
options = [
192198
"--depth=1",
193199
"--single-branch",
200+
"--origin upload",
194201
"-q",
202+
"-oupstream",
195203
]
196204
for option in options:
197205
destination = tmp_dir / option
@@ -207,8 +215,13 @@ def test_clone_from_unsafe_options(self, rw_repo):
207215
unsafe_options = [
208216
f"--upload-pack='touch {tmp_file}'",
209217
f"-u 'touch {tmp_file}'",
218+
f"-utouch {tmp_file}; false",
219+
f"-futouch${{IFS}}{tmp_file}; false",
220+
f"-qutouch${{IFS}}{tmp_file}; false",
210221
"--config=protocol.ext.allow=always",
211222
"-c protocol.ext.allow=always",
223+
"-cprotocol.ext.allow=always",
224+
"-vcprotocol.ext.allow=always",
212225
]
213226
for unsafe_option in unsafe_options:
214227
with self.assertRaises(UnsafeOptionError):
@@ -218,6 +231,7 @@ def test_clone_from_unsafe_options(self, rw_repo):
218231
unsafe_options = [
219232
{"upload-pack": f"touch {tmp_file}"},
220233
{"upload_pack": f"touch {tmp_file}"},
234+
{"upload_p": f"touch {tmp_file}"},
221235
{"u": f"touch {tmp_file}"},
222236
{"config": "protocol.ext.allow=always"},
223237
{"c": "protocol.ext.allow=always"},

test/test_git.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,21 @@ def test_it_transforms_kwargs_into_git_command_arguments(self):
158158
def test_check_unsafe_options_normalizes_kwargs(self):
159159
cases = [
160160
(["upload_pack"], ["--upload-pack"]),
161+
(["upload_p"], ["--upload-pack"]),
162+
(["upload_pac"], ["--upload-pack"]),
161163
(["receive_pack"], ["--receive-pack"]),
164+
(["receive_p"], ["--receive-pack"]),
162165
(["exec"], ["--exec"]),
166+
(["exe"], ["--exec"]),
163167
(["u"], ["-u"]),
168+
(["-utouch /tmp/pwn"], ["-u"]),
169+
(["-futouch /tmp/pwn"], ["-u"]),
170+
(["-qutouch${IFS}/tmp/pwn"], ["-u"]),
164171
(["c"], ["-c"]),
172+
(["-cprotocol.ext.allow=always"], ["-c"]),
173+
(["-vcprotocol.ext.allow=always"], ["-c"]),
174+
(["conf"], ["--config"]),
175+
(["confi"], ["--config"]),
165176
(["--upload-pack=/tmp/helper"], ["--upload-pack"]),
166177
(["--config core.filemode=false"], ["--config"]),
167178
]
@@ -170,6 +181,35 @@ def test_check_unsafe_options_normalizes_kwargs(self):
170181
with self.assertRaises(UnsafeOptionError):
171182
Git.check_unsafe_options(options=options, unsafe_options=unsafe_options)
172183

184+
def test_check_unsafe_options_keeps_short_options_exact(self):
185+
cases = [
186+
(["u"], ["--upload-pack"]),
187+
(["-u helper"], ["--upload-pack"]),
188+
(["c"], ["--config"]),
189+
(["-c protocol.ext.allow=always"], ["--config"]),
190+
(["e"], ["--exec"]),
191+
(["r"], ["--receive-pack"]),
192+
]
193+
194+
for options, unsafe_options in cases:
195+
Git.check_unsafe_options(options=options, unsafe_options=unsafe_options)
196+
197+
def test_check_unsafe_options_ignores_multi_option_values(self):
198+
Git.check_unsafe_options(
199+
options=["--origin", "upload"],
200+
unsafe_options=["--upload-pack", "--config"],
201+
bare_options_are_long=False,
202+
)
203+
204+
def test_check_unsafe_options_allows_attached_safe_short_option_values(self):
205+
cases = [
206+
["-oupstream"],
207+
["-bcurrent"],
208+
]
209+
210+
for options in cases:
211+
Git.check_unsafe_options(options=options, unsafe_options=["--upload-pack", "-u", "--config", "-c"])
212+
173213
_shell_cases = (
174214
# value_in_call, value_from_class, expected_popen_arg
175215
(None, False, False),

test/test_remote.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,11 @@ def test_fetch_unsafe_options(self, rw_repo):
832832
remote = rw_repo.remote("origin")
833833
tmp_dir = Path(tdir)
834834
tmp_file = tmp_dir / "pwn"
835-
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}]
835+
unsafe_options = [
836+
{"upload-pack": f"touch {tmp_file}"},
837+
{"upload_pack": f"touch {tmp_file}"},
838+
{"upload_p": f"touch {tmp_file}"},
839+
]
836840
for unsafe_option in unsafe_options:
837841
with self.assertRaises(UnsafeOptionError):
838842
remote.fetch(**unsafe_option)
@@ -900,7 +904,11 @@ def test_pull_unsafe_options(self, rw_repo):
900904
remote = rw_repo.remote("origin")
901905
tmp_dir = Path(tdir)
902906
tmp_file = tmp_dir / "pwn"
903-
unsafe_options = [{"upload-pack": f"touch {tmp_file}"}, {"upload_pack": f"touch {tmp_file}"}]
907+
unsafe_options = [
908+
{"upload-pack": f"touch {tmp_file}"},
909+
{"upload_pack": f"touch {tmp_file}"},
910+
{"upload_p": f"touch {tmp_file}"},
911+
]
904912
for unsafe_option in unsafe_options:
905913
with self.assertRaises(UnsafeOptionError):
906914
remote.pull(**unsafe_option)
@@ -971,7 +979,9 @@ def test_push_unsafe_options(self, rw_repo):
971979
unsafe_options = [
972980
{"receive-pack": f"touch {tmp_file}"},
973981
{"receive_pack": f"touch {tmp_file}"},
982+
{"receive_p": f"touch {tmp_file}"},
974983
{"exec": f"touch {tmp_file}"},
984+
{"exe": f"touch {tmp_file}"},
975985
]
976986
for unsafe_option in unsafe_options:
977987
assert not tmp_file.exists()

0 commit comments

Comments
 (0)