CVE-2021-3281 (Python)

In Django 2.2 before 2.2.18, 3.0 before 3.0.12, and 3.1 before 3.1.6, the django.utils.archive.extract method (used by "startapp --template" and "startproject --template") allows directory traversal via an archive with absolute paths or relative paths with dot segments.

Weakness:
CWE-73: name: External Control of File Name or Path
description: The product allows user input to control or influence paths or file names that are used in filesystem operations.
CWE-22: name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.

Repository: https://github.com/django/django

Vulnerable code:
file_path: django/utils/archive.py
snippet:     def extract(self, to_path):
        members = self._archive.getmembers()
        leading = self.has_leading_dir(x.name for x in members)
        for member in members:
            name = member.name
            if leading:
                name = self.split_leading_dir(name)[1]
            filename = os.path.join(to_path, name)
            if member.isdir():
                if filename:
                    os.makedirs(filename, exist_ok=True)
            else:
                try:
                    extracted = self._archive.extractfile(member)
                except (KeyError, AttributeError) as exc:
                    # Some corrupt tar files seem to produce this
                    # (specifically bad symlinks)
                    print("In the tar file %s the member %s is invalid: %s" %
                          (name, member.name, exc))
                else:
                    dirname = os.path.dirname(filename)
                    if dirname:
                        os.makedirs(dirname, exist_ok=True)
                    with open(filename, 'wb') as outfile:
                        shutil.copyfileobj(extracted, outfile)
                        self._copy_permissions(member.mode, filename)
                finally:
                    if extracted:
                        extracted.close()

file_path: django/utils/archive.py
snippet:     def extract(self, to_path):
        namelist = self._archive.namelist()
        leading = self.has_leading_dir(namelist)
        for name in namelist:
            data = self._archive.read(name)
            info = self._archive.getinfo(name)
            if leading:
                name = self.split_leading_dir(name)[1]
            filename = os.path.join(to_path, name)
            if filename.endswith(('/', '\\')):
                # A directory
                os.makedirs(filename, exist_ok=True)
            else:
                dirname = os.path.dirname(filename)
                if dirname:
                    os.makedirs(dirname, exist_ok=True)
                with open(filename, 'wb') as outfile:
                    outfile.write(data)
                # Convert ZipInfo.external_attr to mode
                mode = info.external_attr >> 16
                self._copy_permissions(mode, filename)

Fixed code:
file_path: django/utils/archive.py
snippet:     def extract(self, to_path):
        members = self._archive.getmembers()
        leading = self.has_leading_dir(x.name for x in members)
        for member in members:
            name = member.name
            if leading:
                name = self.split_leading_dir(name)[1]
            filename = self.target_filename(to_path, name)
            if member.isdir():
                if filename:
                    os.makedirs(filename, exist_ok=True)
            else:
                try:
                    extracted = self._archive.extractfile(member)
                except (KeyError, AttributeError) as exc:
                    # Some corrupt tar files seem to produce this
                    # (specifically bad symlinks)
                    print("In the tar file %s the member %s is invalid: %s" %
                          (name, member.name, exc))
                else:
                    dirname = os.path.dirname(filename)
                    if dirname:
                        os.makedirs(dirname, exist_ok=True)
                    with open(filename, 'wb') as outfile:
                        shutil.copyfileobj(extracted, outfile)
                        self._copy_permissions(member.mode, filename)
                finally:
                    if extracted:
                        extracted.close()

file_path: django/utils/archive.py
snippet:     def extract(self, to_path):
        namelist = self._archive.namelist()
        leading = self.has_leading_dir(namelist)
        for name in namelist:
            data = self._archive.read(name)
            info = self._archive.getinfo(name)
            if leading:
                name = self.split_leading_dir(name)[1]
            if not name:
                continue
            filename = self.target_filename(to_path, name)
            if name.endswith(('/', '\\')):
                # A directory
                os.makedirs(filename, exist_ok=True)
            else:
                dirname = os.path.dirname(filename)
                if dirname:
                    os.makedirs(dirname, exist_ok=True)
                with open(filename, 'wb') as outfile:
                    outfile.write(data)
                # Convert ZipInfo.external_attr to mode
                mode = info.external_attr >> 16
                self._copy_permissions(mode, filename)

file_path: django/utils/archive.py
snippet:     def target_filename(self, to_path, name):
        target_path = os.path.abspath(to_path)
        filename = os.path.abspath(os.path.join(target_path, name))
        if not filename.startswith(target_path):
            raise SuspiciousOperation("Archive contains invalid path: '%s'" % name)
        return filename