Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/news.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ Release Notes

**UNRELEASED**

- Fixed ``wheel info`` output, the ``wheel pack`` progress message and printed
error messages passing strings taken from the wheel (metadata fields, archive
member names, RECORD contents) to the terminal with control characters intact,
which let a crafted wheel inject terminal escape sequences
- Fixed ``wheel unpack`` taking the mode of the unpack directory itself from the
archive when a wheel contains a member whose name is made up entirely of empty,
``.`` or ``..`` path components (such as ``../``), which let a malicious wheel
Expand Down
8 changes: 7 additions & 1 deletion src/wheel/_commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ def version_f(args: argparse.Namespace) -> None:
print(f"wheel {__version__}")


def escape_control_characters(value: str) -> str:
"""Replace control characters with printable escapes so that data read from
a wheel cannot inject terminal escape sequences into the output."""
return "".join(ch if ch.isprintable() else repr(ch)[1:-1] for ch in value)


def parse_build_tag(build_tag: str) -> str:
if build_tag and not build_tag[0].isdigit():
raise ArgumentTypeError("build tag must begin with a digit")
Expand Down Expand Up @@ -169,6 +175,6 @@ def main() -> int:
args.func(args)
return 0
except WheelError as e:
print(e, file=sys.stderr)
print(escape_control_characters(str(e)), file=sys.stderr)

return 1
38 changes: 21 additions & 17 deletions src/wheel/_commands/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pathlib import Path

from ..wheelfile import WheelFile
from . import escape_control_characters


def info(path: str, verbose: bool = False) -> None:
Expand All @@ -25,9 +26,9 @@ def info(path: str, verbose: bool = False) -> None:
with WheelFile(path) as wf:
# Extract basic wheel information from filename
parsed = wf.parsed_filename
name = parsed.group("name")
version = parsed.group("ver")
build_tag = parsed.group("build")
name = escape_control_characters(parsed.group("name"))
version = escape_control_characters(parsed.group("ver"))
build_tag = escape_control_characters(parsed.group("build") or "")

print(f"Name: {name}")
print(f"Version: {version}")
Expand All @@ -41,23 +42,25 @@ def info(path: str, verbose: bool = False) -> None:
wheel_file
)

