From 8a13215e248d0b3caa04269b2b1bf70d16d9b1c7 Mon Sep 17 00:00:00 2001 From: VedhSontha Date: Fri, 12 Jun 2026 01:07:46 +0530 Subject: [PATCH 1/8] Raise exception for uninstalled LoadImage reader and print writer pkg suggestion --- monai/data/image_writer.py | 20 ++++++++++++++++++-- monai/transforms/io/array.py | 13 +++++++++---- tests/data/test_image_rw.py | 14 ++++++++++++-- tests/data/test_init_reader.py | 23 +++++++++++++++++++++-- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/monai/data/image_writer.py b/monai/data/image_writer.py index cc6cdcdead..47e811def8 100644 --- a/monai/data/image_writer.py +++ b/monai/data/image_writer.py @@ -107,16 +107,32 @@ def resolve_writer(ext_name, error_if_not_found=True) -> Sequence: fmt = fmt[1:] avail_writers = [] default_writers = SUPPORTED_WRITERS.get(EXT_WILDCARD, ()) + import re + errors = [] for _writer in look_up_option(fmt, SUPPORTED_WRITERS, default=default_writers): try: _writer() # this triggers `monai.utils.module.require_pkg` to check the system availability avail_writers.append(_writer) - except OptionalImportError: + except OptionalImportError as e: + errors.append(str(e)) continue except Exception: # other writer init errors indicating it exists avail_writers.append(_writer) if not avail_writers and error_if_not_found: - raise OptionalImportError(f"No ImageWriter backend found for {fmt}.") + required_pkgs = [] + for err in errors: + match = re.search(r"required package `([^`]+)`", err) + if match: + pkg = match.group(1) + pkg = "pillow" if pkg == "PIL" else pkg + pkg = "pynrrd" if pkg == "nrrd" else pkg + if pkg not in required_pkgs: + required_pkgs.append(pkg) + + msg = f"No ImageWriter backend found for {fmt}." + if required_pkgs: + msg += f" Please install: {' or '.join(required_pkgs)}." + raise OptionalImportError(msg) writer_tuple = ensure_tuple(avail_writers) SUPPORTED_WRITERS[fmt] = writer_tuple return writer_tuple diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index aadd96763d..ef9fa52a07 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -209,13 +209,18 @@ def __init__( the_reader = look_up_option(_r.lower(), SUPPORTED_READERS) try: self.register(the_reader(*args, **kwargs)) - except OptionalImportError: - warnings.warn( + except OptionalImportError as e: + raise OptionalImportError( f"required package for reader {_r} is not installed, or the version doesn't match requirement." - ) + ) from e except TypeError: # the reader doesn't have the corresponding args/kwargs warnings.warn(f"{_r} is not supported with the given parameters {args} {kwargs}.") - self.register(the_reader()) + try: + self.register(the_reader()) + except OptionalImportError as e: + raise OptionalImportError( + f"required package for reader {_r} is not installed, or the version doesn't match requirement." + ) from e elif inspect.isclass(_r): self.register(_r(*args, **kwargs)) else: diff --git a/tests/data/test_image_rw.py b/tests/data/test_image_rw.py index d90c1c8571..96224f4cec 100644 --- a/tests/data/test_image_rw.py +++ b/tests/data/test_image_rw.py @@ -139,8 +139,18 @@ def test_rgb(self, reader, writer): class TestRegRes(unittest.TestCase): def test_0_default(self): self.assertTrue(len(resolve_writer(".png")) > 0, "has png writer") - self.assertTrue(len(resolve_writer(".nrrd")) > 0, "has nrrd writer") - self.assertTrue(len(resolve_writer("unknown")) > 0, "has writer") + _, has_nibabel = optional_import("nibabel") + _, has_itk = optional_import("itk", allow_namespace_pkg=True) + if has_nibabel or has_itk: + self.assertTrue(len(resolve_writer(".nrrd")) > 0, "has nrrd writer") + self.assertTrue(len(resolve_writer("unknown")) > 0, "has writer") + else: + with self.assertRaises(OptionalImportError) as ctx: + resolve_writer(".nrrd") + self.assertIn("Please install: itk or nibabel.", str(ctx.exception)) + with self.assertRaises(OptionalImportError) as ctx: + resolve_writer("unknown") + self.assertIn("Please install: itk or nibabel.", str(ctx.exception)) register_writer("unknown1", lambda: (_ for _ in ()).throw(OptionalImportError)) with self.assertRaises(OptionalImportError): resolve_writer("unknown1") diff --git a/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 169fd20a5f..94e9ad55b0 100644 --- a/tests/data/test_init_reader.py +++ b/tests/data/test_init_reader.py @@ -29,9 +29,28 @@ def test_load_image(self): self.assertIsInstance(instance1, LoadImage) self.assertIsInstance(instance2, LoadImage) + from monai.utils import optional_import, OptionalImportError + pkg_map = { + "NibabelReader": "nibabel", + "PILReader": "PIL", + "ITKReader": "itk", + "NrrdReader": "nrrd", + "NumpyReader": "numpy", + "PydicomReader": "pydicom", + } for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", None]: - inst = LoadImaged("image", reader=r) - self.assertIsInstance(inst, LoadImaged) + if r is None: + inst = LoadImaged("image", reader=r) + self.assertIsInstance(inst, LoadImaged) + continue + + _, has_pkg = optional_import(pkg_map[r]) + if has_pkg: + inst = LoadImaged("image", reader=r) + self.assertIsInstance(inst, LoadImaged) + else: + with self.assertRaises(OptionalImportError): + LoadImaged("image", reader=r) @SkipIfNoModule("nibabel") @SkipIfNoModule("cupy") From 5673b525cb218e2ca91000c098dc360abc313d39 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:43:43 +0000 Subject: [PATCH 2/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/data/image_writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monai/data/image_writer.py b/monai/data/image_writer.py index 47e811def8..49ecaed02b 100644 --- a/monai/data/image_writer.py +++ b/monai/data/image_writer.py @@ -128,7 +128,7 @@ def resolve_writer(ext_name, error_if_not_found=True) -> Sequence: pkg = "pynrrd" if pkg == "nrrd" else pkg if pkg not in required_pkgs: required_pkgs.append(pkg) - + msg = f"No ImageWriter backend found for {fmt}." if required_pkgs: msg += f" Please install: {' or '.join(required_pkgs)}." From d06b6ff5325b0699268d407b5baec7d3e6fbc3a0 Mon Sep 17 00:00:00 2001 From: VedhSontha Date: Mon, 22 Jun 2026 03:06:17 +0530 Subject: [PATCH 3/8] docs: fix minor grammatical typo in Model Zoo section of README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d0927ad8c3..f6ff81c440 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ If you have used MONAI in your research, please cite us! The citation can be exp ## Model Zoo -[The MONAI Model Zoo](https://github.com/Project-MONAI/model-zoo) is a place for researchers and data scientists to share the latest and great models from the community. +[The MONAI Model Zoo](https://github.com/Project-MONAI/model-zoo) is a place for researchers and data scientists to share the latest and greatest models from the community. Utilizing [the MONAI Bundle format](https://monai.readthedocs.io/en/latest/bundle_intro.html) makes it easy to [get started](https://github.com/Project-MONAI/tutorials/tree/main/model_zoo) building workflows with MONAI. ## Contributing From f1f014394e562e539bfe208285fea6eca0359664 Mon Sep 17 00:00:00 2001 From: VedhSontha Date: Wed, 24 Jun 2026 18:27:15 +0530 Subject: [PATCH 4/8] feat(apps): add security logs for blocked path traversal attempts in archive extraction --- monai/apps/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index 856bc64c9e..e86c603441 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -133,13 +133,16 @@ def safe_extract_member(member, extract_to): member_path = str(member) if hasattr(member, "issym") and member.issym(): + logger.warning(f"Unsafe path guard: Symbolic link blocked: {member_path}") raise ValueError(f"Symbolic link detected in archive: {member_path}") if hasattr(member, "islnk") and member.islnk(): + logger.warning(f"Unsafe path guard: Hard link blocked: {member_path}") raise ValueError(f"Hard link detected in archive: {member_path}") member_path = os.path.normpath(member_path) if os.path.isabs(member_path) or ".." in member_path.split(os.sep): + logger.warning(f"Unsafe path guard: Absolute/relative path traversal blocked: {member_path}") raise ValueError(f"Unsafe path detected in archive: {member_path}") full_path = os.path.join(extract_to, member_path) @@ -149,6 +152,7 @@ def safe_extract_member(member, extract_to): target_real = os.path.realpath(full_path) # Ensure the resolved path stays within the extraction root if os.path.commonpath([extract_root, target_real]) != extract_root: + logger.warning(f"Unsafe path guard: Out-of-bounds path traversal blocked: {member_path}") raise ValueError(f"Unsafe path: path traversal {member_path}") return full_path From 42f2353fe9d2c8f984bdcbdf22f05cadd875acaa Mon Sep 17 00:00:00 2001 From: VedhSontha Date: Fri, 26 Jun 2026 03:11:01 +0530 Subject: [PATCH 5/8] docs: document secure archive extraction feature in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f6ff81c440..23bb6d7246 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Its ambitions are as follows: - flexible pre-processing for multi-dimensional medical imaging data; - compositional & portable APIs for ease of integration in existing workflows; +- secure archive extraction utilities with built-in path-traversal protection; - domain-specific implementations for networks, losses, evaluation metrics and more; - customizable design for varying user expertise; - multi-GPU multi-node data parallelism support. From be02d5df3bf05ec0f838fb559c6004c8565d65f7 Mon Sep 17 00:00:00 2001 From: VedhSontha Date: Sat, 27 Jun 2026 15:15:45 +0530 Subject: [PATCH 6/8] fix(apps): handle drive mismatch ValueError in commonpath during zip/tar extraction on Windows --- monai/apps/utils.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index e86c603441..487e5b65a3 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -151,9 +151,14 @@ def safe_extract_member(member, extract_to): extract_root = os.path.realpath(extract_to) target_real = os.path.realpath(full_path) # Ensure the resolved path stays within the extraction root - if os.path.commonpath([extract_root, target_real]) != extract_root: - logger.warning(f"Unsafe path guard: Out-of-bounds path traversal blocked: {member_path}") - raise ValueError(f"Unsafe path: path traversal {member_path}") + try: + # On Windows, comparing paths on different drives raises ValueError in commonpath + if os.path.commonpath([extract_root, target_real]) != extract_root: + logger.warning(f"Unsafe path guard: Out-of-bounds path traversal blocked: {member_path}") + raise ValueError(f"Unsafe path: path traversal {member_path}") + except ValueError as e: + logger.warning(f"Unsafe path guard: Out-of-bounds path traversal blocked due to drive mismatch or invalid paths: {member_path}") + raise ValueError(f"Unsafe path: path traversal {member_path}") from e return full_path From f497d1c1ea8e160c1125d7b7a3fdb5db1491cafb Mon Sep 17 00:00:00 2001 From: VedhSontha Date: Sun, 28 Jun 2026 02:46:37 +0530 Subject: [PATCH 7/8] feat(pathology): raise descriptive KeyError with available batch keys if extra_keys mismatch in PrepareBatchHoVerNet --- monai/apps/pathology/engines/utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/monai/apps/pathology/engines/utils.py b/monai/apps/pathology/engines/utils.py index 87ca0f8e76..c9bdebef53 100644 --- a/monai/apps/pathology/engines/utils.py +++ b/monai/apps/pathology/engines/utils.py @@ -51,6 +51,15 @@ def __call__( https://pytorch.org/ignite/v0.4.8/generated/ignite.engine.create_supervised_trainer.html. `kwargs` supports other args for `Tensor.to()` API. """ + # Validate that all extra_keys exist in batchdata to provide a helpful error message + if isinstance(self.prepare_batch.extra_keys, (list, tuple)): + for key in self.prepare_batch.extra_keys: + if key not in batchdata: + raise KeyError( + f"PrepareBatchHoVerNet: extra_key '{key}' not found in batchdata. " + f"Available keys are: {list(batchdata.keys())}" + ) + image, _label, extra_label, _ = self.prepare_batch(batchdata, device, non_blocking, **kwargs) label = {HoVerNetBranch.NP: _label, HoVerNetBranch.NC: extra_label[0], HoVerNetBranch.HV: extra_label[1]} From 3d598954fb396b19e44376226c25850adc2dba29 Mon Sep 17 00:00:00 2001 From: VedhSontha Date: Sun, 28 Jun 2026 02:46:57 +0530 Subject: [PATCH 8/8] fix(apps): clean up partially downloaded files on exception to prevent subsequent run corruption --- monai/apps/utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/monai/apps/utils.py b/monai/apps/utils.py index 487e5b65a3..d92b543df1 100644 --- a/monai/apps/utils.py +++ b/monai/apps/utils.py @@ -119,6 +119,11 @@ def update_to(self, b: int = 1, bsize: int = 1, tsize: int | None = None) -> Non urlretrieve(url, filepath) except (URLError, HTTPError, ContentTooShortError, OSError) as e: logger.error(f"Download failed from {url} to {filepath}.") + if filepath.exists(): + try: + filepath.unlink() + except OSError: + pass raise e