pypa/pip · critical · ValueError

path outside destination: %r

Error message

path outside destination: %r

What it means

Raised by the check_path() helper inside unarchive() when an archive member's normalized absolute path does not stay within dest_dir (or a symlink/hardlink target escapes it). This is distlib's defense against the 'Zip Slip' / TarSlip path-traversal attack where a malicious archive contains '..' segments or absolute paths; extraction is aborted with ValueError 'path outside destination'.

Source

Thrown at src/pip/_vendor/distlib/util.py:1237


#
# Unarchiving functionality for zip, tar, tgz, tbz, whl
#

ARCHIVE_EXTENSIONS = ('.tar.gz', '.tar.bz2', '.tar', '.zip', '.tgz', '.tbz', '.whl')


def unarchive(archive_filename, dest_dir, format=None, check=True):

    def check_path(path, base=None):
        if not isinstance(path, text_type):
            path = path.decode('utf-8')
        if base is None:
            base = dest_dir
        p = os.path.abspath(os.path.join(base, path))
        if not p.startswith(dest_dir) or p[plen] != os.sep:
            raise ValueError('path outside destination: %r' % p)

    def check_link(member):
        # A symlink/hardlink member's name is validated like any other
        # member, but its target (linkname) is not covered by extractall's
        # name-based handling. An unchecked target lets a later member be
        # written through the link to a location outside dest_dir. Validate
        # the resolved target stays within dest_dir. Symlink targets are
        # relative to the member's own directory; hardlink targets are
        # relative to the archive root (i.e. dest_dir).
        if not (member.issym() or member.islnk()):
            return
        if member.issym():
            link_base = os.path.dirname(os.path.join(dest_dir, member.name))
        else:
            link_base = dest_dir
        check_path(member.linkname, base=link_base)

    dest_dir = os.path.abspath(dest_dir)

View on GitHub (pinned to d7d0d0a394)

Solutions

  1. Treat the error as a security signal: do NOT disable checking; investigate the archive contents with 'tar -tvf' / 'unzip -l'.
  2. Obtain the archive from a trusted source or rebuild it without path-traversal entries.
  3. If you must extract untrusted archives, extract to a throwaway sandbox directory and keep check=True.

Example fix

// before
unarchive('untrusted.tar.gz', '/opt/app')  # contains ../../etc/cron.d/x
// after
# Do not extract; verify/rebuild archive first:
#   tar -tvf untrusted.tar.gz | grep '\.\.'
# then use a trusted archive with the same call.
Defensive patterns

Strategy: validation

Validate before calling

import os
def safe_unarchive(archive, dest, **kw):
    # pre-scan members without extracting
    import tarfile, zipfile
    dest = os.path.abspath(dest)
    if archive.endswith(('.tar.gz','.tgz','.tar.bz2','.tbz','.tar')):
        with tarfile.open(archive) as tf:
            for m in tf.getmembers():
                if not os.path.abspath(os.path.join(dest, m.name)).startswith(dest + os.sep):
                    raise ValueError('unsafe member: %r' % m.name)
    elif archive.endswith('.zip'):
        with zipfile.ZipFile(archive) as zf:
            for n in zf.namelist():
                if not os.path.abspath(os.path.join(dest, n)).startswith(dest + os.sep):
                    raise ValueError('unsafe member: %r' % n)
    from distlib.util import unarchive
    return unarchive(archive, dest, **kw)

Try / catch

from distlib.util import unarchive
try:
    unarchive(path, dest)  # check=True default
except ValueError as e:
    if 'path outside destination' in str(e):
        quarantine(path)  # treat as malicious; do NOT pass check=False
    else:
        raise

Prevention

When it happens

Trigger: unarchive() on a tar/zip containing an entry like '../../../etc/passwd' or '/etc/cron.d/x', or a member whose symlink linkname resolves outside dest_dir. Triggered only when check=True (the default).

Common situations: Extracting untrusted or third-party sdists/wheels, corrupted archives, or archives produced by tools that emit absolute member names.

Related errors


AI-assisted analysis of pypa/pip@d7d0d0a394 (2026-08-04). Data as JSON: /data/errors/f6157a76f0be4c56.json. Report an issue: GitHub.