[Debian-med-packaging] Bug#1148120: trixie-pu: package pydicom/2.4.3-2+deb13u1
Karsten Schöke
karsten.schoeke at geobasis-bb.de
Thu Sep 17 09:35:00 BST 2026
Package: release.debian.org
Severity: normal
Tags: trixie
X-Debbugs-Cc: pydicom at packages.debian.org
Control: affects -1 + src:pydicom
User: release.debian.org at packages.debian.org
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
-------------- next part --------------
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 <karsten.schoeke at geobasis-bb.de> 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?= <karsten.schoeke at geobasis-bb.de>
+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
+
+
++ at pytest.fixture
++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/"
++
++
++ at pytest.fixture(
++ 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
More information about the Debian-med-packaging
mailing list