Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: [email protected]
Control: affects -1 + src:pydicom
User: [email protected]
Usertags: pu

Fix CVE-2026-32711
Path traversal in FileSet/DICOMDIR ReferencedFileID 
allows file access outside the File-set root

[ Tests ]
autopkgtest are ok.

[ Checklist ]
  [*] *all* changes are documented in the d/changelog
  [*] I reviewed all changes and I approve them
  [*] attach debdiff against the package in (old)stable
  [*] the issue is verified as fixed in unstable

[ Changes ]
see attachment
diff --git a/debian/changelog b/debian/changelog
index c47a75b..f7cabee 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,10 @@
+pydicom (2.4.3-2+deb13u1) trixie; urgency=medium
+
+  * Team upload.
+  * Fix CVE-2026-32711 vulnerable to Path Traversal
+
+ -- Karsten Schöke <[email protected]>  Thu, 17 Sep 2026 10:11:27 
+0200
+
 pydicom (2.4.3-2) unstable; urgency=medium
 
   * Team upload.
diff --git a/debian/control b/debian/control
index 675f184..e3dc157 100644
--- a/debian/control
+++ b/debian/control
@@ -9,6 +9,7 @@ Build-Depends: debhelper-compat (= 13),
                dh-python,
                python3-all,
                python3-pytest,
+               python3-pyfakefs,
                python3-setuptools,
                python3-setuptools-scm,
                python3-numpy,
diff --git a/debian/patches/0006-Insert-CVE-2026-32711-patch.patch 
b/debian/patches/0006-Insert-CVE-2026-32711-patch.patch
new file mode 100644
index 0000000..a5ff341
--- /dev/null
+++ b/debian/patches/0006-Insert-CVE-2026-32711-patch.patch
@@ -0,0 +1,567 @@
+From: =?utf-8?q?Karsten_Sch=C3=B6ke?= <[email protected]>
+Date: Wed, 16 Sep 2026 18:51:48 +0200
+Subject: Insert CVE-2026-32711 patch.
+
+---
+ pydicom/cli/codify.py         |   2 +-
+ pydicom/fileset.py            | 152 ++++++++++++++++++++++++++++++------------
+ pydicom/tests/conftest.py     |   8 +++
+ pydicom/tests/test_fileset.py | 126 +++++++++++++++++++++++++++++++---
+ 4 files changed, 233 insertions(+), 55 deletions(-)
+
+diff --git a/pydicom/cli/codify.py b/pydicom/cli/codify.py
+index 15115c4..2239507 100644
+--- a/pydicom/cli/codify.py
++++ b/pydicom/cli/codify.py
+@@ -24,7 +24,7 @@ def add_subparser(subparsers: argparse._SubParsersAction) -> 
None:
+         ),
+     )
+ 
+-    # Codify existed before as a stand-alone before, re-use it here
++    # Codify existed before as a stand-alone before, reuse it here
+     pydicom.util.codify.set_parser_arguments(
+         codify_parser, default_exclude_size
+     )
+diff --git a/pydicom/fileset.py b/pydicom/fileset.py
+index 5549d6d..f4412a4 100644
+--- a/pydicom/fileset.py
++++ b/pydicom/fileset.py
+@@ -344,24 +344,47 @@ class RecordNode(Iterable["RecordNode"]):
+ 
+         return len(fp.getvalue())
+ 
+-    @property
+-    def _file_id(self) -> Optional[Path]:
++    def file_id_path(self, root_path: Path) -> Path | None:
+         """Return the *Referenced File ID* as a :class:`~pathlib.Path`.
+ 
++        Params
++        ------
++        root_path : Path
++            The root path of the parent file set.
++
+         Returns
+         -------
+         pathlib.Path or None
+             The *Referenced File ID* from the directory record as a
+             :class:`pathlib.Path` or ``None`` if the element value is null.
++
++        Raises
++        ------
++        PermissionError
++            If the file ID points to a path outside the fileset root path.
++
++        AttributeError
++            If the Referenced File ID is missing in the directory record.
++
++        :meta private:
+         """
+         if "ReferencedFileID" in self._record:
+             elem = self._record["ReferencedFileID"]
++            if elem.VM < 1:
++                return None
+             if elem.VM == 1:
+-                return Path(cast(str, self._record.ReferencedFileID))
+-            if elem.VM > 1:
+-                return Path(*cast(List[str], self._record.ReferencedFileID))
+-
+-            return None
++                path = Path(cast(str, self._record.ReferencedFileID))
++            else:
++                path = Path(*cast(list[str], self._record.ReferencedFileID))
++
++            if path is not None:
++                if path.anchor or not (
++                    (root_path / path).resolve().is_relative_to(root_path)
++                ):
++                    raise PermissionError(
++                        f"ReferencedFileID ('{path}') must be inside the 
DICOMDIR root path"
++                    )
++            return path
+ 
+         raise AttributeError("No 'Referenced File ID' in the directory 
record")
+ 
+@@ -371,7 +394,7 @@ class RecordNode(Iterable["RecordNode"]):
+         return self.root.file_set
+ 
+     def __getitem__(self, key: Union[str, "RecordNode"]) -> "RecordNode":
+-        """Return the current node's child using it's
++        """Return the current node's child using its
+         :attr:`~pydicom.fileset.RecordNode.key`
+         """
+         if isinstance(key, RecordNode):
+@@ -524,8 +547,8 @@ class RecordNode(Iterable["RecordNode"]):
+         for node in self:
+             indent = indent_char * node.depth
+             if node.children:
+-                s.append(f"{indent}{str(node)}")
+-                # Summarise any leaves at the next level
++                s.append(f"{indent}{node}")
++                # Summarize any leaves at the next level
+                 for child in node.children:
+                     if child.has_instance:
+                         s.extend(leaf_summary(child, indent_char))
+@@ -931,9 +954,8 @@ class FileInstance:
+             return os.fspath(cast(Path, self._stage_path))
+ 
+         # If not staged for addition then File Set must exist on file system
+-        return os.fspath(
+-            cast(Path, self.file_set.path) / cast(Path, self.node._file_id)
+-        )
++        root_path = self.file_set.root_path
++        return os.fspath(root_path / cast(Path, 
self.node.file_id_path(root_path)))
+ 
+     @property
+     def SOPClassUID(self) -> UID:
+@@ -966,7 +988,7 @@ class FileSet:
+             to the DICOMDIR file.
+         """
+         # The nominal path to the root of the File-set
+-        self._path: Optional[Path] = None
++        self._root_path: Path | None = None
+         # The root node of the record tree used to fill out the DICOMDIR's
+         #   *Directory Record Sequence*.
+         # The tree for instances currently in the File-set
+@@ -1212,7 +1234,7 @@ class FileSet:
+         """Clear the File-set."""
+         self._tree.children = []
+         self._instances = []
+-        self._path = None
++        self._root_path = None
+         self._ds = Dataset()
+         self._id = None
+         self._uid = generate_uid()
+@@ -1661,7 +1683,7 @@ class FileSet:
+             )
+ 
+         try:
+-            path = Path(cast(str, ds.filename)).resolve(strict=True)
++            path = Path(ds.filename).resolve(strict=True)
+         except FileNotFoundError:
+             raise FileNotFoundError(
+                 "Unable to load the File-set as the 'filename' attribute "
+@@ -1692,7 +1714,7 @@ class FileSet:
+             Optional[str],
+             ds.get("SpecificCharacterSetOfFileSetDescriptorFile", None)
+         )
+-        self._path = path.parent
++        self._root_path = path.parent
+         self._ds = ds
+ 
+         # Create the record tree
+@@ -1701,20 +1723,17 @@ class FileSet:
+         bad_instances = []
+         for instance in self:
+             # Check that the referenced file exists
+-            file_id = instance.node._file_id
+-            if file_id is None:
+-                bad_instances.append(instance)
+-                continue
+-
++            file_id = self._file_id_path(instance.node)
++            assert file_id is not None
+             try:
+                 # self.path is already set at this point
+-                (cast(Path, self.path) / file_id).resolve(strict=True)
++                (self.root_path / file_id).resolve(strict=True)
+             except FileNotFoundError:
+                 bad_instances.append(instance)
+                 warnings.warn(
+                     "The referenced SOP Instance for the directory record at "
+                     f"offset {instance.node._offset} does not exist: "
+-                    f"{cast(Path, self.path) / file_id}"
++                    f"{self.root_path / file_id}"
+                 )
+                 continue
+ 
+@@ -1726,6 +1745,31 @@ class FileSet:
+         for instance in bad_instances:
+             self._instances.remove(instance)
+ 
++    def _file_id_path(self, node: RecordNode) -> Path | None:
++        """Return the *Referenced File ID* from the given node
++        as a :class:`~pathlib.Path`.
++
++        Parameters
++        ----------
++        node: RecordNode
++            The node where the *Referenced File ID* resides.
++
++        Returns
++        -------
++        pathlib.Path or None
++            The *Referenced File ID* from the directory record as a
++            :class:`pathlib.Path` or ``None`` if the element value is null.
++
++        Raises
++        ------
++        PermissionError
++            If the file ID points to a path outside the fileset root path.
++
++        AttributeError
++            If the Referenced File ID is missing in the directory record.
++        """
++        return node.file_id_path(self.root_path)
++
+     def _parse_records(
+         self,
+         ds: Dataset,
+@@ -1782,7 +1826,10 @@ class FileSet:
+                 del node.parent[node]
+ 
+             # The leaf node references the FileInstance
+-            if "ReferencedFileID" in node._record:
++            if (
++                "ReferencedFileID" in node._record
++                and self._file_id_path(node) is not None
++            ):
+                 node.instance = FileInstance(node)
+                 self._instances.append(node.instance)
+ 
+@@ -1817,12 +1864,11 @@ class FileSet:
+         for node in missing:
+             # Get the path to the orphaned instance
+             original_value = node._record.ReferencedFileID
+-            file_id = node._file_id
+-            if file_id is None:
++            if (file_id := self._file_id_path(node)) is None:
+                 continue
+ 
+             # self.path is set for an existing File Set
+-            path = cast(Path, self.path) / file_id
++            path = self.root_path / file_id
+             if node.record_type == "PRIVATE":
+                 instance = self.add_custom(path, node)
+             else:
+@@ -1832,14 +1878,29 @@ class FileSet:
+             instance.node._record.ReferencedFileID = original_value
+ 
+     @property
+-    def path(self) -> Optional[str]:
++    def root_path(self) -> Path:
++        """Return the absolute path to the File-set root directory as
++        :class:`pathlib.Path`.
++
++        Raises
++        ------
++        AttributeError
++            If the root path is not set.
++        """
++        if self._root_path is None:
++            raise AttributeError("No root path set in the File-set")
++
++        return self._root_path
++
++    @property
++    def path(self) -> str | None:
+         """Return the absolute path to the File-set root directory as
+         :class:`str` (if set) or ``None`` otherwise.
+         """
+-        if self._path is not None:
+-            return os.fspath(self._path)
++        if self._root_path is not None:
++            return os.fspath(self._root_path)
+ 
+-        return self._path
++        return None
+ 
+     def _recordify(self, ds: Dataset) -> Iterator[Dataset]:
+         """Yield directory records for a SOP Instance.
+@@ -2105,16 +2166,17 @@ class FileSet:
+             )
+ 
+         if path:
+-            self._path = Path(path)
++            self._root_path = Path(path)
+ 
+         # Don't write unless changed or new
+         if not self.is_staged:
+             return
+ 
+         # Path to the DICOMDIR file
+-        p = cast(Path, self._path) / 'DICOMDIR'
++        root = self.root_path
++        p = root / "DICOMDIR"
+ 
+-        # Re-use the existing directory structure if only moves or removals
++        # Reuse the existing directory structure if only moves or removals
+         #   are required and `use_existing` is True
+         major_change = bool(self._stage['+'])
+         if use_existing and major_change:
+@@ -2159,26 +2221,30 @@ class FileSet:
+         #   and copy any to the stage
+         fout = {Path(ii.FileID) for ii in self}
+         fin = {
+-            ii.node._file_id for ii in self
+-            if ii.SOPInstanceUID not in self._stage['+']
++            self._file_id_path(ii.node)
++            for ii in self
++            if ii.SOPInstanceUID not in self._stage["+"]
+         }
+         collisions = fout & fin
+-        for instance in [ii for ii in self if ii.node._file_id in collisions]:
+-            self._stage['+'][instance.SOPInstanceUID] = instance
+-            instance._apply_stage('+')
++        for instance in [
++            ii for ii in self if self._file_id_path(ii.node) in collisions
++        ]:
++            self._stage["+"][instance.SOPInstanceUID] = instance
++            instance._apply_stage("+")
+             shutil.copyfile(
+-                self._path / instance.node._file_id, instance.path
++                root / cast(Path, self._file_id_path(instance.node)),
++                instance.path,
+             )
+ 
+         for instance in self:
+-            dst = self._path / instance.FileID
++            dst = root / instance.FileID
+             dst.parent.mkdir(parents=True, exist_ok=True)
+             fn: Callable
+             if instance.SOPInstanceUID in self._stage['+']:
+                 src = instance.path
+                 fn = shutil.copyfile
+             else:
+-                src = self._path / instance.node._file_id
++                src = root / cast(Path, self._file_id_path(instance.node))
+                 fn = shutil.move
+ 
+             fn(os.fspath(src), os.fspath(dst))
+diff --git a/pydicom/tests/conftest.py b/pydicom/tests/conftest.py
+index 10d1dc3..301fd00 100644
+--- a/pydicom/tests/conftest.py
++++ b/pydicom/tests/conftest.py
+@@ -22,6 +22,14 @@ def allow_reading_invalid_values():
+     config.settings.reading_validation_mode = value
+ 
+ 
[email protected]
++def ignore_reading_invalid_values():
++    value = config.settings.reading_validation_mode
++    config.settings.reading_validation_mode = config.IGNORE
++    yield
++    config.settings.reading_validation_mode = value
++
++
+ @pytest.fixture
+ def enforce_writing_invalid_values():
+     value = config.settings.writing_validation_mode
+diff --git a/pydicom/tests/test_fileset.py b/pydicom/tests/test_fileset.py
+index cff26da..bdb7cee 100644
+--- a/pydicom/tests/test_fileset.py
++++ b/pydicom/tests/test_fileset.py
+@@ -1,5 +1,6 @@
+ 
+ import os
++import platform
+ import sys
+ from pathlib import Path
+ import shutil
+@@ -10,7 +11,7 @@ import pytest
+ from pydicom import config, dcmread
+ from pydicom.data import get_testdata_file
+ from pydicom.dataset import Dataset, FileMetaDataset
+-from pydicom.filebase import DicomBytesIO
++from pydicom.filebase import DicomBytesIO, DicomFileLike
+ from pydicom.fileset import (
+     FileSet, FileInstance, RecordNode, is_conformant_file_id,
+     generate_filename, _define_patient, _define_study, _define_series,
+@@ -81,6 +82,56 @@ def tdir():
+     return TemporaryDirectory()
+ 
+ 
++FILESET_ROOT = "/path/to/fileset/"
++ABS_FILE_PATH = "/secret.txt"
++SYMLINK_TO_ABS_FILE = "Pat1/St1/Im2"
++SYMLINK_TO_ABS_DIR = "Pat1/St2"
++DOT_DOT_FILE = "../goback.txt"
++ABS_FILE_CONTENTS = "Top Secret file contents"
++COPY_PATH = "/path/to/copied/"
++
++
[email protected](
++    params=[
++        ABS_FILE_PATH,
++        DOT_DOT_FILE,
++        SYMLINK_TO_ABS_FILE,
++        SYMLINK_TO_ABS_DIR + ABS_FILE_PATH,
++    ]
++)
++def fileset_fs(request, fs, ignore_reading_invalid_values):
++    """Create an in-memory file system with pyfakefs and test DICOMDIRs"""
++    # Simplified version of submitted report from JeongAhn Jang, in pyfakefs
++    orig_dicomdir_root = Path(TEST_FILE).parent
++    dicomdir_root = Path(FILESET_ROOT)
++    fs.add_real_file(
++        orig_dicomdir_root / "77654033/CR1/6154",
++        target_path=dicomdir_root / "Pat1/St1/Im1",
++    )
++    fs.create_file(ABS_FILE_PATH, contents=ABS_FILE_CONTENTS)
++    fs.create_dir(COPY_PATH)
++    fs.create_symlink(dicomdir_root / SYMLINK_TO_ABS_FILE, ABS_FILE_PATH)
++    fs.create_symlink(dicomdir_root / SYMLINK_TO_ABS_DIR, "/")
++    # MAKE DICOMDIR for this simplified file-set
++    fset = FileSet()
++    fset.add(dicomdir_root / "Pat1/St1/Im1")
++    fset.write(dicomdir_root)
++
++    # Create bad DICOMDIR2 file from the simplified one
++    # Modify first referenced file
++    fset = FileSet(dicomdir_root / "DICOMDIR")
++    record = next(
++        rec for rec in fset._ds.DirectoryRecordSequence if "ReferencedFileID" 
in rec
++    )
++    record.ReferencedFileID = request.param
++
++    # Write modified DICOMDIR file
++    with open(dicomdir_root / "DICOMDIR2", "wb") as fp:
++        fset._write_dicomdir(DicomFileLike(fp))
++
++    yield fs
++
++
+ @pytest.fixture
+ def custom_leaf():
+     """Return the leaf node from a custom 4-level record hierarchy"""
+@@ -116,7 +167,7 @@ def custom_leaf():
+ 
+ 
+ @pytest.fixture
+-def private(dicomdir):
++def private(dicomdir, request, ignore_reading_invalid_values):
+     """Return a DICOMDIR dataset with PRIVATE records."""
+     def write_record(ds):
+         """Return `ds` as explicit little encoded bytes."""
+@@ -143,9 +194,17 @@ def private(dicomdir):
+     middle = private_record()
+     bottom = private_record()
+     bottom.ReferencedSOPClassUIDInFile = "1.2.3.4"
+-    bottom.ReferencedFileID = [
+-        "TINY_ALPHA", "PT000000", "ST000000", "SE000000", "IM000000"
+-    ]
++    if hasattr(request, "param"):
++        file_ids = request.param
++    else:
++        file_ids = [
++            "TINY_ALPHA",
++            "PT000000",
++            "ST000000",
++            "SE000000",
++            "IM000000",
++        ]
++    bottom.ReferencedFileID = file_ids
+     bottom.ReferencedSOPInstanceUIDInFile = (
+         "1.2.276.0.7230010.3.1.4.0.31906.1359940846.78187"
+     )
+@@ -647,6 +706,15 @@ class TestRecordNode:
+         with pytest.raises(AttributeError, match=msg):
+             instance.node.key
+ 
++    @pytest.mark.parametrize("private", [["/", "etc", "passwd"]], 
indirect=True)
++    def test_id_outside_root(self, private):
++        """File ID points to a path outside the root directory."""
++        with pytest.raises(
++            PermissionError,
++            match=r"ReferencedFileID .* must be inside the DICOMDIR root 
path",
++        ):
++            FileSet(private)
++
+     def test_bad_record(self, private):
+         """Test a bad directory record raises an exception when loading."""
+         del private.DirectoryRecordSequence[0].PatientID
+@@ -709,7 +777,33 @@ class TestRecordNode:
+         item.ReferencedFileID = "01"
+         ds.save_as(p / "DICOMDIR")
+         fs = FileSet(ds)
+-        assert fs._instances[0].node._file_id == Path("01")
++        assert fs._instances[0].node.file_id_path(fs.root_path) == Path("01")
++
++    def test_absolute_file_id(self, ct, tdir, ignore_reading_invalid_values):
++        """Test a singleton File ID."""
++        fs = FileSet()
++        p = Path(tdir.name)
++        ct.save_as(p / "01")
++        fs.add(p / "01")
++        fs.write(p)
++        ds = dcmread(p / "DICOMDIR")
++        item = ds.DirectoryRecordSequence[-1]
++        item.ReferencedFileID = "/01"
++        ds.save_as(p / "DICOMDIR")
++        with pytest.raises(
++            PermissionError,
++            match=r"ReferencedFileID .* must be inside the DICOMDIR root 
path",
++        ):
++            FileSet(ds)
++
++    def test_root_path_missing(self, ct):
++        """Test RecordNode._file_id if no Referenced File ID."""
++        fs = FileSet()
++        instance = fs.add(ct)
++        # del instance.node._record.ReferencedFileID
++        msg = r"No root path set in the File-set"
++        with pytest.raises(AttributeError, match=msg):
++            fs.root_path
+ 
+     def test_file_id_missing(self, ct):
+         """Test RecordNode._file_id if no Referenced File ID."""
+@@ -718,7 +812,7 @@ class TestRecordNode:
+         del instance.node._record.ReferencedFileID
+         msg = r"No 'Referenced File ID' in the directory record"
+         with pytest.raises(AttributeError, match=msg):
+-            instance.node._file_id
++            instance.node.file_id_path(Path("/dicom_data"))
+ 
+ 
+ @pytest.mark.filterwarnings("ignore:The 'DicomDir'")
+@@ -1651,7 +1745,7 @@ class TestFileSet:
+         assert "ISO 1" == fs.descriptor_character_set
+         assert [] != fs._instances
+         assert fs._id is not None
+-        assert fs._path is not None
++        assert fs.root_path is not None
+         uid = fs._uid
+         assert fs._uid is not None
+         assert fs._ds is not None
+@@ -1662,7 +1756,7 @@ class TestFileSet:
+         fs.clear()
+         assert [] == fs._instances
+         assert fs._id is None
+-        assert fs._path is None
++        assert fs._root_path is None
+         assert uid != fs._uid
+         assert fs._uid.is_valid
+         assert fs._ds == Dataset()
+@@ -2323,14 +2417,14 @@ class TestFileSet_Modify:
+         tdir, ds = dicomdir_copy
+         assert 52 == len(ds.DirectoryRecordSequence)
+         fs = FileSet(ds)
+-        orig_paths = [p for p in fs._path.glob('**/*') if p.is_file()]
++        orig_paths = [p for p in fs.root_path.glob("**/*") if p.is_file()]
+         instance = fs._instances[0]
+         assert Path(instance.path) in orig_paths
+         fs.remove(instance)
+         orig_file_ids = [ii.ReferencedFileID for ii in fs]
+         fs.write(use_existing=True)
+         assert 50 == len(fs._ds.DirectoryRecordSequence)
+-        paths = [p for p in fs._path.glob('**/*') if p.is_file()]
++        paths = [p for p in fs.root_path.glob("**/*") if p.is_file()]
+         assert orig_file_ids == [ii.ReferencedFileID for ii in fs]
+         assert Path(instance.path) not in paths
+         assert sorted(orig_paths)[1:] == sorted(paths)
+@@ -2480,6 +2574,16 @@ class TestFileSet_Copy:
+     def teardown_method(self):
+         FileSet.__len__ = self.orig
+ 
++    @pytest.mark.skipif(platform.python_implementation() == "PyPy",
++                        reason="pyfakefs does not work with generate_uid() in 
PyPy")
++    def test_constrained_to_fileset_root(self, fileset_fs):
++        """Ensure files cannot be copied outside the FileSet root"""
++        with pytest.raises(
++            PermissionError,
++            match=r"ReferencedFileID .* must be inside the DICOMDIR root 
path",
++        ):
++            FileSet(Path(FILESET_ROOT) / "DICOMDIR2")
++
+     def test_copy(self, dicomdir, tdir):
+         """Test FileSet.copy()"""
+         orig_root = Path(dicomdir.filename).parent
diff --git a/debian/patches/series b/debian/patches/series
index b82a55e..d48f0cc 100644
--- a/debian/patches/series
+++ b/debian/patches/series
@@ -3,3 +3,4 @@ ignore_tests_downloading_data.patch
 ignore_tests_with_wrong_gdcm_usage.patch
 sphinx_no_git.patch
 skip-i386-incapable-tests.patch
+0006-Insert-CVE-2026-32711-patch.patch

Reply via email to