Skip to content

pip 21.3.1.post1 (Py3.6): backport seven pip CVEs; vendored certifi 2026.10.19.post1 - #4

Merged
icanhasmath merged 18 commits into
21.3.1.xfrom
21.3.1-security
Oct 22, 2026
Merged

icanhasmath merged 18 commits into
21.3.1.xfrom
21.3.1-security

Conversation

@icanhasmath

Copy link
Copy Markdown

First ActiveState security release for the pip 21.3.1 line, the last pip that supports Python 3.6.

CVE Advisory Status
CVE-2023-5752 GHSA-mq26-g339-26xf Patched: ceff283f4
CVE-2026-1703 GHSA-6vgw-5pg2-w6jp Patched: 1ce8f44e9
CVE-2025-8869 GHSA-4xh5-x5gv-qwph Patched: dff236862 (Python 3.6 has no data_filter, so this is the only guard)
CVE-2026-3219 GHSA-58qw-9mgm-455v Patched: 5e44cc346
CVE-2026-8643 GHSA-wf93-45jw-7689 Patched as defense in depth: 1dd659095 (vendored distlib 0.3.3 already strips separators; only ./.. reached the join)
CVE-2026-13346 GHSA-qwm4-qh6w-59xr Patched: afe0929e0 (pristine turned a doubly-encoded URL into ../../../evil-…whl)
CVE-2026-6357 GHSA-jp4c-xjxw-mgf9 Patched: fd30acd50
CVE-2022-23491 GHSA-43fp-rhv2-5gv8 Patched by certifi upgrade: e65adb438 (TrustCor)
CVE-2023-37920 GHSA-xqr8-7jwr-rhp7 Patched by certifi upgrade (E-Tugra)
CVE-2024-39689 GHSA-248v-346w-9cwc Patched by certifi upgrade (GLOBALTRUST, present in the 2021.05.30 bundle)
— GHSA-6q48-6c7w-9gw2 Patched by certifi upgrade (emSign roots with compromised intermediates, critical)

Notes

  • Vendored certifi 2021.05.30 → 2026.10.19.post1. Python 3.6 has no importlib.resources, so this uses the AS build's core.py, which has a fallback for older Pythons. 21.3.1's extra _PIP_STANDALONE_CERT patch, used for isolated builds, is kept and certifi.patch was regenerated. That override had a latent bug: contents() raised NameError when the variable was set. It is now fixed.
  • CVE-2026-6357 port: follows upstream. The check fetches before the command body and only prints afterwards. Command.handle_pip_version_check becomes the pip_version_check(options, args) context manager (internal API, matches upstream). UpgradePrompt is a NamedTuple because 3.6 has no dataclasses.

Validation (Python 3.6.15, python:3.6-slim)

  • Unit tests: full tests/unit gives 1492 passed, against 1391 on pristine 21.3.1, with the same failure set (1 failure and 3 errors, all from git missing in the image). The new tests fail on pristine.
  • CVE-2026-6357 end-to-end check: a wheel that ships only a top-level _manylinux.py is installed with the version check enabled. The vendored packaging retries import _manylinux on every tag computation, because a failed import is never cached. Pristine pip runs the malicious file after the install; this build does not.
  • Smoke test: pip --version reports 21.3.1.post1, pip debug shows certifi==2026.10.19.post1, and a real pip download six==1.16.0 and an isolated pip wheel --no-binary :all: tomli==1.2.3 both work. flake8 counts are unchanged.

🤖 Generated with Claude Code

icanhasmath and others added 9 commits October 21, 2026 17:20
GHSA-mq26-g339-26xf. Mercurial.get_base_rev_args returned the bare
revision as its own argv element, so a revision such as
--config=alias.update=!<cmd> reached hg as a standalone token and was
parsed as a Mercurial command-line option, allowing configuration
injection and code execution when installing from an hg URL.

Return a single -r=<rev> token so hg always treats the whole value as the
revision. Upstream: pypa#12306 (389cb79), released in 23.3. On 21.3.1
the method returned [rev] rather than upstream's ["-r", rev], so the fix
also adds the -r flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GHSA-6vgw-5pg2-w6jp. is_within_directory used os.path.commonprefix, which
compares paths character by character, so a sibling such as
/dest/parentfoo counted as within /dest/parent. A crafted archive could
therefore write files into a sibling directory whose name has the install
path as a string prefix.

