Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions news/14000.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Reject ``console_scripts`` and ``gui_scripts`` entry points whose name would
install a script outside the scripts directory.
26 changes: 23 additions & 3 deletions src/pip/_internal/operations/install/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,11 +397,31 @@ def __init__(self, entry_point: str) -> None:
)


def _raise_for_invalid_entrypoint(specification: str) -> None:
def _script_within_dir(name: str, scripts_dir: str) -> bool:
"""Return whether script ``name`` resolves to a path inside the ``scripts_dir``.

distlib joins the entry point name onto the scripts directory, so a name
with path separators or ``..`` components can resolve elsewhere.
"""
root = os.path.normpath(scripts_dir)
dest = os.path.normpath(os.path.join(scripts_dir, name))
return dest.startswith(root + os.sep)


def _raise_for_invalid_entrypoint(specification: str, scripts_dir: str) -> None:
entry = get_export_entry(specification)
if entry is not None and entry.suffix is None:
if entry is None:
return

if entry.suffix is None:
raise MissingCallableSuffix(str(entry))

if not _script_within_dir(entry.name, scripts_dir):
raise InstallationError(
f"Invalid script entry point name {entry.name!r}: the script "
f"would be installed outside the scripts directory ({scripts_dir})."
)


class PipScriptMaker(ScriptMaker):
# Override distlib's default script template with one that
Expand All @@ -419,7 +439,7 @@ class PipScriptMaker(ScriptMaker):
def make(
self, specification: str, options: dict[str, Any] | None = None
) -> list[str]:
_raise_for_invalid_entrypoint(specification)
_raise_for_invalid_entrypoint(specification, self.target_dir)
return super().make(specification, options)


Expand Down
64 changes: 64 additions & 0 deletions tests/unit/test_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,32 @@ def test_invalid_entrypoints_fail(
assert os.path.basename(wheel_path) in exc_text
assert entrypoint in exc_text

@pytest.mark.parametrize("bad_name", ["../../outside", "..", "."])
@pytest.mark.parametrize("entry_point_type", ["console_scripts", "gui_scripts"])
def test_wheel_install_rejects_entry_point_path_traversal(
self, data: TestData, tmpdir: Path, bad_name: str, entry_point_type: str
) -> None:
"""An entry point name with separators or ``..`` must not install a
script outside the scripts directory.
"""
self.prep(data, tmpdir)
wheel_path = make_wheel(
"simple",
"0.1.0",
entry_points={entry_point_type: [f"{bad_name} = simple:main"]},
).save_to_dir(tmpdir)
with pytest.raises(InstallationError) as e:
wheel.install_wheel(
"simple",
str(wheel_path),
scheme=self.scheme,
req_description="simple",
)

assert "outside the scripts directory" in str(e.value)
# Nothing was written outside the install destination.
assert not os.path.exists(os.path.join(str(tmpdir), "outside"))


class TestMessageAboutScriptsNotOnPATH:
tilde_warning_msg = (
Expand Down Expand Up @@ -665,3 +691,41 @@ def test_get_console_script_specs_replaces_python_version(
"not_pip_or_easy_install-99 = whatever",
"not_pip_or_easy_install-99.88 = whatever",
]


@pytest.mark.parametrize(
"name, within",
[
("pip", True),
("pip3.13", True),
("foo-bar.baz", True),
("...", True), # a literal filename, not a path component
("sub/script", True), # in-tree subdirectory
("a/../b", True),
("sub\\script", True), # backslash stays in-tree on POSIX and Windows
(" ../../inside", True), # distlib keeps a leading space; resolves in-tree
("../outside", False),
("../../outside", False),
("a/../../outside", False),
("/etc/cron.d/outside", False), # absolute path; os.path.join drops the root
# "." and ".." pass PyPI's [\w.-]+ name check but must be rejected here.
(".", False),
("..", False),
("", False),
],
)
def test_script_within_dir(name: str, within: bool) -> None:
assert wheel._script_within_dir(name, "/srv/env/bin") is within


def test_script_within_dir_allows_doubled_slash_root() -> None:
# A scripts directory can have a doubled leading slash
assert wheel._script_within_dir("pip", "//srv/env/bin") is True
assert wheel._script_within_dir("../outside", "//srv/env/bin") is False


@pytest.mark.skipif(not WINDOWS, reason="drive letters only matter on Windows")
def test_script_within_dir_rejects_other_drive() -> None:
# Validate that a script on a different drive is rejected,
# and doesn't throw an error
assert wheel._script_within_dir("D:\\outside", "C:\\env\\bin") is False
Loading