print(
f"Wheel-Version: {wheel_metadata.get('Wheel-Version', 'Unknown')}"
wheel_version = escape_control_characters(
wheel_metadata.get("Wheel-Version", "Unknown")
)
print(
f"Root-Is-Purelib: {wheel_metadata.get('Root-Is-Purelib', 'Unknown')}"
print(f"Wheel-Version: {wheel_version}")
root_is_purelib = escape_control_characters(
wheel_metadata.get("Root-Is-Purelib", "Unknown")
)
print(f"Root-Is-Purelib: {root_is_purelib}")

# Get all tags
tags = wheel_metadata.get_all("Tag", [])
if tags:
print("Tags:")
for tag in sorted(tags): # Sort tags for consistent output
print(f" {tag}")
print(f" {escape_control_characters(tag)}")

generators = wheel_metadata.get_all("Generator", [])
for generator in generators:
print(f"Generator: {generator}")
print(f"Generator: {escape_control_characters(generator)}")
except KeyError:
print("Warning: WHEEL metadata file not found", file=sys.stderr)

Expand All @@ -70,23 +73,23 @@ def info(path: str, verbose: bool = False) -> None:

summary = pkg_metadata.get("Summary", "")
if summary and summary != "UNKNOWN":
print(f"Summary: {summary}")
print(f"Summary: {escape_control_characters(summary)}")

author = pkg_metadata.get("Author", "")
if author and author != "UNKNOWN":
print(f"Author: {author}")
print(f"Author: {escape_control_characters(author)}")

author_email = pkg_metadata.get("Author-email")
if author_email and author_email != "UNKNOWN":
print(f"Author-email: {author_email}")
print(f"Author-email: {escape_control_characters(author_email)}")

homepage = pkg_metadata.get("Home-page")
if homepage and homepage != "UNKNOWN":
print(f"Home-page: {homepage}")
print(f"Home-page: {escape_control_characters(homepage)}")

license_info = pkg_metadata.get("License")
if license_info and license_info != "UNKNOWN":
print(f"License: {license_info}")
print(f"License: {escape_control_characters(license_info)}")

# Show classifiers
classifiers = pkg_metadata.get_all("Classifier", [])
Expand All @@ -95,7 +98,7 @@ def info(path: str, verbose: bool = False) -> None:
for classifier in sorted(
classifiers[:5]
): # Sort and limit to first 5
print(f" {classifier}")
print(f" {escape_control_characters(classifier)}")

if len(classifiers) > 5:
print(f" ... and {len(classifiers) - 5} more")
Expand All @@ -105,7 +108,7 @@ def info(path: str, verbose: bool = False) -> None:
if requires_dist:
print("Requires-Dist:")
for req in sorted(requires_dist): # Sort dependencies
print(f" {req}")
print(f" {escape_control_characters(req)}")
except KeyError:
print("Warning: METADATA file not found", file=sys.stderr)

Expand All @@ -121,4 +124,5 @@ def info(path: str, verbose: bool = False) -> None:
print("\nFile listing:")
for zinfo in wf.filelist:
size_str = f"{zinfo.file_size:,}" if zinfo.file_size > 0 else "0"
print(f" {zinfo.filename:60} {size_str:>10} bytes")
filename = escape_control_characters(zinfo.filename)
print(f" {filename:60} {size_str:>10} bytes")
4 changes: 3 additions & 1 deletion src/wheel/_commands/pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from packaging.version import InvalidVersion, Version

from ..wheelfile import WheelError, WheelFile
from . import escape_control_characters

DIST_INFO_RE = re.compile(r"^(?P<namever>(?P<name>.+?)-(?P<ver>\d.*?))\.dist-info$")

Expand Down Expand Up @@ -118,7 +119,8 @@ def pack(
# Repack the wheel
wheel_path = os.path.join(dest_dir, f"{name_version}-{tagline}.whl")
with WheelFile(wheel_path, "w") as wf:
print(f"Repacking wheel as {wheel_path}...", end="", flush=True)
printable_path = escape_control_characters(wheel_path)
print(f"Repacking wheel as {printable_path}...", end="", flush=True)
wf.write_files(directory)

print("OK")
Expand Down
75 changes: 72 additions & 3 deletions tests/commands/test_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytest

from wheel._commands import main
from wheel._commands.info import info

from .util import run_command
Expand All @@ -21,9 +22,9 @@


def _build_wheel_with_modified_metadata(
src_whl: str, dest_dir: os.PathLike[str], wheel_content: str
src_whl: str, dest_dir: os.PathLike[str], wheel_content: str, member: str = "WHEEL"
) -> str:
"""Copy a wheel and replace its WHEEL metadata, updating the RECORD hash.
"""Copy a wheel and replace a .dist-info metadata file, updating the RECORD hash.

Returns the path to the new wheel file.
"""
Expand All @@ -33,7 +34,7 @@ def _build_wheel_with_modified_metadata(
with zipfile.ZipFile(dest_whl, "r") as zr:
wheel_path = record_path = None
for name in zr.namelist():
if name.endswith("/WHEEL"):
if name.endswith(f"/{member}"):
wheel_path = name
elif name.endswith("/RECORD"):
record_path = name
Expand Down Expand Up @@ -198,3 +199,71 @@ def test_info_short_verbose_flag() -> None:
# Should include file listing like --verbose
assert "File listing:" in output
assert "hello/hello.py" in output


def test_info_escapes_control_characters(tmp_path: os.PathLike[str]) -> None:
"""Control characters in wheel contents must not reach the terminal raw."""
metadata_content = (
"Metadata-Version: 2.1\n"
"Name: test\n"
"Version: 1.0\n"
"Summary: innocent\x1b]52;c;aGFja2Vk\x07\x1b[8m\n"
"Author: evil\x1b[2K\x1b[1A\n"
)
whl = _build_wheel_with_modified_metadata(
TESTWHEEL_PATH, str(tmp_path), metadata_content, member="METADATA"
)
with zipfile.ZipFile(whl, "a") as zf:
zf.writestr("hello/\x1b]0;pwned\x07.py", b"")

output = _capture_info_output(whl, verbose=True)

assert "\x1b" not in output
assert "\x07" not in output
assert "Summary: innocent\\x1b]52;c;aGFja2Vk\\x07\\x1b[8m" in output
assert "Author: evil\\x1b[2K\\x1b[1A" in output
assert "hello/\\x1b]0;pwned\\x07.py" in output


def test_info_escapes_control_characters_in_errors(
tmp_path: os.PathLike[str],
) -> None:
"""WheelError messages printed by the CLI must not contain raw escapes."""
dest_whl = os.path.join(str(tmp_path), TESTWHEEL_NAME)
shutil.copy2(TESTWHEEL_PATH, dest_whl)
with zipfile.ZipFile(dest_whl, "r") as zr:
record_path = next(n for n in zr.namelist() if n.endswith("/RECORD"))
record = zr.read(record_path).decode()

# Rename the hash algorithm of the first hashed entry in RECORD so that it
# carries an escape sequence
lines = record.splitlines()
for index, line in enumerate(lines):
path, _, rest = line.partition(",")
hash_sum, _, size = rest.partition(",")
if hash_sum:
digest = hash_sum.partition("=")[2]
lines[index] = f"{path},ev\x1bil={digest},{size}"
break
modified_record = "\n".join(lines) + "\n"

tmp_whl = os.path.join(str(tmp_path), "tmp.whl")
with zipfile.ZipFile(dest_whl, "r") as zr, zipfile.ZipFile(tmp_whl, "w") as zw:
for item in zr.infolist():
if item.filename == record_path:
zw.writestr(item, modified_record.encode())
else:
zw.writestr(item, zr.read(item.filename))
os.replace(tmp_whl, dest_whl)

stderr = StringIO()
with (
patch.object(sys, "argv", ["wheel", "info", dest_whl]),
patch.object(sys, "stdout", StringIO()),
patch.object(sys, "stderr", stderr),
):
assert main() == 1

error_output = stderr.getvalue()
assert "\x1b" not in error_output
assert "Unsupported hash algorithm: ev\\x1bil" in error_output
Loading