Switch to os.path.commonpath, which compares whole path components.
Upstream: 4c651b7 (pypa#13777), released in 26.0. Applies as-is on
Python 3.6. Adds upstream's prefix-substring case to
test_is_within_directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GHSA-4xh5-x5gv-qwph. untar_file extracted symlink members without checking
their target. A malicious sdist could ship a symlink pointing at an
arbitrary host path, and later writes through that link would create or
overwrite files outside the install directory. Upstream treats this as the
fallback path for interpreters without tarfile.data_filter (PEP 706).
Python 3.6 never has data_filter, so on 21.3.1 it is the only path.

Add is_symlink_target_in_tar() and refuse any symlink member whose target is
not itself a member of the same archive (InstallationError). Upstream:
pypa#13550 (2490eb2..b154d06), released in 25.3. 21.3.1 has a single
extraction loop, so the check goes there directly. Tests port upstream's
four link tests without the data_filter monkeypatching, using
TarInfo.linkname.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ack_file

GHSA-58qw-9mgm-455v. unpack_file ran zipfile.is_zipfile() before looking at
the filename's tar extensions. is_zipfile scans for a ZIP end-of-central-
directory record anywhere in the file, so a tar+ZIP polyglot (a ZIP
appended to a valid .tar.gz) was always extracted as a ZIP, letting an
attacker control which set of files pip actually installs.

Choose the format in order of decreasing reliability (content-type, then
filename extension, then magic signature) and only trust the magic
signature when it is unambiguous. A file that is both a valid zip and a
valid tar is rejected with InstallationError. Upstream: pypa#13870
(c4cdbf9), released in 26.1. Ported directly. Python 3.6 adaptations in the
tests only: Optional[...] instead of PEP 604 unions, call_args[1] instead
of call_args.kwargs (3.8+), and tmpdir/open() instead of tmp_path
write_bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pts dir

GHSA-wf93-45jw-7689. pip joined console_scripts / gui_scripts entry point
names onto the scripts directory without checking that the result stays
inside it. Add _script_within_dir() and have _raise_for_invalid_entrypoint()
(called from PipScriptMaker.make with self.target_dir) raise
InstallationError for any name that resolves outside the scripts dir.

Upstream: pypa#14000 (8eb1784), released in 26.1.2. Ported as-is.

Exposure on 21.3.1 is narrower than upstream: the vendored distlib 0.3.3
only accepts [\w.+-]+ entry point names, so a name with path separators
(upstream's ../../outside payload) is reduced to its last component and
stays in the scripts dir. The traversal came with distlib 0.4's looser
name regex. On this line only '.' and '..' reach the join; they now fail
with a clear InstallationError. Backported as defense in depth. Tests
mirror upstream's, plus one pinning the distlib 0.3.3 separator behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GHSA-qwm4-qh6w-59xr. Link.path already percent-decodes the URL path, but
Link.filename unquoted the basename a second time, so a doubly-encoded
separator from a malicious index (..%252F..%252Fevil.whl) became a real
"../../evil.whl" and the download was written outside the download
directory. This matters most for `pip download --only-binary`.

- Link.filename no longer decodes twice and always returns a single path
  component (no separator, never "." or ".."), falling back to the netloc.
- New PathComponent NewType and helpers _to_path_component /
  as_path_component / join_within_directory in pip._internal.models.link.
  The HTTP response file name (including the Content-Disposition value,
  where a bare ".." previously got through sanitize_content_filename) is
  validated with as_path_component.
- All four download-path joins go through join_within_directory: Downloader,
  BatchDownloader (21.3.1 has both), _check_download_dir and
  RequirementPreparer.save_linked_requirement.

Upstream: pypa#14110 (10dfb6b), released in 26.2.0.

Adaptation: NewType is only enforced by mypy, so join_within_directory also
re-validates its argument at runtime and raises ValueError, stricter than
upstream and the same as the 20.3.4.post2 backport. Tests mirror upstream's
test_link.py additions, plus download-level tests for the doubly-encoded
URL and Content-Disposition "..".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GHSA-jp4c-xjxw-mgf9. pip ran its self-version check in a `finally` after
the command body, so after `pip install` had already written wheels into
site-packages. The check queries the environment and builds a
PackageFinder, and the vendored packaging.tags then probes `import
_manylinux` (the PEP 513/600 override hook, not lru_cached). A
just-installed wheel that ships a top-level `_manylinux.py` was therefore
imported and executed by pip itself. Reproduced on pristine 21.3.1 under
Python 3.6 with a wheel containing only `_manylinux.py`. A sys.modules trace
across the post-install check showed nothing, because a failed
`import _manylinux` is never stored in sys.modules; the earlier "not
applicable" assessment was wrong.

Split the check into a fetch phase, which runs before the command body
(environment lookup, state file, remote lookup, "installed by pip"
decision), and an emit phase, which only logs the prompt afterwards:

- Command.handle_pip_version_check(options) becomes the context manager
  Command.pip_version_check(options, args) and wraps the command body in
  Command._main.
- IndexGroupCommand fetches before yielding and emits in `finally`, each
  guarded by upstream's "error checking the latest version" handling.
- InstallCommand skips the check when pip itself is a requirement.
- self_outdated_check gains UpgradePrompt, pip_self_version_check_fetch
  and pip_self_version_check_emit. The installer is read from the dist
  found during fetch instead of re-querying the environment afterwards, so
  was_installed_by_pip is removed, as upstream.

Upstream: pypa#13923 (b369bfc), released in 26.1. Follows the
ActiveState 24.0 port (bedea55).

Adaptation: 21.3.1 has no index_command.py, _run_wrapper or rich-rendered
UpgradePrompt, so the context manager lives in cli/req_command.py and wraps
run() in _main. UpgradePrompt is a NamedTuple (no dataclasses on Python
3.6), and emit keeps 21.3.1's existing warning text.
pip_self_version_check(session, options) is kept as a fetch+emit wrapper so
the old entry point still works. 21.3.1 has no externally-managed check,
so that part of upstream does not apply. Tests follow upstream, plus
fetch -> body -> emit ordering and a test that emit never touches the
environment or PackageFinder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vendored certifi 2021.05.30 still trusts root certificates Mozilla has
since removed for cause:

- CVE-2022-23491 (GHSA-43fp-rhv2-5gv8): TrustCor RootCert CA-1, CA-2, ECA-1
- CVE-2023-37920 (GHSA-xqr8-7jwr-rhp7): E-Tugra Certification Authority
- CVE-2024-39689 (GHSA-248v-346w-9cwc): GLOBALTRUST 2020 (added in
  certifi 2021.05.30, so this line is affected)
- GHSA-6q48-6c7w-9gw2 (no CVE): emSign ECC Root CA - G3, whose
  intermediate CA keys were compromised

Replace the vendored copy with ActiveState certifi 2026.10.19.post1
(ActiveState/certifi 87fbc73). That is upstream certifi 2026.10.19, the
release vendored by pip 26.2.2, with a pre-3.7 fallback. Python 3.6 has no
importlib.resources, so upstream 2026.10.19 cannot be vendored as-is on
this line. cacert.pem, __init__.py and LICENSE are byte-identical to that
release; __main__.py gets pip's usual import rename.

core.py keeps both of pip's vendoring patches: the "certifi" ->
"pip._vendor.certifi" resource rename, and the _PIP_STANDALONE_CERT
override that build_env.py relies on so the standalone pip in an isolated
build environment uses the parent's CA bundle. The override now also
defines contents(); the old patch skipped read_text() in that branch, so
contents() raised NameError there. tools/vendoring/patches/certifi.patch
was regenerated and reproduces the vendored core.py from the release.

Checked on Python 3.6, 3.9 and 3.12 (all three core.py branches) and with
_PIP_STANDALONE_CERT set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…10.19.post1

Bump version 21.3.1 -> 21.3.1.post1 (PEP 440 post-release) and add a NEWS
section covering CVE-2023-5752, CVE-2025-8869, CVE-2026-1703,
CVE-2026-3219, CVE-2026-6357, CVE-2026-8643 and CVE-2026-13346, plus the
vendored certifi upgrade (CVE-2022-23491, CVE-2023-37920, CVE-2024-39689,
GHSA-6q48-6c7w-9gw2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ezequielp-activestate ezequielp-activestate left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 blockers. Requesting changes.

The other backports match upstream, and the vendored certifi reproduces exactly from the 2026.10.19.post1 tag. The blockers come from two upstream follow-up fixes that weren't taken.

🔴 Blockers

  • Every hg+…@<rev> install breaks. src/pip/_internal/vcs/mercurial.py:37 returns [f"-r={rev}"]. That is upstream's first CVE-2023-5752 fix (pypa#12306), which broke Mercurial: hg reads -r=0 as the revision =0. Upstream replaced it with --rev= in pypa#12373 (6dbd9c68f0, released in 23.3.1).
    • hg update -r=0 fails with hg: parse error at 0: not a prefix: =; hg update --rev=0 works.
    • obtain, switch and update all build their command line this way.
    • Fix: use --rev=, and update test_rev_options_to_args / test_mercurial_rev_not_interpreted_as_option in tests/unit/test_vcs.py, which assert the broken form.
  • Malicious sdists can still write outside the extract directory (CVE-2025-8869). The backport takes upstream's 25.3 fix but not the later hardening in pypa#14127 (17eb176dc, released in 26.2). Python 3.6 has no tarfile.data_filter, so this code is the only guard for every user of this line. Calling untar_file on the PR head under Python 3.6:
    • symlink outside_link -> .., then outside_link/outside.txt, then a .. dir member: outside.txt is written next to the extract directory. The InstallationError only comes at the later .. member, after the write.
    • ., redir -> ., redir/up -> .., redir/up/outside.txt: no error, and outside.txt is written outside.
    • Why the check misses it: is_symlink_target_in_tar (unpacking.py:92-102) only asks whether the link target is an archive member, and ./.. are members. The per-member check at :198 uses abspath, not realpath.
    • Fix: port 17eb176dc and its two tests.

🟡 Should fix

  • Installs under a // path now fail. is_within_directory in utils/unpacking.py:87 uses os.path.commonpath, which drops the POSIX // prefix: is_within_directory("//srv/env/bin", "//srv/env/bin/pip") is False (it's True on the base branch). pip install --no-index --prefix //tmp/pfx1 demo-1.0-py3-none-any.whl fails with "trying to install outside the target directory" at operations/install/wheel.py:503; on the base branch it installs. Upstream fixed it in 7ff8bdd81.
  • Scripts directory at a filesystem root. _script_within_dir (operations/install/wheel.py:418-426) uses dest.startswith(root + os.sep), so with a scripts dir of / or C:\ every entry point is rejected. Upstream has the same issue.
  • One helper fixes both of the above (upstream's startswith(abs_dir + os.sep) form fixes // but breaks /):
    prefix = root if root.endswith(os.sep) else root + os.sep
    return target == root or target.startswith(prefix)
  • Valid sdists with symlinks are now refused. is_symlink_target_in_tar needs the exact member name, so two valid archive shapes fail on this PR and unpack on the base branch: a ./-prefixed archive (as made by tar -C dir .) with ./pkg/link -> target, and a symlink to a directory that has no directory entry of its own. Ordinary setuptools sdists and GitHub tarballs are fine. Comparing against the set of normalized member names plus their parent directories would fix it.

⚪ Nits

  • A .. download name surfaces as a raw ValueError traceback (network/download.py:118). The write is still blocked.
  • On Windows, os.path.commonpath raises ValueError for a member on another drive (utils/unpacking.py:87). Still blocked, but as a traceback.

📋 Checked

  • CVE-2026-1703, 3219 and 8643 are identical to pypa#13777, pypa#13870 and pypa#14000. CVE-2026-13346 matches pypa#14110, and all four download write sites use join_within_directory. CVE-2026-6357 matches pypa#13923, with UpgradePrompt as a NamedTuple for 3.6.
  • Vendored certifi: cacert.pem, __init__.py and LICENSE are byte-identical to 2026.10.19.post1, and certifi.patch reproduces core.py/__main__.py.
  • Tests on Python 3.6: 665 passed across 12 test files; the 3 failures are environmental (no git; running as root). The new tests fail on the base source for every CVE.

⚠️ Not assessed

  • Full tests/unit suite, functional tests, and Windows.
  • A real pip install hg+…@rev end to end; only the hg command line was run.
  • Real network downloads against the new CA bundle.

icanhasmath and others added 8 commits October 22, 2026 10:04
Reviewer blocker on PR #4: the CVE-2025-8869 backport took upstream's 25.3
fix but not the later hardening in pypa#14127 (17eb176, pip 26.2).
Python 3.6 has no tarfile.data_filter, so untar_file's member loop is the
only guard for every user of this line, and two archive shapes still wrote
outside the extract directory:

- symlink outside_link -> .., then outside_link/outside.txt (the error only
  came at a later ".." member, after the write);
- ".", redir -> ., redir/up -> .., redir/up/outside.txt (no error at all).

is_symlink_target_in_tar only asks whether the link target is an archive
member, and "." / ".." are members; the per-member check used abspath.

Port the upstream fix: is_within_directory gains a keyword-only
resolve_symlinks flag (realpath instead of abspath); each member is also
checked with symlinks resolved, and a symlink whose target resolves outside
the destination is rejected before it is created. Upstream's two tests are
ported unchanged (they force the fallback where data_filter exists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t_within_dir

Two reviewer should-fix findings on PR #4:

- is_within_directory used os.path.commonpath, which drops a POSIX "//"
  prefix: is_within_directory("//srv/env/bin", "//srv/env/bin/pip") was
  False, so `pip install --prefix //tmp/pfx <wheel>` failed with "trying to
  install outside the target directory". On Windows commonpath also raised
  ValueError for a target on another drive. (Upstream fixed the "//" case
  in 7ff8bdd, but with a form that rejects everything under "/".)
- _script_within_dir used dest.startswith(root + os.sep), so with a scripts
  directory of "/" or "C:\" every entry point was rejected.

Compare on a component boundary that is root-safe: the target equals the
directory, or starts with the directory plus a separator (a root already
ends with one). _script_within_dir reuses is_within_directory and still
rejects the directory itself (".", ".."), so the CVE-2026-8643 and
CVE-2026-1703 cases keep failing closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer blocker on PR #4: the CVE-2023-5752 backport emitted `-r=<rev>`,
which is upstream's first fix (pypa#12306) and broke Mercurial: hg
parses `-r=0` as the revset `=0` and fails with "hg: parse error at 0: not
a prefix: =". Every `hg+...@<rev>` install (obtain, switch and update all
build their command line this way) failed.

Port upstream's follow-up pypa#12373 (6dbd9c6, pip 23.3.1): use the
long form `--rev=<rev>`. It is still a single token, and hg reads
`--rev=--config=...` as a revision value, not an option, so the
CVE-2023-5752 protection holds. Update the two tests that asserted the
broken form.

Verified end to end with Mercurial 7.0.1: `pip install
"hg+file:///repo@0#egg=demo"` installs revision 0 with this change and
fails with the previous `-r=` form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer finding (#3 should-fix, #5 nit; same code on this line): the
CVE-2026-6357 port wrapped the already-intercepted `run` in
`with self.pip_version_check(...)`, so the pre-command fetch ran outside
intercepts_unhandled_exc. A ValueError from `_arg_refers_to_pip` (urlparse's
"Invalid IPv6 URL" for `pip install "foo @ http://[bad/x"`) or a Ctrl-C
during the fetch escaped as a raw, uncaught traceback (exit 1).

Move the `with` into an inner function that is itself wrapped by
intercepts_unhandled_exc, which is where upstream has it (`_inner_run`).
Also let `_arg_refers_to_pip` treat a bare ValueError like
InvalidRequirement (not pip), so the malformed requirement reaches the
normal requirement parser.

`pip install "foo @ http://[bad/x"` now behaves exactly as on pristine
21.3.1 (pip's "ERROR: Exception:" report, exit 2); a Ctrl-C during the
fetch prints "Operation cancelled by user". Tests cover both, plus
_arg_refers_to_pip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer should-fix on PR #4: is_symlink_target_in_tar looked the link
target up with tar.getmember(), which needs the exact member name. Two valid
archive shapes were therefore refused on Python 3.6, where this tar
fallback is the only extraction path (they unpack on pristine 21.3.1):

- a "./"-prefixed archive (as made by `tar -C dir .`) with
  ./pkg/link -> target.txt;
- a symlink to a directory that has no directory entry of its own.

Compare against the set of normalized member paths plus every implied
parent directory instead. The set is built once per archive and passed in,
so a large archive with many symlinks stays linear. Upstream 26.2.2 still
refuses both shapes; this is a deliberate improvement. It does not weaken
containment: the symlink-resolving is_within_directory checks from the
CVE-2025-8869 hardening reject any link or member that resolves outside
the destination, and a link to a path that is not in the archive is still
refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…back

Reviewer nit (all five PRs): a download whose name reduces to "." / ".."
(e.g. `Content-Disposition: filename=".."` with a content type that has no
known extension) was correctly refused by as_path_component /
join_within_directory, but as a bare ValueError, so pip printed
"ERROR: Exception:" and a traceback (exit 2).

Add UnsafeFilenameError(InstallationError, ValueError) and raise it instead.
It is still a ValueError, so existing callers and tests keep working, and
pip's command loop now reports it as a normal error line:

    ERROR: Unexpected file name derived from URL: '..'   (exit 1)

Verified end to end against a local server sending that header. The write
is still blocked either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The release section's `.. note` (no `::`) was an RST comment and did not
  render (reviewer nit on #3/#7; same pattern here). It is now a `.. note::`
  directive. The upstream header comment on line 1 is left as is.
- Record the review fixes in the 21.3.1.post1 section: the CVE-2025-8869
  hardening (pypa#14127), the Mercurial `--rev=` form for the
  CVE-2023-5752 fix (pypa#12373), the component-boundary wording for
  CVE-2026-1703, and a Bug Fixes list for root-safe path checks, the version
  check's error handling, normalized tar symlink lookup and the clean error
  for unsafe download names.

Checked with docutils: no warnings and the note renders as an admonition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
posixpath.normpath keeps a leading "//" and posixpath.dirname("//") is
"//", so collecting the implied parent directories of a member named
"//x/y" never terminated: a crafted archive could hang pip in the tar
fallback path. Stop walking parents once dirname() stops changing the
name, and add a regression test.

The bug was in the shared review-round helper (item G) and was caught on
the 26.0.1 line; this applies the same guard here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
Review finding on #4/#6/#7, which applies equally to this
line: the CVE-2025-8869 backport only checked that a symlink's target is
an archive member, and "." and ".." are members. A crafted sdist could
therefore write outside the extract directory through a symlink:

- outside_link -> .., then outside_link/outside.txt (the error only came
  at a later ".." member, after the write);
- ., redir -> ., redir/up -> .., redir/up/outside.txt (no error at all).

Both escapes reproduce on every released 20.3.4.x. Python 2.7 has no
tarfile.data_filter, so untar_file's member loop is the only guard.

Port upstream pypa#14127 (17eb176, released in 26.2):
- is_within_directory() gains resolve_symlinks=False (positional default,
  so existing two-argument callers are unaffected); when true it compares
  realpaths.
- Each member is rejected unless both the abspath and the realpath of its
  destination stay inside the extract directory.
- A symlink is rejected when its target, resolved, leaves the extract
  directory, before is_symlink_target_in_tar() is consulted.

The new symlink check reuses this line's existing "has a link ... trying
to point outside target directory" message. Upstream's two tests are
ported (no data_filter monkeypatch is needed on Python 2.7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
…t_within_dir

Review finding (#3 nit carried over from #2, and #4-#7):
both checks appended os.sep to the directory before a prefix test, so a
directory that is a filesystem root ("/" or "C:\\") became "//" and
rejected every target. With a scripts directory of "/", every entry
point failed to install.

- is_within_directory(): uses the directory itself as the prefix when it
  already ends with a separator. It still never uses os.path.commonpath
  (absent on Python 2.7, and it drops a POSIX "//" prefix), so the
  CVE-2026-1703 component boundary and "//" roots keep working.
- _script_within_dir(): now delegates to is_within_directory() and
  additionally refuses a script that resolves to the directory itself.

Tests cover "/" and "//" roots, the realpath mode added for
CVE-2025-8869, and the Windows drive-root case (skipped off Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
Review finding (#4, same code on this line): the
CVE-2025-8869 symlink check required the link target to match a member
name exactly, so two valid archive shapes were refused that the pristine
20.3.4 unpacked:

- "./"-prefixed archives (as produced by `tar -C dir .`), e.g. a
  ./pkg/link -> target.txt member;
- a symlink to a directory that has no directory entry of its own.

On Python 2.7 untar_file's member loop is the only extraction path, so
this affected every such sdist. Upstream 26.2.2 behaves the same; this is
a deliberate improvement. Containment is enforced by the realpath checks
added in the previous commit, not by this lookup.

- New _tar_member_paths(): posix-normalized member names plus every
  implied parent directory.
- is_symlink_target_in_tar() compares the normalized link target against
  that set. It takes an optional member_paths so untar_file() computes the
  set once instead of once per symlink.

Tests cover both shapes and a link to a non-member (still refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
Review finding (#4, same code on this line): the
CVE-2023-5752 backport in 20.3.4+security.1 passes the revision as
"-r=<rev>". hg parses that as the revision "=<rev>" and fails, so every
`pip install hg+...@<rev>` has been broken since 20.3.4+security.1
(also in 20.3.4.post2):

    hg update -q -r=0
    hg: parse error at 0: not a prefix: =

Upstream hit the same regression with its first CVE-2023-5752 fix
(pypa#12306) and replaced it with the long form in pypa#12373
(6dbd9c6, pip 23.3.1). Port that: get_base_rev_args() returns
["--rev=<rev>"]. The revision is still a single token, so a malicious
revision such as "--config=..." is still read by hg as a revision value
(verified: `hg update --rev=--config=x` is a revision parse error, not an
option), and the CVE protection holds.

Verified end to end in python:2.7-slim with Mercurial 4.8.2:
`pip install "hg+file:///repo@0#egg=hgdemo"` fails on the previous head
with the parse error above and installs revision 0 with this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
Review nit (#3, carried over from #2, and #4-#7): a
download whose derived file name is not a single safe component (e.g.
`Content-Disposition: filename=".."`) was refused, but as a bare
ValueError, so pip printed "ERROR: Exception:" plus a traceback and
exited 2.

Add pip._internal.exceptions.UnsafeFilenameError(InstallationError,
ValueError) and raise it from as_path_component() and
join_within_directory() with the same messages. It remains a ValueError,
so existing callers and tests are unaffected. As an InstallationError,
pip's command loop reports it as a single line:

    ERROR: Unexpected file name derived from URL: '..'

(exit 1). Verified end to end against a local server sending
filename="..". The write was already blocked before; only the reporting
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
…t_within_dir

Review findings on #5 (and the same findings on #4/#6/#7):

- is_within_directory used os.path.commonpath (from the 24.0.0.1
  CVE-2026-1703 backport), which drops a POSIX "//" prefix, so
  `pip install --prefix //tmp/x <wheel>` failed with "trying to install
  outside the target directory". On Windows commonpath also raises
  ValueError for a target on another drive.
- _script_within_dir used dest.startswith(root + os.sep), so with a
  scripts directory at a filesystem root ("/" or "C:\") every entry
  point was rejected.

is_within_directory now compares on a path-component boundary without
commonpath: target == directory, or target starts with the directory
plus a separator (not doubled when the directory is already a root).
_script_within_dir reuses it and still rejects a name that resolves to
the scripts directory itself ("." / "").

Upstream fixed the "//" case in pypa/pip 7ff8bdd with the
startswith(abs_dir + os.sep) form, which rejects everything under "/";
this form handles both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
)

Review finding (raised as a blocker on #4/#6/#7; confirmed
on this line too): the CVE-2025-8869 backport shipped in 24.0.0.1 only
checks that a symlink's target is another archive member, and "." / ".."
can be members. Two archive shapes therefore still wrote outside the
extract directory:

- symlink `outside_link -> ..`, then `outside_link/outside.txt`, then a
  `..` directory member (the error came only after the write);
- `.`, `redir -> .`, `redir/up -> ..`, then `redir/up/outside.txt`
  (no error at all).

pip 24.0 has no tarfile.data_filter path, so on this line untar_file's
member loop is the only guard, for every Python version.

Port upstream pypa#14127 (17eb176, released in 26.2):
- is_within_directory gains a keyword-only `resolve_symlinks` option that
  compares realpath()s;
- each member is rejected if its path escapes either textually or after
  resolving symlinks;
- a symlink is rejected if its target resolves outside the destination,
  before anything can be written through it.

Upstream's two regression tests are included and fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
Review finding on #4 (same code on this line): the
CVE-2025-8869 symlink check looked up the link target with
tar.getmember(), which needs the exact member name. Two valid archive
shapes were therefore refused on the tar fallback path:

- "./"-prefixed archives (as produced by `tar -C dir .`), e.g.
  `./pkg/link -> target.txt`;
- a symlink to a directory that has no directory entry of its own.

is_symlink_target_in_tar now compares against the set of normalized
member paths plus every implied parent directory, computed once per
archive (keyword-only `member_paths`) to avoid O(n^2) work. Upstream
26.2.2 still refuses both shapes; this is a deliberate improvement.
Containment is still enforced by the realpath checks from the previous
commit (pypa#14127), and a link to a path that is not in the archive
is still refused. Tests cover all three cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
Review finding on #4 (should fix; the same code is on this
line): on the tar fallback path, is_symlink_target_in_tar() required the
link target's exact member name, so valid archives were refused:
- "./"-prefixed archives (as made by `tar -C dir .`) with
  `./pkg/link -> target`, and
- a symlink to a directory that has no directory entry of its own.
Upstream 26.2.2 behaves the same; this is a deliberate improvement on the
fallback path, which is the only path on Python 3.8.0-3.8.16.

Compare against the set of normalized member paths plus every implied
parent directory, computed once per archive and passed in, so the check
stays linear. Containment is not weakened: symlinks are still required to
resolve inside the destination (realpath checks from pypa#14127), and a
link to something that isn't in the archive is still refused (new test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
…back

Review nit on #5 (and #3/#4/#6/#7): when a server's
Content-Disposition (or a URL) yields a download name of ".." or ".",
as_path_component raised a bare ValueError and pip printed a traceback.
The write was already blocked; only the reporting was poor.

Add pip._internal.exceptions.UnsafeFilenameError, a subclass of both
InstallationError and ValueError, and raise it from as_path_component
and join_within_directory. Existing callers and tests that expect
ValueError keep working, and pip's command loop now reports it as
"ERROR: Unexpected file name derived from URL: '..'" (exit 1) instead of
"ERROR: Exception:" plus a traceback (exit 2). Verified end to end
against a local server sending `Content-Disposition: filename=".."`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
…t_within_dir

Reviewer findings (#7, should fix; same on #4/#5/#6):

- is_within_directory used os.path.commonpath, which drops the POSIX "//"
  prefix, so `pip install --prefix //tmp/x <wheel>` failed with "trying to
  install outside the target directory" (already broken on the 26.0.1 base).
  Upstream fixed it in 7ff8bdd (pypa#14001, 26.1.2) with
  startswith(abs_dir + os.sep), but that form rejects every target when the
  directory is a filesystem root ("/" or "C:\\").
- _script_within_dir used dest.startswith(root + os.sep), so a scripts
  directory of "/" or "C:\\" rejected every console/gui script.

Both now use one prefix rule: a target is within a directory when it equals
it or starts with the directory plus a separator, where a directory that
already ends with a separator (a filesystem root) is used as-is. No
commonpath, so a target on another Windows drive is simply outside instead of
raising ValueError. _script_within_dir delegates to is_within_directory and
still refuses a name that resolves to the scripts directory itself.

Tests cover "//" roots, "/" as the directory and as the scripts directory,
and the other-drive case on Windows; the existing CVE-2026-1703 prefix-sibling
and CVE-2026-8643 cases still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
Reviewer finding (#4, should fix; the same code is on every
AS pip line): on the tar fallback path (no tarfile.data_filter), the symlink
check looked the link target up with tar.getmember(), which needs the exact
stored member name. Two valid archive shapes were therefore refused:

- "./"-prefixed archives, as produced by `tar -C dir .`, e.g.
  ./pkg/link -> target.txt (stored as "./pkg/target.txt");
- a symlink to a directory that has no directory entry of its own and only
  exists as the parent of other members.

Upstream 26.2.2 behaves the same; this is a deliberate improvement. The link
target is now compared against the set of normalized member paths plus every
implied parent directory. That set is computed once per archive and passed
in (is_symlink_target_in_tar gains a keyword-only member_paths argument), so
extraction stays linear. Containment is still enforced by the realpath checks
from the previous review commit (pypa#14127): a link to "." or ".." or to
an absolute path is rejected there, and a link to a path that is not in the
archive is still refused here.

The parent walk stops at any fixed point: posixpath.dirname("//") is "//",
so a "//"-prefixed member name would otherwise loop forever.

Tests: both valid shapes unpack, a link to a non-member is refused, and
collecting member paths terminates for a "//" member name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@icanhasmath

Copy link
Copy Markdown
Author

Thanks for the review. Both blockers and all the should-fix items are addressed. The five PRs were reviewed together before fixing, because most findings share a root cause. Every line got the same helper code from one shared spec, and some findings were applied to lines where they were not flagged. Everything is in new commits on top of the reviewed head, with no force-push.

🔴 Mercurial -r=: fixed in 1f35dbc0a. It now passes --rev=<rev> (pypa#12373), and test_rev_options_to_args and test_mercurial_rev_not_interpreted_as_option are updated. hg update --rev=--config=x is still read as a revision, so the CVE-2023-5752 protection holds. A real pip install "hg+file:///repo@0#egg=demo" installs revision 0.

🔴 CVE-2025-8869 hardening: fixed in 61f744b70, a port of pypa#14127 (17eb176dc) with its two tests. is_within_directory gains a keyword-only resolve_symlinks. Each member is checked with and without symlinks resolved, and a symlink whose resolved target leaves the extract directory is refused before anything is written through it.

🟡 // installs and a scripts dir at a filesystem root: fixed in 65f312160, using the helper you suggested: target == root or target.startswith(root if root.endswith(os.sep) else root + os.sep), with no commonpath. A member on another Windows drive now returns False instead of raising. _script_within_dir reuses it and still rejects ./...

🟡 Valid symlink archives refused: fixed in 960bde606. Link targets are now compared against normalized member names plus implied parent directories, computed once per archive. Upstream 26.2.2 refuses these shapes too, so this is a documented improvement on upstream; the realpath checks from 61f744b70 are what enforce containment.

⚪ A .. download name as a raw ValueError: fixed in 188d999bb, using UnsafeFilenameError(InstallationError, ValueError).

⚪ Windows commonpath ValueError: fixed by 65f312160 above.

Also included: fb2e14d22 moves the pre-command version check inside pip's error handling (upstream's _inner_run shape), as #3/#5 found. pip install "foo @ http://[bad/x" now matches pristine 21.3.1, with exit 2 and pip's ERROR: Exception: report. That report still includes a traceback, because the error comes from 21.3.1's own requirement parser. 440ee9524 updates NEWS.

  • Added in this round: a member named //x/y made _tar_member_paths loop forever, because posixpath.dirname("//") == "//". This was a bug in the new symlink-name helper, caught during the round. A fixed-point guard and a test are included.

Re-verified on a wheel built from the new head, in the line's Docker image:

  • CVE-2025-8869: both Fix symlink path traversal in untar fallback pypa/pip#14127 attack archives are refused (escaped=False). Before this round they wrote outside.txt outside the extract directory.
  • Archive shapes: a ./-prefixed archive, a symlink to a directory with no directory entry, and an ordinary sdist symlink all unpack.
  • CVE-2026-6357: the end-to-end _manylinux reproducer reports not executed.
  • CVE-2026-1703: the sibling-prefix reproducer is blocked.
  • Other checks: pip install --no-index --prefix //tmp/pfx <wheel> succeeds. A Content-Disposition: filename=".." download now ends with ERROR: Unexpected file name derived from URL instead of a traceback. A real pip download six==1.16.0 works.
  • Tests: the full tests/unit run on Python 3.6.15 gives 1512 passed, against 1492 at your reviewed head, with the same git-only failures.

🤖 Generated with Claude Code

@ezequielp-activestate ezequielp-activestate left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of e710286: both blockers and all should-fixes fixed. Approving.

Earlier finding Status Evidence
🔴 vcs/mercurial.py:37 used -r= Fixed Now --rev={rev}. hg update --rev=0 works and --rev=--config=… is refused. test_rev_options_to_args[Mercurial] and test_mercurial_rev_not_interpreted_as_option fail on the old code.
🔴 missing pypa#14127 Fixed Ported into untar_file, the only path on 3.6, with both upstream tests.
🟡 // broken by commonpath Fixed is_within_directory("//srv/env/bin", "//srv/env/bin/pip") was False and is now True. The test fails on the old code.
🟡 root scripts dir Fixed test_script_within_dir_filesystem_root fails on the old code.
🟡 valid ./-prefixed archives and links to implied dirs rejected Fixed They now extract. Two new tests fail on the old code.
  • Tar escape (pypa#14127): with tarfile.data_filter removed, both shapes (outside_link -> .. + outside_link/outside.txt + ..; and ., redir -> ., redir/up -> .., redir/up/outside.txt) wrote outside.txt outside the extract dir on the old head. They now raise InstallationError and nothing is written. Five more attack shapes (chained, absolute, leading-dir, nested, deep-to-shallow symlinks) are rejected. Valid archives that were wrongly rejected before (./-prefixed, a link to an implied directory, a destination path containing a symlink) now extract.

Also fixed: the version check runs inside pip's error interception, and the raw ValueError is now UnsafeFilenameError.

Tests (Python 3.6): full tests/unit gives 1512 passed on the new head, against 1491 on the old one. The same 4 failures and 3 errors occur on both, all from the container.

⚪ Nit (all five lines): _tar_member_paths never adds "." (the while name not in ("", ".", "/") loop in utils/unpacking.py), so is_symlink_target_in_tar rejects a symlink that resolves to the archive root even when the archive has an explicit . member. For example, a tar -C dir . archive containing ./sub/root -> .. used to extract and now fails with InstallationError. Upstream also rejects that link whenever . is not an explicit member. There is no security impact, since this direction only adds rejections.

⚠️ Not assessed: Windows, macOS, and hg+…@rev end to end through pip.

icanhasmath added a commit that referenced this pull request Oct 22, 2026
…26.10.19 (#7)

* Backport CVE-2026-3219: disambiguate tar/zip archive detection in unpack_file

GHSA-58qw-9mgm-455v. unpack_file() treated any file that zipfile.is_zipfile()
accepted as a zip, regardless of its name or whether it was also a valid
tar. A tar+zip polyglot could therefore install different contents from
what its .tar.gz name suggests. Choose the format from the content type,
then the file extension, then the magic signature, and refuse to unpack a
file whose signature matches both formats.

Upstream: pypa#13870 (c4cdbf9), released in 26.1. Source change
cherry-picked unchanged; the test import block was resolved against
26.0.1, which lacks the _get_default_mode_plus_executable import the
upstream test file had picked up in the meantime (unused by these tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-8643: reject entry point names that escape the scripts dir

GHSA-wf93-45jw-7689. pip joined console_scripts / gui_scripts entry point
names onto the scripts directory without checking that the result stays
inside it. The vendored distlib 0.4.0 accepts any non-whitespace name
(ENTRY_RE name group [^\[]\S*) and its ScriptMaker has no directory guard,
so a wheel declaring e.g. '../../outside = pkg:main' or an absolute path
wrote a script outside the scripts directory on 26.0.1. Add
_script_within_dir() and have _raise_for_invalid_entrypoint() (called from
PipScriptMaker.make with self.target_dir) raise InstallationError for any
name that resolves outside the scripts dir.

Upstream: pypa#14000 (8eb1784), released in 26.1.2. Cherry-picked
unchanged, including tests and news fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-13346: stop Link.filename decoding the URL path twice

GHSA-qwm4-qh6w-59xr. Link already percent-decodes the URL path into
self._path, but Link.filename unquoted the basename again, so a
doubly-encoded separator from a malicious index (..%252F..%252Fevil.whl)
became a real '../../evil.whl' and the download was written outside the
download directory. This matters most for `pip download --only-binary`.

Drop the second decode, make Link.filename always return a single path
component (PathComponent), validate the HTTP response file name with
as_path_component(), and route the download-path joins in Downloader,
_check_download_dir and RequirementPreparer.save_linked_requirement through
join_within_directory().

Upstream: pypa#14110 (10dfb6b), released in 26.2.0. Cherry-picked;
only the import blocks in download.py and prepare.py conflicted and were
resolved against 26.0.1's imports (26.0.1 still imports PyPI in
download.py and does not need DirectUrl in prepare.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-6357: run the pip self-version check before installing

GHSA-jp4c-xjxw-mgf9. The self-version check ran after the command body in
a finally block, and its imports are deliberately deferred
(cli/index_command.py lazily imports pip._internal.self_outdated_check and
its dependencies). After `pip install` had just written new wheels into
site-packages, those deferred imports could resolve to modules from the
freshly installed packages, e.g. a top-level _manylinux.py, which
packaging's tag logic imports while the check builds its PackageFinder.
Split the check into a fetch phase that runs before the command body (all
network and metadata work, and every import) and an emit phase that only
logs the captured UpgradePrompt afterwards. Command.handle_pip_version_check()
becomes the pip_version_check() context manager; InstallCommand skips the
check when pip itself is a requirement.

Upstream: pypa#13923 (b369bfc), released in 26.1. Cherry-picked;
only the index_command.py import block conflicted (26.0.1 also imports
sys there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Upgrade vendored certifi to 2026.10.19

GHSA-6q48-6c7w-9gw2 (critical, no CVE): certifi releases from 2019.03.09
up to 2026.10.19 bundle the emSign ECC Root CA - G3, whose intermediate CA
keys were compromised. The vendored certifi 2026.1.4 is affected.

Vendor certifi 2026.10.19, the release vendored by upstream pip 26.2.2.
LICENSE, __init__.py, __main__.py, cacert.pem, core.py and py.typed are
byte-identical to pip 26.2.2's vendored copy (pip 26.2.2 also carries a
certifi/tests directory that 26.0.1 never vendored; it is not added).
core.py and tools/vendoring/patches/certifi.patch are unchanged from
26.0.1, so the patch still applies. The bundle drops emSign ECC Root CA -
G3 and the other roots Mozilla removed since 2026.1.4 (136 -> 120
certificates).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Release 26.0.1.post1: backport four pip CVEs and vendored certifi 2026.10.19

Bump version 26.0.1 -> 26.0.1.post1 (PEP 440 post-release) and add a NEWS
section covering CVE-2026-3219, CVE-2026-6357, CVE-2026-8643,
CVE-2026-13346 and the vendored certifi upgrade (GHSA-6q48-6c7w-9gw2,
emSign). CVE-2026-1703 and CVE-2025-8869 are noted as already fixed in
26.0.1.

The upstream news/ fragments brought in by the cherry-picks are removed:
their content is folded into the NEWS section above, and leaving them
would make a towncrier run list the same changes again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: harden the CVE-2025-8869 fix (port pypa#14127)

Reviewer finding (#7, blocker): 26.0.1 has upstream's 25.3 fix
for CVE-2025-8869 (GHSA-4xh5-x5gv-qwph) but not the later hardening from
pypa#14127 (17eb176, released in 26.2). On the tar fallback path used
when tarfile.data_filter is missing (Python 3.9.0-3.9.16), symlinks to "." or
".." are archive members, so is_symlink_target_in_tar accepts them, and a later
member written through them lands outside the extract directory:

- outside_link -> .., outside_link/outside.txt, then a ".." dir member: the
  file is written before the InstallationError is raised;
- ., redir -> ., redir/up -> .., redir/up/outside.txt: no error at all.

Port 17eb176: is_within_directory gains a keyword-only resolve_symlinks
flag (realpath instead of abspath), each member is also checked after
resolving symlinks, and a symlink whose target resolves outside the
destination is rejected before it is created. Upstream's two regression
tests are included. The only conflict was the is_within_directory docstring;
the commonpath body is replaced separately in the next review commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: root-safe path containment for is_within_directory and _script_within_dir

Reviewer findings (#7, should fix; same on #4/#5/#6):

- is_within_directory used os.path.commonpath, which drops the POSIX "//"
  prefix, so `pip install --prefix //tmp/x <wheel>` failed with "trying to
  install outside the target directory" (already broken on the 26.0.1 base).
  Upstream fixed it in 7ff8bdd (pypa#14001, 26.1.2) with
  startswith(abs_dir + os.sep), but that form rejects every target when the
  directory is a filesystem root ("/" or "C:\\").
- _script_within_dir used dest.startswith(root + os.sep), so a scripts
  directory of "/" or "C:\\" rejected every console/gui script.

Both now use one prefix rule: a target is within a directory when it equals
it or starts with the directory plus a separator, where a directory that
already ends with a separator (a filesystem root) is used as-is. No
commonpath, so a target on another Windows drive is simply outside instead of
raising ValueError. _script_within_dir delegates to is_within_directory and
still refuses a name that resolves to the scripts directory itself.

Tests cover "//" roots, "/" as the directory and as the scripts directory,
and the other-drive case on Windows; the existing CVE-2026-1703 prefix-sibling
and CVE-2026-8643 cases still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: accept normalized member names in is_symlink_target_in_tar

Reviewer finding (#4, should fix; the same code is on every
AS pip line): on the tar fallback path (no tarfile.data_filter), the symlink
check looked the link target up with tar.getmember(), which needs the exact
stored member name. Two valid archive shapes were therefore refused:

- "./"-prefixed archives, as produced by `tar -C dir .`, e.g.
  ./pkg/link -> target.txt (stored as "./pkg/target.txt");
- a symlink to a directory that has no directory entry of its own and only
  exists as the parent of other members.

Upstream 26.2.2 behaves the same; this is a deliberate improvement. The link
target is now compared against the set of normalized member paths plus every
implied parent directory. That set is computed once per archive and passed
in (is_symlink_target_in_tar gains a keyword-only member_paths argument), so
extraction stays linear. Containment is still enforced by the realpath checks
from the previous review commit (pypa#14127): a link to "." or ".." or to
an absolute path is rejected there, and a link to a path that is not in the
archive is still refused here.

The parent walk stops at any fixed point: posixpath.dirname("//") is "//",
so a "//"-prefixed member name would otherwise loop forever.

Tests: both valid shapes unpack, a link to a non-member is refused, and
collecting member paths terminates for a "//" member name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: report an unsafe download file name as a pip error, not a traceback

Reviewer nit (#7; the same on #3-#6): when a download's file
name is not a single safe path component, e.g. `Content-Disposition:
filename=".."` with a content type that adds no extension, as_path_component
raised a bare ValueError and pip printed a traceback (exit 2). The write was
already blocked; only the reporting was poor. Upstream behaves the same.

Add UnsafeFilenameError(InstallationError, ValueError) to pip's exceptions
and raise it instead. It is still a ValueError, so existing callers and tests
keep working, and pip's command loop reports InstallationError as a plain
line. With a local server returning filename="..":
  before: Traceback ... ValueError: Unexpected file name derived from URL: '..'
  after:  ERROR: Unexpected file name derived from URL: '..' (exit 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: let _arg_refers_to_pip tolerate a bare ValueError

Reviewer finding (#3, should fix; applied to every AS pip line
for consistency): the CVE-2026-6357 backport calls _arg_refers_to_pip() before
the command body to decide whether `pip install pip` should skip the version
check. It only caught InvalidRequirement, but packaging can surface a bare
ValueError (urlsplit's "Invalid IPv6 URL" for `foo @ http://[bad/x`), which
would crash the pre-command check instead of letting pip's normal requirement
parsing report the error.

On this line the version check already runs inside _inner_run (pip's error
handling), and 26.0.1's vendored packaging parses that input without raising,
so this is defensive: catch (InvalidRequirement, ValueError) and return False.
Note that `pip install --no-index "foo @ http://[bad/x"` still ends in a
ValueError traceback raised later from Link.__init__; that is unchanged
upstream 26.0.1 behaviour and outside this backport.

Tests: the ValueError case (fails before this change) plus basic name matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: NEWS — render notes, record review fixes

Reviewer nits (#7): the 26.0.1.post1 section's `.. note`
lacked `::`, so reStructuredText treated it as a comment and it never
rendered; and it claimed CVE-2025-8869 was "already fixed" in 26.0.1 although
26.0.1 lacked the pypa#14127 hardening.

- Make the note a real `.. note::` admonition (upstream's header comment on
  line 1 is intentionally left as a comment). Checked with docutils: the
  note now renders.
- CVE-2025-8869: say it was fixed in 26.0.1 and is hardened here, and add a
  Security Fixes entry for the pypa#14127 port.
- Add a Bug Fixes subsection for the other review fixes: root-safe
  is_within_directory / _script_within_dir, normalized symlink member names,
  the clean error for unsafe download file names, and the
  _arg_refers_to_pip ValueError tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
* Backport CVE-2026-6357: run the self-version check before the command body

GHSA-jp4c-xjxw-mgf9. pip ran its self-version check in the `finally` of
Command._main, so after `pip install` had already written packages into
site-packages. The check builds a PackageFinder and computes the supported
tags, and the vendored packaging 20.8 tags._is_manylinux_compatible probes
`import _manylinux` (the PEP 513/600 override hook) every time. A failed
import is never cached in sys.modules, so the probe is retried after the
install and imports a top-level `_manylinux.py` from a just-installed wheel.
Reproduced on released 20.3.4.post2 under Python 2.7 with a wheel containing
only `_manylinux.py`: pip executed it from the post-install check.
`--disable-pip-version-check` avoided it.

The June 20.3.4+security.1 assessment called this not applicable on the
grounds that the check only touches already-imported modules. It missed this
retried hook import.

Split the check into a fetch half, run before the command body, and an emit
half, which only logs afterwards:

- self_outdated_check: new _pip_self_version_check_fetch(session, options)
  does the index lookup, state file and "installed by pip" decision and
  returns (installed, latest) or None. _pip_self_version_check_emit(prompt)
  only logs. pip_self_version_check(session, options) keeps its signature
  and behaviour as fetch followed by emit.
- Command gains internal hooks _pip_version_check_fetch(options, args) and
  _pip_version_check_emit(options, fetched). _main() calls fetch before
  run() and emit in the existing `finally`. The default emit still calls
  handle_pip_version_check(options), so non-index commands behave as before.
- IndexGroupCommand fetches before the body (with the same disable /
  --no-index short-circuit and error handling) and only emits afterwards.
  It does not fall back to handle_pip_version_check(), which stays public
  with an unchanged signature but is no longer called from _main for index
  commands.
- InstallCommand skips the check when pip itself is a requirement, as
  upstream does.

Upstream: pypa#13923 (b369bfc), released in 26.1. Reference ports:
the AS 24.0 (bedea55) and 25.0.1 (57fcc05) lines.

Py2.7 adaptation: no contextlib.ExitStack, so instead of upstream's
pip_version_check() context manager the split is two internal hook methods
around the existing try/finally. Public signatures
(handle_pip_version_check, pip_self_version_check, was_installed_by_pip) are
unchanged. Type comments, str.format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Release 20.3.4.post3: backport CVE-2026-6357

Bump version 20.3.4.post2 -> 20.3.4.post3 and add a NEWS section for
CVE-2026-6357 (GHSA-jp4c-xjxw-mgf9). The section states that the
20.3.4+security.1 not-applicable assessment was wrong and why. The old note
gets a one-line pointer to the correction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: harden CVE-2025-8869 fix against symlink traversal (pypa#14127)

Review finding on #4/#6/#7, which applies equally to this
line: the CVE-2025-8869 backport only checked that a symlink's target is
an archive member, and "." and ".." are members. A crafted sdist could
therefore write outside the extract directory through a symlink:

- outside_link -> .., then outside_link/outside.txt (the error only came
  at a later ".." member, after the write);
- ., redir -> ., redir/up -> .., redir/up/outside.txt (no error at all).

Both escapes reproduce on every released 20.3.4.x. Python 2.7 has no
tarfile.data_filter, so untar_file's member loop is the only guard.

Port upstream pypa#14127 (17eb176, released in 26.2):
- is_within_directory() gains resolve_symlinks=False (positional default,
  so existing two-argument callers are unaffected); when true it compares
  realpaths.
- Each member is rejected unless both the abspath and the realpath of its
  destination stay inside the extract directory.
- A symlink is rejected when its target, resolved, leaves the extract
  directory, before is_symlink_target_in_tar() is consulted.

The new symlink check reuses this line's existing "has a link ... trying
to point outside target directory" message. Upstream's two tests are
ported (no data_filter monkeypatch is needed on Python 2.7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: root-safe path containment for is_within_directory and _script_within_dir

Review finding (#3 nit carried over from #2, and #4-#7):
both checks appended os.sep to the directory before a prefix test, so a
directory that is a filesystem root ("/" or "C:\\") became "//" and
rejected every target. With a scripts directory of "/", every entry
point failed to install.

- is_within_directory(): uses the directory itself as the prefix when it
  already ends with a separator. It still never uses os.path.commonpath
  (absent on Python 2.7, and it drops a POSIX "//" prefix), so the
  CVE-2026-1703 component boundary and "//" roots keep working.
- _script_within_dir(): now delegates to is_within_directory() and
  additionally refuses a script that resolves to the directory itself.

Tests cover "/" and "//" roots, the realpath mode added for
CVE-2025-8869, and the Windows drive-root case (skipped off Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: accept normalized member names in is_symlink_target_in_tar

Review finding (#4, same code on this line): the
CVE-2025-8869 symlink check required the link target to match a member
name exactly, so two valid archive shapes were refused that the pristine
20.3.4 unpacked:

- "./"-prefixed archives (as produced by `tar -C dir .`), e.g. a
  ./pkg/link -> target.txt member;
- a symlink to a directory that has no directory entry of its own.

On Python 2.7 untar_file's member loop is the only extraction path, so
this affected every such sdist. Upstream 26.2.2 behaves the same; this is
a deliberate improvement. Containment is enforced by the realpath checks
added in the previous commit, not by this lookup.

- New _tar_member_paths(): posix-normalized member names plus every
  implied parent directory.
- is_symlink_target_in_tar() compares the normalized link target against
  that set. It takes an optional member_paths so untar_file() computes the
  set once instead of once per symlink.

Tests cover both shapes and a link to a non-member (still refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: pass the Mercurial revision as --rev=<rev> (pypa#12373)

Review finding (#4, same code on this line): the
CVE-2023-5752 backport in 20.3.4+security.1 passes the revision as
"-r=<rev>". hg parses that as the revision "=<rev>" and fails, so every
`pip install hg+...@<rev>` has been broken since 20.3.4+security.1
(also in 20.3.4.post2):

    hg update -q -r=0
    hg: parse error at 0: not a prefix: =

Upstream hit the same regression with its first CVE-2023-5752 fix
(pypa#12306) and replaced it with the long form in pypa#12373
(6dbd9c6, pip 23.3.1). Port that: get_base_rev_args() returns
["--rev=<rev>"]. The revision is still a single token, so a malicious
revision such as "--config=..." is still read by hg as a revision value
(verified: `hg update --rev=--config=x` is a revision parse error, not an
option), and the CVE protection holds.

Verified end to end in python:2.7-slim with Mercurial 4.8.2:
`pip install "hg+file:///repo@0#egg=hgdemo"` fails on the previous head
with the parse error above and installs revision 0 with this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: run the version-check fetch inside pip's error handling

Review finding (#3, should-fix): the CVE-2026-6357 port
called _pip_version_check_fetch() just before _main()'s try:, so it
bypassed pip's error handling:

- `pip install "foo @ http://[bad/x"` escaped as a raw interpreter
  traceback with exit code 1, because _arg_refers_to_pip() only caught
  InvalidRequirement and urlparse raises ValueError("Invalid IPv6 URL");
  post2 reported it through pip's handler ("ERROR: Exception:", exit 2);
- a Ctrl-C during the (up to 5s) fetch escaped as a raw KeyboardInterrupt
  instead of "Operation cancelled by user".

- The fetch is now the first statement inside the try (version_check is
  initialised to None before it), matching upstream, where the check is
  inside _inner_run(). The emit step stays in the finally.
- _arg_refers_to_pip() also treats ValueError as "not pip", so a
  malformed requirement falls through to pip's normal parsing path.

`pip install "foo @ http://[bad/x"` is back to post2's behaviour
("ERROR: Exception:", exit 2). New tests cover KeyboardInterrupt and
ValueError raised by the fetch (on the previous head the KeyboardInterrupt
escapes and interrupts the test run itself) and the malformed requirement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: report unsafe download file names as a pip error

Review nit (#3, carried over from #2, and #4-#7): a
download whose derived file name is not a single safe component (e.g.
`Content-Disposition: filename=".."`) was refused, but as a bare
ValueError, so pip printed "ERROR: Exception:" plus a traceback and
exited 2.

Add pip._internal.exceptions.UnsafeFilenameError(InstallationError,
ValueError) and raise it from as_path_component() and
join_within_directory() with the same messages. It remains a ValueError,
so existing callers and tests are unaffected. As an InstallationError,
pip's command loop reports it as a single line:

    ERROR: Unexpected file name derived from URL: '..'

(exit 1). Verified end to end against a local server sending
filename="..". The write was already blocked before; only the reporting
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: NEWS — render notes, record review fixes

Review nit (#3, #7): ".. note" without "::" is a
reStructuredText comment, so the notes did not render, including the
post3 correction that says 20.3.4+security.1 and .post2 are affected by
CVE-2026-6357. Make all three AS notes (post3, post2, +security.1) real
".. note::" directives; the upstream header comment on line 1 is left
as is. Markup only, no wording changes to the older sections.

Record the review fixes in the 20.3.4.post3 section:
- Security: CVE-2025-8869 hardening (pypa#14127) and the CVE-2023-5752
  Mercurial --rev= regression fix (affects +security.1 and .post2).
- Bug fixes: filesystem-root containment, version-check fetch inside
  error handling, normalized symlink member names, and unsafe download
  file names reported as a pip error.

Checked with docutils: 3 notes render, no warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: stop _tar_member_paths at a fixed point

posixpath.normpath keeps a leading "//" and posixpath.dirname("//") is
"//", so collecting the implied parent directories of a member named
"//x/y" never terminated: a crafted archive could hang pip in the tar
fallback path. Stop walking parents once dirname() stops changing the
name, and add a regression test.

The bug was in the shared review-round helper (item G) and was caught on
the 26.0.1 line; this applies the same guard here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
_tar_member_paths never recorded ".", so is_symlink_target_in_tar refused a
symlink that resolves to the archive root even when the archive has an
explicit "." member. A "tar -C dir ." archive containing
"./sub/root -> .." extracted before this review round (tar.getmember(".")
found the member) and on upstream 26.2.2, but failed with InstallationError
after the member-name normalization change.

Record "." only when a member normalizes to it, never implicitly, which
matches the previous behaviour and upstream: without an explicit "." member
the link is still refused. No security impact: this only removes a false
rejection, and the realpath containment checks are unchanged (a link to the
root resolves to the extract directory itself, which is inside it).

Adds tests for the explicit-root (accepted) and implicit-root (refused)
cases.

Also restore the second blank line before
test_tar_member_paths_terminates_on_doubled_slash (flake8 E302), which the
previous review commit left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@icanhasmath

Copy link
Copy Markdown
Author

Nit addressed in ac2081714: _tar_member_paths now records "." when a member normalizes to it, so a tar -C dir . archive with ./sub/root -> .. extracts again, as it did before the review round and does on upstream 26.2.2. Without an explicit . member, the link is still refused, matching upstream. There is no security impact: the realpath containment checks are unchanged.

🤖 Generated with Claude Code

@ezequielp-activestate ezequielp-activestate left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of ac20817: 0 blockers. Approving.

The new commit fixes the nit from my last review. An explicit . / ./ member is now recorded as the archive root in _tar_member_paths (src/pip/_internal/utils/unpacking.py), so a symlink that resolves to it is accepted. The root is still never added implicitly. The source change is the same 6 lines on all five lines (pip PRs 4, 5, 6, 8 and 9).

Allowing . doesn't reopen the tar escape. I ran the exploit and legit-archive probe on this commit under Python 3.6, with tarfile.data_filter removed:

  • All 7 escape shapes are still refused and nothing is written outside. That includes Case B (., redir -> ., redir/up -> .., redir/up/outside.txt), which the realpath check now stops at redir/up.
  • Three extra variants that combine an explicit . member with symlink chains (root -> . + root/up -> ..; sub/r -> .. + sub/r/../outside.txt; a -> . + a/a/a/b -> ..) are refused, with nothing written outside.
  • Legit archives L1–L4 still extract, and the new case (., ./sub, ./sub/root -> ..) now extracts.

Tests: tests/unit/test_utils_unpacking.py gives 65 passed, 2 skipped on Python 3.6. test_symlink_to_explicit_archive_root_is_in_tar fails on the previous code (assert '.' in {'sub', 'sub/root'}), checked on the 25.0.1 and 26.0.1 lines.

⚠️ Not assessed: Windows, and the full tests/unit suite (this commit only touches unpacking.py, whose tests were run).

icanhasmath added a commit that referenced this pull request Oct 22, 2026
…6.10.19 (#6)

* Backport CVE-2025-8869: validate tar symlink targets on the extraction fallback

GHSA-4xh5-x5gv-qwph. When tarfile.data_filter (PEP 706) is unavailable,
pip extracts sdists through _untar_without_filter(), which extracted
symlink members without checking where they point. A malicious sdist could
ship a symlink whose target lies outside the unpack directory, and later
members written through it would land outside the target directory.

Add is_symlink_target_in_tar() and reject any symlink member whose target
is not another member of the same archive with InstallationError. The
data_filter path (used on Python builds with PEP 706, e.g. 3.8.17+) is
unchanged; data_filter already rejects such links there.

Upstream: pypa#13550 (f2b9231), released in 25.3. Applied cleanly;
the only change is removing a zero-width space and a non-ASCII ">=" from
upstream's comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-1703: enforce component boundary in is_within_directory

GHSA-6vgw-5pg2-w6jp. is_within_directory() compared paths with
os.path.commonprefix(), a character-by-character comparison, so
/install/parent/childfoo was considered inside /install/parent/child. A
crafted archive could use this to write into a sibling directory whose
name starts with the install path.

Use os.path.commonpath(), which compares whole path components.

Upstream: pypa#13777 (8e227a9), released in 26.0. Applied cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-3219: disambiguate tar/zip archive detection in unpack_file

GHSA-58qw-9mgm-455v. unpack_file() checked zipfile.is_zipfile() before
tarfile.is_tarfile(), so a file that is both a valid tar and a valid zip
(a polyglot) was always unpacked as a zip regardless of what other tools,
or a reviewer, would see when treating it as a tar.

Choose the format by decreasing reliability: Content-Type, then file
extension, then magic signature, and only when exactly one signature
matches. Ambiguous archives are rejected with InstallationError.

Upstream: pypa#13870 (c4cdbf9), released in 26.1. Source applied
cleanly; only the test-file imports needed merging with 25.0.1's
typing.List/Tuple import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-6357: run the pip self-version check before the command

GHSA-jp4c-xjxw-mgf9. The self-version check ran in a `finally` after the
command body, i.e. after `pip install` had placed new files on disk. The
check (and the metadata / network code it reaches) imports modules lazily,
so a freshly installed wheel that ships a module at one of those import
paths would get its code run inside the same pip invocation.

Split the check into pip_self_version_check_fetch(), which runs before the
command body and does all imports and network access, and
pip_self_version_check_emit(), which only logs the captured prompt
afterwards. Command.handle_pip_version_check() becomes the context manager
Command.pip_version_check(options, args). `pip install` also skips the
check entirely when pip itself is one of the requirements.

Applicable to 25.0.1 (unlike 20.3.4, where the check's dependencies were
all imported eagerly at startup). The existing eager import of
self_outdated_check in commands/install.py is kept.

Upstream: pypa#13923 (b369bfc), released in 26.1.

Py3.8 adaptation: these 25.0.1 modules have no `from __future__ import
annotations`, so typing.Iterator / List / Optional are used instead of
collections.abc.Iterator / list[str] / X | None. Tests ported from
upstream; only the test_self_check_outdated imports needed merging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-8643: reject entry point names that escape the scripts dir

GHSA-wf93-45jw-7689. pip joined console_scripts / gui_scripts entry point
names onto the scripts directory without checking that the result stays
inside it. The vendored distlib 0.3.9 accepts any non-whitespace name
(ENTRY_RE `[^\[]\S*`) and does not check the output path itself, so a wheel
declaring e.g. `../../outside = pkg:main` installed a script outside the
scripts directory. This line is fully affected.

Add _script_within_dir() and have _raise_for_invalid_entrypoint() (called
from PipScriptMaker.make with self.target_dir) raise InstallationError for
any name that resolves outside the scripts dir.

Upstream: pypa#14000 (8eb1784), released in 26.1.2. One conflict,
in PipScriptMaker.make's signature (typing.Optional/Dict/List kept for
3.8); tests applied unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-13346: stop Link.filename decoding the URL path twice

GHSA-qwm4-qh6w-59xr. Link already percent-decodes the URL path into
self._path, but Link.filename unquoted the basename a second time, so a
doubly-encoded separator from a malicious index (..%252F..%252Fevil.whl)
became a real "../../evil.whl" and the download was written outside the
download directory. This matters most for `pip download --only-binary`.

- Link.filename no longer decodes twice and always returns a single path
  component (no separator, never "." or ".."), falling back to the netloc.
- New PathComponent type plus _to_path_component / as_path_component /
  join_within_directory helpers in pip._internal.models.link. The HTTP
  response file name (including the Content-Disposition value) is validated
  with as_path_component.
- All four download-path joins go through join_within_directory:
  Downloader, BatchDownloader, _check_download_dir and
  RequirementPreparer.save_linked_requirement.

Upstream: pypa#14110 (10dfb6b), released in 26.2.0.

Adaptation: 25.0.1 still has the separate Downloader/BatchDownloader
classes (upstream merged them and added resumable downloads), so the joins
were ported by hand; the helpers and test_link.py additions match upstream.
Added download-level tests for the doubly-encoded URL and a
Content-Disposition "..".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Upgrade vendored certifi to 2026.10.19

GHSA-6q48-6c7w-9gw2 (critical, no CVE assigned): certifi releases from
2019.03.09 up to 2026.10.18 bundle the emSign ECC Root CA - G3, whose
intermediate CA keys were compromised. Mozilla removed it, and certifi
2026.10.19 is the first release without it (the other emSign roots,
unaffected, are kept).

Replace the vendored certifi 2024.8.30 with 2026.10.19, the release vendored
by upstream pip 26.2.2. LICENSE, __init__.py, __main__.py, cacert.pem,
core.py and py.typed are byte-identical to pip 26.2.2's vendored copy (its
tests/ directory is not vendored, matching 25.0.1's file set), and
tools/vendoring/patches/certifi.patch is taken from 26.2.2 as well. The new
core.py only drops certifi's pre-3.7 fallback, which is unused on 3.8+.

The earlier certifi root-removal CVEs (CVE-2022-23491, CVE-2023-37920,
CVE-2024-39689) were already fixed by the 2024.8.30 bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Release 25.0.1.post1: backport six pip CVEs; vendored certifi 2026.10.19

Bump version 25.0.1 -> 25.0.1.post1 (PEP 440 post-release) and add a NEWS
section covering CVE-2025-8869, CVE-2026-1703, CVE-2026-3219,
CVE-2026-6357, CVE-2026-8643, CVE-2026-13346 and the vendored certifi
upgrade (GHSA-6q48-6c7w-9gw2, emSign). The towncrier fragments brought in
by the upstream cherry-picks are folded into that section and removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: harden CVE-2025-8869 fix against symlink chains (pypa#14127)

Review finding on #6 (blocker): the tar fallback used when
tarfile.data_filter is missing (Python 3.8.0-3.8.16) still let a member be
written outside the extract directory through an earlier symlink:
`outside_link -> ..` then `outside_link/outside.txt`, or the chain
`redir -> .`, `redir/up -> ..`, `redir/up/outside.txt`.
is_symlink_target_in_tar only asks whether the link target is an archive
member, and "." / ".." are members.

Port upstream pypa#14127 (17eb176, released in 26.2):
- is_within_directory() gains keyword-only resolve_symlinks (realpath).
- Each member must be within the destination both textually and after
  resolving symlinks.
- A symlink whose target resolves outside the destination is rejected
  before anything can be written through it.
Upstream's two regression tests are included; both write outside.txt on
the previous head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: root-safe path containment for is_within_directory and _script_within_dir

Review findings on #6 (should fix):
- is_within_directory() used os.path.commonpath, which drops the POSIX "//"
  prefix, so `pip install --prefix //tmp/pfx <wheel>` failed with "trying to
  install outside the target directory" (works on 25.0.1). Upstream fixed
  this in 7ff8bdd (26.1.2) with startswith(abs_dir + os.sep), but that
  form rejects every target when the directory is a filesystem root.
- _script_within_dir() used dest.startswith(root + os.sep), so a scripts
  directory of "/" (or "C:\") rejected every entry point.

One rule now serves both: the target equals the directory, or starts with
the directory plus a separator (no extra separator when the directory is
already a root). _script_within_dir() reuses is_within_directory() and still
rejects the scripts directory itself ("." / ""). No commonpath means a
target on another Windows drive returns False instead of raising ValueError.
The CVE-2026-1703 prefix-sibling case stays rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: accept normalized member names in is_symlink_target_in_tar

Review finding on #4 (should fix; the same code is on this
line): on the tar fallback path, is_symlink_target_in_tar() required the
link target's exact member name, so valid archives were refused:
- "./"-prefixed archives (as made by `tar -C dir .`) with
  `./pkg/link -> target`, and
- a symlink to a directory that has no directory entry of its own.
Upstream 26.2.2 behaves the same; this is a deliberate improvement on the
fallback path, which is the only path on Python 3.8.0-3.8.16.

Compare against the set of normalized member paths plus every implied
parent directory, computed once per archive and passed in, so the check
stays linear. Containment is not weakened: symlinks are still required to
resolve inside the destination (realpath checks from pypa#14127), and a
link to something that isn't in the archive is still refused (new test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: report unsafe download file names as a pip error, not a traceback

Review nit on #6 (and the same on every line): a download
whose name reduces to nothing usable (e.g. `Content-Disposition:
filename=".."`) was blocked, but surfaced as a raw ValueError traceback
from as_path_component() (network/download.py -> models/link.py).

Add UnsafeFilenameError(InstallationError, ValueError) and raise it
instead. It is still a ValueError, so existing callers and tests keep
working, and pip's command loop now reports it as a normal `ERROR:` line.
Upstream raises a bare ValueError here; this only changes how the refusal
is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: treat a malformed requirement as "not pip" in _arg_refers_to_pip

Review finding on #3 (the CVE-2026-6357 port is shared by
every line): _arg_refers_to_pip() only caught InvalidRequirement, so a
ValueError from urllib (e.g. "foo @ http://[bad/x" -> "Invalid IPv6 URL")
could escape from the pre-command version check.

On 25.0.1 the vendored packaging already turns this into
InvalidRequirement and the check already runs inside _inner_run, so there
is no user-visible change here: the malformed URL still produces the same
traceback and exit code 2 as pristine 25.0.1, raised later from Link() in
pip's own requirement parsing. Catch ValueError as well anyway so the
pre-command check can never be the thing that aborts the command, and add
tests (one patches Requirement to raise ValueError).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: NEWS - render notes, record review fixes

- `.. note` without `::` is a reStructuredText comment, so the
  ActiveState release note never rendered (review nit on #7;
  the same markup was used here). Use `.. note::`. The upstream header
  comment on line 1 is intentionally left as a comment.
- Record the review fixes in the 25.0.1.post1 section: the CVE-2025-8869
  hardening (pypa#14127), the root-safe / "//"-safe containment check
  (CVE-2026-1703 wording no longer mentions os.path.commonpath), the
  normalized symlink-target lookup, UnsafeFilenameError, and the
  _arg_refers_to_pip change.

Checked with docutils: no warnings, and the note renders as an admonition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: stop _tar_member_paths at a fixed point

posixpath.normpath keeps a leading "//" and posixpath.dirname("//") is
"//", so collecting the implied parent directories of a member named
"//x/y" never terminated: a crafted archive could hang pip in the tar
fallback path. Stop walking parents once dirname() stops changing the
name, and add a regression test.

The bug was in the shared review-round helper (item G) and was caught on
the 26.0.1 line; this applies the same guard here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: let a symlink resolve to an explicit archive root

_tar_member_paths never recorded ".", so is_symlink_target_in_tar refused a
symlink that resolves to the archive root even when the archive has an
explicit "." member. A "tar -C dir ." archive containing
"./sub/root -> .." extracted before this review round (tar.getmember(".")
found the member) and on upstream 26.2.2, but failed with InstallationError
after the member-name normalization change.

Record "." only when a member normalizes to it, never implicitly, which
matches the previous behaviour and upstream: without an explicit "." member
the link is still refused. No security impact: this only removes a false
rejection, and the realpath containment checks are unchanged (a link to the
root resolves to the extract directory itself, which is inside it).

Adds tests for the explicit-root (accepted) and implicit-root (refused)
cases.

Also restore the second blank line before
test_tar_member_paths_terminates_on_doubled_slash (flake8 E302), which the
previous review commit left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
icanhasmath added a commit that referenced this pull request Oct 22, 2026
…ndored certifi 2026.10.19 (#5)

* Backport CVE-2026-3219: disambiguate tar/zip archive detection in unpack_file

GHSA-58qw-9mgm-455v. unpack_file ran zipfile.is_zipfile() for any archive
whose name or content type pip did not already recognise. is_zipfile scans
for a ZIP end-of-central-directory record anywhere in the file, so a tar+ZIP
polyglot (a ZIP appended to a valid .tar.gz) was always handled as a ZIP,
whatever its file name, letting an attacker pick which set of files pip
actually extracts.

Choose the format by decreasing reliability: content type, then file name
extension, then magic signature, and only trust the magic signature when it
is unambiguous. A file that is both a valid zip and a valid tar with no other
hint is now rejected with InstallationError.

Upstream: pypa#13870 (c4cdbf9), released in 26.1. Applied verbatim
apart from typing.Optional in place of 'str | None'. Tests mirror upstream;
mock call_args.kwargs (3.8+) is replaced by call_args[1] for Python 3.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-6357: run the self-version check before installing

GHSA-jp4c-xjxw-mgf9. pip ran its self-version check in a `finally` after the
command body, so after `pip install` had already written wheels into
site-packages. The check builds a PackageFinder, and the vendored
packaging.tags then probes `import _manylinux` (the PEP 600 override hook).
A just-installed wheel that ships a top-level `_manylinux.py` was therefore
imported and executed by pip itself. Reproduced on pristine 24.0 under
Python 3.7 with a wheel containing only `_manylinux.py`: the module ran
from the post-install check.

Split the check into a fetch phase, which runs before the command body
(remote lookup, state file, "installed by pip" decision), and an emit
phase, which only logs the prompt afterwards:

- Command.handle_pip_version_check(options) becomes the context manager
  Command.pip_version_check(options, args) and wraps the command body in
  Command._main.
- IndexGroupCommand fetches before yielding and emits in `finally`, each
  guarded by the old "error checking the latest version" handling.
- InstallCommand skips the check when pip itself is a requirement.
- self_outdated_check gains pip_self_version_check_fetch / _emit and
  _compute_upgrade_prompt; the installer is read from the dist found
  during fetch instead of re-querying the environment afterwards
  (was_installed_by_pip and _self_version_check_logic are removed, as
  upstream).

Upstream: pypa#13923 (b369bfc), released in 26.1.

Adaptation: 24.0 has no index_command.py or _run_wrapper; the context
manager lives in cli/req_command.py and wraps run() in _main. typing
Iterator/List/Optional for Python 3.7. pip_self_version_check(session,
options) is kept as a fetch+emit wrapper so the old entry point still
works. 24.0's check has no externally-managed short-circuit, so that part
of upstream does not apply. Tests follow upstream, plus one asserting the
fetch -> body -> emit order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-8643: reject entry point names that escape the scripts dir

GHSA-wf93-45jw-7689. pip joined console_scripts / gui_scripts entry point
names onto the scripts directory without checking that the result stays
inside it. The vendored distlib 0.3.8 accepts any non-whitespace name
(ENTRY_RE '[^\[]\S*') and, unlike distlib 0.4.2, does no containment
check of its own, so a wheel with an entry point such as
'../../../../tmp/escaped-script = pkg:main' wrote a script outside the
environment. Reproduced on pristine 24.0 under Python 3.7.

Add _script_within_dir() and have _raise_for_invalid_entrypoint() (called
from PipScriptMaker.make with self.target_dir) raise InstallationError for
any name that resolves outside the scripts dir.

Upstream: pypa#14000 (8eb1784), released in 26.1.2. Applied
verbatim; tests mirror upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Backport CVE-2026-13346: stop Link.filename decoding the URL path twice

GHSA-qwm4-qh6w-59xr. Link.path already percent-decodes the URL path, but
Link.filename unquoted the basename a second time, so a doubly-encoded
separator from a malicious index (..%252F..%252Fevil.whl) became a real
"../../evil.whl" and the download was written outside the download
directory. This matters most for `pip download --only-binary`. On pristine
24.0 such a link yields filename '../../../evil-1.0-py3-none-any.whl'.

- Link.filename no longer decodes twice and always returns a PathComponent,
  a single path component (no separator, never "." or ".."), falling back to
  the netloc.
- New helpers in pip._internal.models.link: PathComponent (NewType),
  _to_path_component, as_path_component and join_within_directory. The HTTP
  response file name, including the Content-Disposition value (where a bare
  ".." previously got through sanitize_content_filename), is validated with
  as_path_component.
- All four download-path joins go through join_within_directory: Downloader,
  BatchDownloader, _check_download_dir and
  RequirementPreparer.save_linked_requirement.

Upstream: pypa#14110 (10dfb6b), released in 26.2.0.

Adaptation: typing.NewType is available on Python 3.7, so upstream's
PathComponent type is kept. Because NewType is not enforced at runtime,
join_within_directory also re-validates its argument and raises ValueError,
matching the ActiveState 20.3.4.post2 backport (stricter than upstream). 24.0
has BatchDownloader in addition to Downloader, and both are covered. Tests
mirror upstream's test_link.py additions, plus download-level tests for the
doubly-encoded URL and Content-Disposition "..".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Upgrade vendored certifi to 2026.10.19

The vendored certifi 2023.7.22 still trusts root certificates that have
since been removed for cause:

- GHSA-6q48-6c7w-9gw2 (critical, no CVE yet): emSign ECC Root CA - G3,
  whose intermediate CA keys were compromised (removed upstream in
  certifi 2026.10.19)
- CVE-2024-39689 (GHSA-248v-346w-9cwc): GLOBALTRUST 2020 roots (affected
  since certifi 2021.5.30, removed upstream in certifi 2024.7.4)

Replace it with certifi 2026.10.19, the release vendored by upstream pip
26.2.2. LICENSE, __init__.py, __main__.py, cacert.pem, core.py and
py.typed are byte-identical to pip 26.2.2's vendored copy (certifi's own
tests/ directory is not vendored, as before). core.py's Python 3.7 branch
(importlib.resources.path / read_text) is unchanged from upstream, so no
port is needed on this line. tools/vendoring/patches/certifi.patch is
pip 26.2.2's and reproduces the vendored core.py from upstream certifi
2026.10.19.

CVE-2022-23491 (TrustCor) and CVE-2023-37920 (E-Tugra) were already fixed
by the 2023.7.22 bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Release 24.0.post2: backport CVE-2026-6357, CVE-2026-8643, CVE-2026-13346, CVE-2026-3219; vendored certifi 2026.10.19

Bump version 24.0 -> 24.0.post2 and add a NEWS section covering the four
pip CVE backports and the vendored certifi upgrade (GHSA-6q48-6c7w-9gw2,
CVE-2024-39689). The NEWS note records CVE-2025-8869 and CVE-2026-1703 as
patched in the earlier 24.0.0.1 build, which kept the internal version 24.0.

.post2 because 24.0.0.1 was ActiveState release 1 on this line; that keeps
the override-table version (24.0.0.2) distinct from 24.0.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: root-safe path containment for is_within_directory and _script_within_dir

Review findings on #5 (and the same findings on #4/#6/#7):

- is_within_directory used os.path.commonpath (from the 24.0.0.1
  CVE-2026-1703 backport), which drops a POSIX "//" prefix, so
  `pip install --prefix //tmp/x <wheel>` failed with "trying to install
  outside the target directory". On Windows commonpath also raises
  ValueError for a target on another drive.
- _script_within_dir used dest.startswith(root + os.sep), so with a
  scripts directory at a filesystem root ("/" or "C:\") every entry
  point was rejected.

is_within_directory now compares on a path-component boundary without
commonpath: target == directory, or target starts with the directory
plus a separator (not doubled when the directory is already a root).
_script_within_dir reuses it and still rejects a name that resolves to
the scripts directory itself ("." / "").

Upstream fixed the "//" case in pypa/pip 7ff8bdd with the
startswith(abs_dir + os.sep) form, which rejects everything under "/";
this form handles both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: harden CVE-2025-8869 fix against symlink redirection (pypa#14127)

Review finding (raised as a blocker on #4/#6/#7; confirmed
on this line too): the CVE-2025-8869 backport shipped in 24.0.0.1 only
checks that a symlink's target is another archive member, and "." / ".."
can be members. Two archive shapes therefore still wrote outside the
extract directory:

- symlink `outside_link -> ..`, then `outside_link/outside.txt`, then a
  `..` directory member (the error came only after the write);
- `.`, `redir -> .`, `redir/up -> ..`, then `redir/up/outside.txt`
  (no error at all).

pip 24.0 has no tarfile.data_filter path, so on this line untar_file's
member loop is the only guard, for every Python version.

Port upstream pypa#14127 (17eb176, released in 26.2):
- is_within_directory gains a keyword-only `resolve_symlinks` option that
  compares realpath()s;
- each member is rejected if its path escapes either textually or after
  resolving symlinks;
- a symlink is rejected if its target resolves outside the destination,
  before anything can be written through it.

Upstream's two regression tests are included and fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: accept normalized member names in is_symlink_target_in_tar

Review finding on #4 (same code on this line): the
CVE-2025-8869 symlink check looked up the link target with
tar.getmember(), which needs the exact member name. Two valid archive
shapes were therefore refused on the tar fallback path:

- "./"-prefixed archives (as produced by `tar -C dir .`), e.g.
  `./pkg/link -> target.txt`;
- a symlink to a directory that has no directory entry of its own.

is_symlink_target_in_tar now compares against the set of normalized
member paths plus every implied parent directory, computed once per
archive (keyword-only `member_paths`) to avoid O(n^2) work. Upstream
26.2.2 still refuses both shapes; this is a deliberate improvement.
Containment is still enforced by the realpath checks from the previous
commit (pypa#14127), and a link to a path that is not in the archive
is still refused. Tests cover all three cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: run the version-check fetch inside pip's error handling

Review finding on #5 (nit) and #3 (should fix): the
CVE-2026-6357 port entered `with self.pip_version_check(...)` outside the
intercepts_unhandled_exc wrapper, so anything raised while fetching (up
to 5s, before the command body) escaped pip's error handling: a Ctrl-C
showed a raw KeyboardInterrupt traceback instead of "Operation cancelled
by user", and `pip install "foo @ http://[bad/x"` died with a bare
ValueError (exit 1) from _arg_refers_to_pip.

- base_command: wrap the `with pip_version_check(...)` block and
  self.run() in an inner function and intercept that, as upstream does
  with _inner_run (debug mode still calls it directly).
- install: _arg_refers_to_pip also returns False on a plain ValueError
  from the vendored packaging/urllib.parse, so a malformed requirement is
  reported by pip's normal requirement handling. On this line that
  restores the 24.0.0.1 behaviour for that input (exit 2, pip's
  "ERROR: Exception:" report).

Tests cover KeyboardInterrupt and ValueError raised from the fetch, and
_arg_refers_to_pip with the malformed URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: report unsafe download file names as a pip error, not a traceback

Review nit on #5 (and #3/#4/#6/#7): when a server's
Content-Disposition (or a URL) yields a download name of ".." or ".",
as_path_component raised a bare ValueError and pip printed a traceback.
The write was already blocked; only the reporting was poor.

Add pip._internal.exceptions.UnsafeFilenameError, a subclass of both
InstallationError and ValueError, and raise it from as_path_component
and join_within_directory. Existing callers and tests that expect
ValueError keep working, and pip's command loop now reports it as
"ERROR: Unexpected file name derived from URL: '..'" (exit 1) instead of
"ERROR: Exception:" plus a traceback (exit 2). Verified end to end
against a local server sending `Content-Disposition: filename=".."`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: NEWS — render notes, record review fixes

- The 24.0.post2 note used `.. note` without `::`, which reStructuredText
  treats as a comment, so it did not render (review nit on #3/#7; same
  here). It is now a real `.. note::` admonition. The upstream header
  comment at the top of the file is left as is.
- Record the review fixes in the 24.0.post2 section: the CVE-2025-8869
  hardening (pypa#14127; the 24.0.0.1 fix was incomplete), and under
  Bug Fixes the "//" prefix regression (from the 24.0.0.1 CVE-2026-1703
  fix), scripts directories at a filesystem root, "./"-prefixed and
  implicit-directory symlinks, error handling around the version-check
  fetch, and the clean error for "." / ".." download names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: stop _tar_member_paths at a fixed point

posixpath.normpath keeps a leading "//" and posixpath.dirname("//") is
"//", so collecting the implied parent directories of a member named
"//x/y" never terminated: a crafted archive could hang pip in the tar
fallback path. Stop walking parents once dirname() stops changing the
name, and add a regression test.

The bug was in the shared review-round helper (item G) and was caught on
the 26.0.1 line; this applies the same guard here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review: let a symlink resolve to an explicit archive root

_tar_member_paths never recorded ".", so is_symlink_target_in_tar refused a
symlink that resolves to the archive root even when the archive has an
explicit "." member. A "tar -C dir ." archive containing
"./sub/root -> .." extracted before this review round (tar.getmember(".")
found the member) and on upstream 26.2.2, but failed with InstallationError
after the member-name normalization change.

Record "." only when a member normalizes to it, never implicitly, which
matches the previous behaviour and upstream: without an explicit "." member
the link is still refused. No security impact: this only removes a false
rejection, and the realpath containment checks are unchanged (a link to the
root resolves to the extract directory itself, which is inside it).

Adds tests for the explicit-root (accepted) and implicit-root (refused)
cases.

Also restore the second blank line before
test_tar_member_paths_terminates_on_doubled_slash (flake8 E302), which the
previous review commit left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@icanhasmath
icanhasmath merged commit b5c78fd into 21.3.1.x Oct 22, 2026
4 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants