Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-pydicom for openSUSE:Factory checked in at 2026-03-23 17:11:33 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-pydicom (Old) and /work/SRC/openSUSE:Factory/.python-pydicom.new.8177 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-pydicom" Mon Mar 23 17:11:33 2026 rev:18 rq:1341568 version:3.0.2 Changes: -------- --- /work/SRC/openSUSE:Factory/python-pydicom/python-pydicom.changes 2025-04-20 20:08:07.309497382 +0200 +++ /work/SRC/openSUSE:Factory/.python-pydicom.new.8177/python-pydicom.changes 2026-03-23 17:11:54.482029725 +0100 @@ -1,0 +2,7 @@ +Fri Mar 20 10:13:28 UTC 2026 - Markéta Machová <[email protected]> + +- update to 3.0.2 (CVE-2026-32711, bsc#1259973) + * A crafted DICOMDIR could create a path traversal by setting + ReferencedFileID to a path outside the File-set root. + +------------------------------------------------------------------- Old: ---- pydicom-3.0.1-gh.tar.gz New: ---- pydicom-3.0.2-gh.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-pydicom.spec ++++++ --- /var/tmp/diff_new_pack.o80GTf/_old 2026-03-23 17:11:55.166058216 +0100 +++ /var/tmp/diff_new_pack.o80GTf/_new 2026-03-23 17:11:55.166058216 +0100 @@ -1,7 +1,7 @@ # # spec file for package python-pydicom # -# Copyright (c) 2025 SUSE LLC +# Copyright (c) 2026 SUSE LLC and contributors # # All modifications and additions to the file contributed by third parties # remain the property of their copyright owners, unless otherwise agreed @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-pydicom -Version: 3.0.1 +Version: 3.0.2 Release: 0 Summary: Pure python package for DICOM medical file reading and writing License: MIT @@ -31,9 +31,10 @@ BuildRequires: fdupes BuildRequires: python-rpm-macros # SECTION test requirements -BuildRequires: %{python_module numpy} BuildRequires: %{python_module Pillow} +BuildRequires: %{python_module numpy} BuildRequires: %{python_module pydicom-data} +BuildRequires: %{python_module pyfakefs >= 6.1.6} BuildRequires: %{python_module pytest} BuildRequires: %{python_module requests} # /SECTION ++++++ pydicom-3.0.1-gh.tar.gz -> pydicom-3.0.2-gh.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pydicom-3.0.1/doc/release_notes/index.rst new/pydicom-3.0.2/doc/release_notes/index.rst --- old/pydicom-3.0.1/doc/release_notes/index.rst 2024-09-22 03:57:27.000000000 +0200 +++ new/pydicom-3.0.2/doc/release_notes/index.rst 2026-03-19 22:37:06.000000000 +0100 @@ -2,6 +2,7 @@ Release notes ============= +.. include:: v3.0.2.rst .. include:: v3.0.1.rst .. include:: v3.0.0.rst .. include:: v2.4.0.rst diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pydicom-3.0.1/doc/release_notes/v3.0.2.rst new/pydicom-3.0.2/doc/release_notes/v3.0.2.rst --- old/pydicom-3.0.1/doc/release_notes/v3.0.2.rst 1970-01-01 01:00:00.000000000 +0100 +++ new/pydicom-3.0.2/doc/release_notes/v3.0.2.rst 2026-03-19 22:37:06.000000000 +0100 @@ -0,0 +1,8 @@ +3.0.2 +===== + +Fixes +----- + +* Fixed a security issue: a crafted DICOMDIR could set ``ReferencedFileID`` to a path outside the File-set root. + This addresses CVE-2026-32711. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pydicom-3.0.1/pyproject.toml new/pydicom-3.0.2/pyproject.toml --- old/pydicom-3.0.1/pyproject.toml 2024-09-22 03:57:27.000000000 +0200 +++ new/pydicom-3.0.2/pyproject.toml 2026-03-19 22:37:06.000000000 +0100 @@ -29,7 +29,7 @@ name = "pydicom" readme = "README.md" requires-python = ">=3.10" -version = "3.0.1" +version = "3.0.2" [project.optional-dependencies] @@ -55,6 +55,7 @@ "ruff==0.6.3", "types-requests", "pre-commit", + "pyfakefs>=6.1.6", ] basic = ["numpy", "types-pydicom"] @@ -90,6 +91,30 @@ show = "pydicom.cli.show:add_subparser" +[dependency-groups] +docs = [ + "numpy", + "numpydoc", + "matplotlib", + "pillow", + "pydata-sphinx-theme", + "sphinx", + "sphinx-gallery", + "sphinxcontrib-napoleon", + "sphinx-copybutton", + "sphinx_design", +] + +dev = [ + "pydicom-data", + "pyfakefs", + "pytest", + "pytest-cov", + "types-requests", + "pre-commit", +] + + [tool.black] exclude = ".venv|build|/_.*_dict.py$" force-exclude = ".venv|/_.*_dict.py$" # to not do files pre-commit asks for diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pydicom-3.0.1/src/pydicom/fileset.py new/pydicom-3.0.2/src/pydicom/fileset.py --- old/pydicom-3.0.1/src/pydicom/fileset.py 2024-09-22 03:57:27.000000000 +0200 +++ new/pydicom-3.0.2/src/pydicom/fileset.py 2026-03-19 22:37:06.000000000 +0100 @@ -346,24 +346,47 @@ return len(fp.getvalue()) - @property - def _file_id(self) -> Path | None: + 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)) + path = Path(cast(str, self._record.ReferencedFileID)) + else: + path = Path(*cast(list[str], self._record.ReferencedFileID)) - return None + 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") @@ -373,7 +396,7 @@ 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): @@ -525,7 +548,7 @@ indent = indent_char * node.depth if node.children: s.append(f"{indent}{node}") - # Summarise any leaves at the next level + # Summarize any leaves at the next level for child in node.children: if child.has_instance: s.extend(leaf_summary(child, indent_char)) @@ -926,9 +949,8 @@ 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: @@ -962,7 +984,7 @@ to the DICOMDIR file. """ # The nominal path to the root of the File-set - self._path: Path | None = 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 @@ -1203,7 +1225,7 @@ """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() @@ -1635,7 +1657,7 @@ ) 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 " @@ -1661,7 +1683,7 @@ self._charset = cast( str | None, ds.get("SpecificCharacterSetOfFileSetDescriptorFile", None) ) - self._path = path.parent + self._root_path = path.parent self._ds = ds # Create the record tree @@ -1670,20 +1692,17 @@ 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) warn_and_log( "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 @@ -1695,6 +1714,31 @@ 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, include_orphans: bool, raise_orphans: bool = False ) -> None: @@ -1748,7 +1792,10 @@ 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) @@ -1781,12 +1828,11 @@ 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: @@ -1796,14 +1842,29 @@ instance.node._record.ReferencedFileID = original_value @property + 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. @@ -2062,14 +2123,15 @@ ) 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 # are required and `use_existing` is True @@ -2117,23 +2179,31 @@ # 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]: + 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) + shutil.copyfile( + 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 + src: Path | str 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 -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pydicom-3.0.1/tests/conftest.py new/pydicom-3.0.2/tests/conftest.py --- old/pydicom-3.0.1/tests/conftest.py 2024-09-22 03:57:27.000000000 +0200 +++ new/pydicom-3.0.2/tests/conftest.py 2026-03-19 22:37:06.000000000 +0100 @@ -22,6 +22,14 @@ @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 + + [email protected] def enforce_writing_invalid_values(): value = config.settings.writing_validation_mode config.settings.writing_validation_mode = config.RAISE diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pydicom-3.0.1/tests/test_dataset.py new/pydicom-3.0.2/tests/test_dataset.py --- old/pydicom-3.0.1/tests/test_dataset.py 2024-09-22 03:57:27.000000000 +0200 +++ new/pydicom-3.0.2/tests/test_dataset.py 2026-03-19 22:37:06.000000000 +0100 @@ -2987,7 +2987,7 @@ msg = ( r"Error deepcopying the buffered element \(7FE0,0010\) 'Pixel Data': " - r"cannot (.*) '_io.BufferedReader' object" + r"cannot (.*)BufferedReader" ) with pytest.raises(TypeError, match=msg): copy.deepcopy(ds) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/pydicom-3.0.1/tests/test_fileset.py new/pydicom-3.0.2/tests/test_fileset.py --- old/pydicom-3.0.1/tests/test_fileset.py 2024-09-22 03:57:27.000000000 +0200 +++ new/pydicom-3.0.2/tests/test_fileset.py 2026-03-19 22:37:06.000000000 +0100 @@ -1,4 +1,5 @@ import os +import platform import sys from pathlib import Path import shutil @@ -9,7 +10,7 @@ from pydicom import 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, @@ -106,6 +107,56 @@ 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""" @@ -139,7 +190,7 @@ @pytest.fixture -def private(dicomdir): +def private(dicomdir, request, ignore_reading_invalid_values): """Return a DICOMDIR dataset with PRIVATE records.""" def write_record(ds): @@ -167,13 +218,17 @@ 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" ) @@ -683,6 +738,15 @@ 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 @@ -745,7 +809,33 @@ item.ReferencedFileID = "01" ds.save_as(p / "DICOMDIR", overwrite=True) 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", overwrite=True) + 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.""" @@ -754,7 +844,7 @@ 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")) class TestFileInstance: @@ -1673,7 +1763,7 @@ 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 @@ -1684,7 +1774,7 @@ 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() @@ -2328,14 +2418,14 @@ 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) @@ -2487,6 +2577,18 @@ 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
