From 5d2f672c2757ff956393f4607546ba18e3aec6a5 Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 12:44:27 +0100 Subject: [PATCH 1/9] fix: confine datastore write paths to prevent path traversal User-supplied image/label IDs and label tags are concatenated into filesystem paths by LocalDatastore.add_image() and save_label() without validation. Resolve each destination and require it to stay inside the datastore image/labels directory, and sanitize the image ID at the /datastore/image endpoint by stripping directory components. Adds regression tests covering traversal via image ID, label tag, and label image ID. Signed-off-by: R. Garcia-Dias --- monailabel/datastore/local.py | 22 +++++- monailabel/endpoints/datastore.py | 6 ++ .../test_local_datastore_path_traversal.py | 76 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/unit/datastore/test_local_datastore_path_traversal.py diff --git a/monailabel/datastore/local.py b/monailabel/datastore/local.py index d8b0538aa..bee08a6dc 100644 --- a/monailabel/datastore/local.py +++ b/monailabel/datastore/local.py @@ -228,6 +228,20 @@ def _to_id(self, file: str) -> Tuple[str, str]: def _filename(self, id: str, ext: str) -> str: return id + ext + @staticmethod + def _confine_path(dest: str, allowed_base: str) -> str: + """Resolve ``dest`` and require it to stay inside ``allowed_base``. + + Guards against path traversal in user-supplied IDs (image/label) that + are concatenated into filesystem paths. Raises :class:`ValueError` if + the resolved destination escapes the allowed directory. + """ + dest = os.path.realpath(dest) + allowed_base = os.path.realpath(allowed_base) + if os.path.commonpath([dest, allowed_base]) != allowed_base: + raise ValueError(f"Path traversal detected: {dest!r} is outside {allowed_base!r}") + return dest + def _to_bytes(self, file): return io.BytesIO(pathlib.Path(file).read_bytes()) @@ -438,7 +452,7 @@ def add_image(self, image_id: str, image_filename: str, image_info: Dict[str, An logger.info(f"Adding Image: {image_id} => {image_filename}") name = self._filename(image_id, image_ext) - dest = os.path.realpath(os.path.join(self._datastore.image_path(), name)) + dest = self._confine_path(os.path.join(self._datastore.image_path(), name), self._datastore.image_path()) with FileLock(self._lock_file): logger.debug("Acquired the lock!") @@ -493,8 +507,12 @@ def save_label(self, image_id: str, label_filename: str, label_tag: str, label_i logger.info(f"Adding Label: {image_id} => {label_tag} => {label_filename}") label_path = self._datastore.label_path(label_tag) + # Confine the label directory itself as well as the final file path, so + # a crafted label tag cannot redirect the write outside the labels dir. + labels_root = self._datastore.label_path(None) + label_path = self._confine_path(label_path, labels_root) name = self._filename(image_id, label_ext) - dest = os.path.join(label_path, name) + dest = self._confine_path(os.path.join(label_path, name), label_path) with FileLock(self._lock_file): logger.debug("Acquired the lock!") diff --git a/monailabel/endpoints/datastore.py b/monailabel/endpoints/datastore.py index fdd63bb6e..b44c960c8 100644 --- a/monailabel/endpoints/datastore.py +++ b/monailabel/endpoints/datastore.py @@ -69,6 +69,12 @@ def add_image( file_ext = "".join(pathlib.Path(file.filename).suffixes) if file.filename else ".nii.gz" image_id = image if image else os.path.basename(file.filename).replace(file_ext, "") + # Sanitize the user-supplied image ID: strip any directory components so it + # cannot be used to write outside the datastore (path traversal). + if image: + image_id = os.path.basename(image_id) + if not image_id or image_id in (".", "..") or image_id.startswith("."): + raise HTTPException(status_code=400, detail="Invalid image ID") image_file = tempfile.NamedTemporaryFile(suffix=file_ext).name with open(image_file, "wb") as buffer: diff --git a/tests/unit/datastore/test_local_datastore_path_traversal.py b/tests/unit/datastore/test_local_datastore_path_traversal.py new file mode 100644 index 000000000..25737f786 --- /dev/null +++ b/tests/unit/datastore/test_local_datastore_path_traversal.py @@ -0,0 +1,76 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import shutil +import tempfile +import unittest + +from monailabel.datastore.local import LocalDatastore + + +class TestLocalDatastorePathTraversal(unittest.TestCase): + """Regression tests for GHSA-cxx7-378q-5622. + + User-supplied image/label IDs and label tags must never be able to write + files outside the datastore image/labels directories. + """ + + def setUp(self): + self.base = tempfile.mkdtemp(prefix="monailabel_ds_traversal_") + self.studies = os.path.join(self.base, "studies") + os.makedirs(self.studies, exist_ok=True) + self.datastore = LocalDatastore(self.studies) + + self.marker = b"ARBITRARY_FILE_WRITE_VIA_PATH_TRAVERSAL" + self.image_payload = os.path.join(self.base, "payload.nii.gz") + with open(self.image_payload, "wb") as f: + f.write(self.marker) + + self.legit_id = "legit" + self.datastore.add_image(self.legit_id, self.image_payload, {}) + + def tearDown(self): + shutil.rmtree(self.base, ignore_errors=True) + + def test_add_image_rejects_traversal_id(self): + outside = os.path.realpath(os.path.join(self.base, "poc_traversal_proof.nii.gz")) + with self.assertRaises(ValueError): + self.datastore.add_image("../../poc_traversal_proof", self.image_payload, {}) + self.assertFalse(os.path.exists(outside)) + + def test_add_image_accepts_legitimate_id(self): + image_id = self.datastore.add_image("good_image", self.image_payload, {}) + self.assertEqual(image_id, "good_image") + self.assertTrue(os.path.exists(os.path.join(self.studies, "good_image.nii.gz"))) + + def test_save_label_rejects_traversal_tag(self): + outside = os.path.realpath(os.path.join(self.base, "labels", "final", "escaped.nii.gz")) + with self.assertRaises(ValueError): + self.datastore.save_label(self.legit_id, self.image_payload, "../../labels/final/escaped", {}) + self.assertFalse(os.path.exists(outside)) + + def test_save_label_rejects_traversal_image_id(self): + # emulate a datastore already poisoned with a traversal object + self.datastore._datastore.objects["../../poison"] = self.datastore._datastore.objects[self.legit_id] + outside = os.path.realpath(os.path.join(self.base, "poison.nii.gz")) + with self.assertRaises(ValueError): + self.datastore.save_label("../../poison", self.image_payload, "final", {}) + self.assertFalse(os.path.exists(outside)) + + def test_save_label_accepts_legitimate_input(self): + label_id = self.datastore.save_label(self.legit_id, self.image_payload, "final", {}) + self.assertEqual(label_id, self.legit_id) + self.assertTrue(os.path.exists(os.path.join(self.studies, "labels", "final", "legit.nii.gz"))) + + +if __name__ == "__main__": + unittest.main() From caa5bfe49adde4eb89c012e52be9b3233a9cfdfa Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 17:36:16 +0100 Subject: [PATCH 2/9] fix: handle cross-drive ValueError in path confinement check Signed-off-by: R. Garcia-Dias --- monailabel/datastore/local.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/monailabel/datastore/local.py b/monailabel/datastore/local.py index bee08a6dc..b4d903988 100644 --- a/monailabel/datastore/local.py +++ b/monailabel/datastore/local.py @@ -238,7 +238,12 @@ def _confine_path(dest: str, allowed_base: str) -> str: """ dest = os.path.realpath(dest) allowed_base = os.path.realpath(allowed_base) - if os.path.commonpath([dest, allowed_base]) != allowed_base: + try: + inside = os.path.commonpath([dest, allowed_base]) == allowed_base + except ValueError: + # e.g. Windows paths on different drives have no common path + inside = False + if not inside: raise ValueError(f"Path traversal detected: {dest!r} is outside {allowed_base!r}") return dest From a77ff170e3f68c6baf42b887c04eee860f062e93 Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 17:37:05 +0100 Subject: [PATCH 3/9] fix(ci): add SimpleITK to dev requirements for unit tests Signed-off-by: R. Garcia-Dias --- requirements-dev.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index cad2f34c4..718959071 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -38,3 +38,4 @@ click>=8.1.2 testresources pre-commit attrs>=22.1.0 +SimpleITK From 6d655d7868c6f9e7f586b5685f216eb745dcf80a Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 18:39:59 +0100 Subject: [PATCH 4/9] fix(ci): resolve pre-existing CI failures - Drop Python 3.9 from CI matrices: highdicom==0.26.1 requires pydicom>=3.0.1, and pydicom 3.0.x requires Python>=3.10 (#1918). - Add SimpleITK and pyOpenSSL>=26.0.0 to requirements.txt: both are runtime imports that were undeclared, breaking the build and docs jobs (#1919). SimpleITK moved out of requirements-dev.txt accordingly. - Document the PyType no-op and link it to #1917. Packaging job (OHIF frontend build) remains broken and is tracked in #1916. Signed-off-by: R. Garcia-Dias --- .github/workflows/pythonapp.yml | 8 +++++--- requirements-dev.txt | 1 - requirements.txt | 2 ++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 52eff10fa..3e3da9fca 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -32,7 +32,7 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v6 - name: Set up Python @@ -54,7 +54,7 @@ jobs: MONAI_ZOO_AUTH_TOKEN: ${{ github.token }} strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v6 - name: Set up Python @@ -71,6 +71,8 @@ jobs: run: | $(pwd)/runtests.sh --clean - name: PyType + # NOTE: pytype is currently a no-op in CI (see issue #1917). The echo is + # intentional until the pre-existing pytype errors are triaged. run: | echo $(pwd)/runtests.sh --pytype - name: Unit Tests @@ -88,7 +90,7 @@ jobs: MONAI_ZOO_AUTH_TOKEN: ${{ github.token }} strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v6 with: diff --git a/requirements-dev.txt b/requirements-dev.txt index 718959071..cad2f34c4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -38,4 +38,3 @@ click>=8.1.2 testresources pre-commit attrs>=22.1.0 -SimpleITK diff --git a/requirements.txt b/requirements.txt index 11f068192..0447df3a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,8 @@ pydicom==3.0.1 highdicom==0.26.1 pynetdicom==2.0.2 pynrrd==1.1.3 +SimpleITK +pyOpenSSL>=26.0.0 numpymaxflow==0.0.7; python_version < "3.13" setuptools>=61 setuptools-scm<8.0.0 From 5a9f93cd45b9723af11b5a3eda39743d74a7d07e Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 18:49:48 +0100 Subject: [PATCH 5/9] fix(test): remove double unlink in test_binary_to_image The test registers addCleanup(os.unlink, result) and then also calls os.unlink(result) at the end, so the cleanup raises FileNotFoundError. Previously masked because the module failed to import (missing SimpleITK, #1919). Signed-off-by: R. Garcia-Dias --- tests/unit/datastore/test_convert.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/unit/datastore/test_convert.py b/tests/unit/datastore/test_convert.py index 81d19b128..d94e38175 100644 --- a/tests/unit/datastore/test_convert.py +++ b/tests/unit/datastore/test_convert.py @@ -264,8 +264,6 @@ def test_binary_to_image(self): count_diff_ratio < 0.01 ), f"Nonzero count changed significantly: {original_nonzero_count} vs {result_nonzero_count}" - os.unlink(result) - def _test_nifti_to_dicom_seg_with_label_info_impl(self, use_itk): """Helper: Test NIfTI to DICOM SEG conversion with custom label info.""" series_dir, label = self._get_test_paths() From 3da00b721fe3e2879e90b0f281007392318bb4bd Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 18:59:22 +0100 Subject: [PATCH 6/9] fix: restore compatibility with MONAI 1.6 in dataset download and NIfTI writer - main.py: DecathlonDataset publishes md5 digests, but MONAI >= 1.6 defaults download_and_extract(hash_type=...) to sha256, so 'monailabel datasets --download' always failed the hash check. Pass hash_type='md5' explicitly. - transform/writer.py: work around a MONAI 1.6 regression where NibabelWriter fails on 2D channel-less arrays (spatial_size=None reaches fall_back_tuple). When no target affine is given the output grid equals the input grid, so passing the input shape explicitly is behaviour-preserving (verified identical output on MONAI 1.5.1). - requirements-dev.txt: add dcmqi (optional at runtime) so the ITK DICOM SEG unit tests can run in CI. Signed-off-by: R. Garcia-Dias --- monailabel/main.py | 3 ++- monailabel/transform/writer.py | 8 +++++++- requirements-dev.txt | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/monailabel/main.py b/monailabel/main.py index 7d2205a7d..e725db27c 100644 --- a/monailabel/main.py +++ b/monailabel/main.py @@ -171,7 +171,8 @@ def action_datasets(self, args): root_dir = os.path.dirname(os.path.realpath(dataset_dir)) os.makedirs(root_dir, exist_ok=True) tarfile_name = f"{dataset_dir}.tar" - download_and_extract(resource[args.name], tarfile_name, root_dir, md5.get(args.name)) + # DecathlonDataset publishes md5 digests; MONAI >= 1.6 defaults hash_type to sha256 + download_and_extract(resource[args.name], tarfile_name, root_dir, md5.get(args.name), hash_type="md5") junk_files = pathlib.Path(dataset_dir).rglob("._*") for j in junk_files: diff --git a/monailabel/transform/writer.py b/monailabel/transform/writer.py index 402e1d17d..38eebbcbd 100644 --- a/monailabel/transform/writer.py +++ b/monailabel/transform/writer.py @@ -30,7 +30,13 @@ def write_nifti(data, filename, affine=None, original_affine=None, output_dtype=np.float32): writer = NibabelWriter(output_dtype=output_dtype) writer.set_data_array(data, channel_dim=None, spatial_ndim=None) - writer.set_metadata({"affine": affine, "original_affine": original_affine}) + meta = {"affine": affine, "original_affine": original_affine} + if original_affine is None and np.ndim(data) == 2: + # Without a target affine the output keeps the input grid, so the spatial shape is + # the input shape. Passing it explicitly works around a MONAI 1.6 regression where + # 2D channel-less arrays fail to resample (spatial_size=None reaches fall_back_tuple). + meta["spatial_shape"] = np.asarray(np.shape(data)) + writer.set_metadata(meta) writer.write(filename) diff --git a/requirements-dev.txt b/requirements-dev.txt index cad2f34c4..5fbfbab4b 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -38,3 +38,5 @@ click>=8.1.2 testresources pre-commit attrs>=22.1.0 +# optional at runtime (see requirements.txt); needed by the ITK DICOM SEG unit tests +dcmqi From 5021250ff6d7bd11a737100364aa5f920dc6770c Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 19:18:54 +0100 Subject: [PATCH 7/9] test: xfail graphcut inferer tests on MONAI >= 1.6 (Spacingd regression) MONAI 1.6 Spacingd calls .update() on every '_*' entry, so the string 'image_path' that MONAI Label adds to every request raises. Tracked upstream in Project-MONAI/MONAI#9165 with a fix in Project-MONAI/MONAI#9167. Non-strict xfail so a fixed MONAI reports XPASS instead of failing. Signed-off-by: R. Garcia-Dias --- tests/unit/scribbles/test_transforms_infer.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/scribbles/test_transforms_infer.py b/tests/unit/scribbles/test_transforms_infer.py index 4f693acd0..5b047561f 100644 --- a/tests/unit/scribbles/test_transforms_infer.py +++ b/tests/unit/scribbles/test_transforms_infer.py @@ -12,8 +12,10 @@ import unittest import numpy as np +import pytest +from monai import __version__ as monai_version from monai.transforms import LoadImage -from monai.utils import set_determinism +from monai.utils import set_determinism, version_geq from parameterized import parameterized from monailabel.scribbles.infer import HistogramBasedGraphCut @@ -388,6 +390,12 @@ def test_soften_prob_softmax_transforms(self): class TestScribblesInferers(unittest.TestCase): @parameterized.expand(TEST_CASE_HISTOGRAM_GRAPHCUT) + @pytest.mark.xfail( + version_geq(monai_version, "1.6"), + reason="MONAI >= 1.6 Spacingd regression on '_*' non-dict entries such as image_path " + "(Project-MONAI/MONAI#9165, fix in Project-MONAI/MONAI#9167)", + strict=False, + ) @unittest.skipUnless(has_numpymaxflow, "numpymaxflow is not installed") def test_histogram_graphcut_inferer(self, test_input, expected_shape): test_input.update({"image_path": "fakepath.nii"}) From 28ab20158bd81977c00172e1751a18ae6028aea7 Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 19:59:21 +0100 Subject: [PATCH 8/9] test: rename datastore path tests and tidy wording Signed-off-by: R. Garcia-Dias --- monailabel/datastore/local.py | 8 +- monailabel/endpoints/datastore.py | 4 +- .../test_local_datastore_path_confinement.py | 71 +++++++++++++++++ .../test_local_datastore_path_traversal.py | 76 ------------------- 4 files changed, 77 insertions(+), 82 deletions(-) create mode 100644 tests/unit/datastore/test_local_datastore_path_confinement.py delete mode 100644 tests/unit/datastore/test_local_datastore_path_traversal.py diff --git a/monailabel/datastore/local.py b/monailabel/datastore/local.py index b4d903988..aafcb1ca5 100644 --- a/monailabel/datastore/local.py +++ b/monailabel/datastore/local.py @@ -232,9 +232,9 @@ def _filename(self, id: str, ext: str) -> str: def _confine_path(dest: str, allowed_base: str) -> str: """Resolve ``dest`` and require it to stay inside ``allowed_base``. - Guards against path traversal in user-supplied IDs (image/label) that - are concatenated into filesystem paths. Raises :class:`ValueError` if - the resolved destination escapes the allowed directory. + IDs and tags are concatenated into filesystem paths, so the resolved + destination is checked before writing. Raises :class:`ValueError` if + it is not inside the allowed directory. """ dest = os.path.realpath(dest) allowed_base = os.path.realpath(allowed_base) @@ -244,7 +244,7 @@ def _confine_path(dest: str, allowed_base: str) -> str: # e.g. Windows paths on different drives have no common path inside = False if not inside: - raise ValueError(f"Path traversal detected: {dest!r} is outside {allowed_base!r}") + raise ValueError(f"Invalid path: {dest!r} is not inside {allowed_base!r}") return dest def _to_bytes(self, file): diff --git a/monailabel/endpoints/datastore.py b/monailabel/endpoints/datastore.py index b44c960c8..9fa67df88 100644 --- a/monailabel/endpoints/datastore.py +++ b/monailabel/endpoints/datastore.py @@ -69,8 +69,8 @@ def add_image( file_ext = "".join(pathlib.Path(file.filename).suffixes) if file.filename else ".nii.gz" image_id = image if image else os.path.basename(file.filename).replace(file_ext, "") - # Sanitize the user-supplied image ID: strip any directory components so it - # cannot be used to write outside the datastore (path traversal). + # Normalize a caller-supplied image ID to a plain file name + # (no directory components) before it is used to build a path. if image: image_id = os.path.basename(image_id) if not image_id or image_id in (".", "..") or image_id.startswith("."): diff --git a/tests/unit/datastore/test_local_datastore_path_confinement.py b/tests/unit/datastore/test_local_datastore_path_confinement.py new file mode 100644 index 000000000..756f4a6be --- /dev/null +++ b/tests/unit/datastore/test_local_datastore_path_confinement.py @@ -0,0 +1,71 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import shutil +import tempfile +import unittest + +from monailabel.datastore.local import LocalDatastore + + +class TestLocalDatastorePathConfinement(unittest.TestCase): + """Image/label IDs and label tags must resolve inside the datastore image/labels directories.""" + + def setUp(self): + self.base = tempfile.mkdtemp(prefix="monailabel_ds_confine_") + self.studies = os.path.join(self.base, "studies") + os.makedirs(self.studies, exist_ok=True) + self.datastore = LocalDatastore(self.studies) + + self.image_file = os.path.join(self.base, "image.nii.gz") + with open(self.image_file, "wb") as f: + f.write(b"image") + + self.image_id = "image_1" + self.datastore.add_image(self.image_id, self.image_file, {}) + + def tearDown(self): + shutil.rmtree(self.base, ignore_errors=True) + + def test_add_image_rejects_id_outside_image_dir(self): + outside = os.path.realpath(os.path.join(self.base, "other.nii.gz")) + with self.assertRaises(ValueError): + self.datastore.add_image("../../other", self.image_file, {}) + self.assertFalse(os.path.exists(outside)) + + def test_add_image_accepts_plain_id(self): + image_id = self.datastore.add_image("image_2", self.image_file, {}) + self.assertEqual(image_id, "image_2") + self.assertTrue(os.path.exists(os.path.join(self.studies, "image_2.nii.gz"))) + + def test_save_label_rejects_tag_outside_labels_dir(self): + outside = os.path.realpath(os.path.join(self.base, "labels", "final", "other.nii.gz")) + with self.assertRaises(ValueError): + self.datastore.save_label(self.image_id, self.image_file, "../../labels/final/other", {}) + self.assertFalse(os.path.exists(outside)) + + def test_save_label_rejects_image_id_outside_labels_dir(self): + # an existing datastore entry whose ID does not resolve inside the labels directory + self.datastore._datastore.objects["../../other"] = self.datastore._datastore.objects[self.image_id] + outside = os.path.realpath(os.path.join(self.base, "other.nii.gz")) + with self.assertRaises(ValueError): + self.datastore.save_label("../../other", self.image_file, "final", {}) + self.assertFalse(os.path.exists(outside)) + + def test_save_label_accepts_plain_input(self): + label_id = self.datastore.save_label(self.image_id, self.image_file, "final", {}) + self.assertEqual(label_id, self.image_id) + self.assertTrue(os.path.exists(os.path.join(self.studies, "labels", "final", "image_1.nii.gz"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/datastore/test_local_datastore_path_traversal.py b/tests/unit/datastore/test_local_datastore_path_traversal.py deleted file mode 100644 index 25737f786..000000000 --- a/tests/unit/datastore/test_local_datastore_path_traversal.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) MONAI Consortium -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# http://www.apache.org/licenses/LICENSE-2.0 -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import shutil -import tempfile -import unittest - -from monailabel.datastore.local import LocalDatastore - - -class TestLocalDatastorePathTraversal(unittest.TestCase): - """Regression tests for GHSA-cxx7-378q-5622. - - User-supplied image/label IDs and label tags must never be able to write - files outside the datastore image/labels directories. - """ - - def setUp(self): - self.base = tempfile.mkdtemp(prefix="monailabel_ds_traversal_") - self.studies = os.path.join(self.base, "studies") - os.makedirs(self.studies, exist_ok=True) - self.datastore = LocalDatastore(self.studies) - - self.marker = b"ARBITRARY_FILE_WRITE_VIA_PATH_TRAVERSAL" - self.image_payload = os.path.join(self.base, "payload.nii.gz") - with open(self.image_payload, "wb") as f: - f.write(self.marker) - - self.legit_id = "legit" - self.datastore.add_image(self.legit_id, self.image_payload, {}) - - def tearDown(self): - shutil.rmtree(self.base, ignore_errors=True) - - def test_add_image_rejects_traversal_id(self): - outside = os.path.realpath(os.path.join(self.base, "poc_traversal_proof.nii.gz")) - with self.assertRaises(ValueError): - self.datastore.add_image("../../poc_traversal_proof", self.image_payload, {}) - self.assertFalse(os.path.exists(outside)) - - def test_add_image_accepts_legitimate_id(self): - image_id = self.datastore.add_image("good_image", self.image_payload, {}) - self.assertEqual(image_id, "good_image") - self.assertTrue(os.path.exists(os.path.join(self.studies, "good_image.nii.gz"))) - - def test_save_label_rejects_traversal_tag(self): - outside = os.path.realpath(os.path.join(self.base, "labels", "final", "escaped.nii.gz")) - with self.assertRaises(ValueError): - self.datastore.save_label(self.legit_id, self.image_payload, "../../labels/final/escaped", {}) - self.assertFalse(os.path.exists(outside)) - - def test_save_label_rejects_traversal_image_id(self): - # emulate a datastore already poisoned with a traversal object - self.datastore._datastore.objects["../../poison"] = self.datastore._datastore.objects[self.legit_id] - outside = os.path.realpath(os.path.join(self.base, "poison.nii.gz")) - with self.assertRaises(ValueError): - self.datastore.save_label("../../poison", self.image_payload, "final", {}) - self.assertFalse(os.path.exists(outside)) - - def test_save_label_accepts_legitimate_input(self): - label_id = self.datastore.save_label(self.legit_id, self.image_payload, "final", {}) - self.assertEqual(label_id, self.legit_id) - self.assertTrue(os.path.exists(os.path.join(self.studies, "labels", "final", "legit.nii.gz"))) - - -if __name__ == "__main__": - unittest.main() From 78ae7d2a8f611983ad38337480c25bb39eb20e35 Mon Sep 17 00:00:00 2001 From: "R. Garcia-Dias" Date: Mon, 12 Oct 2026 20:26:58 +0100 Subject: [PATCH 9/9] fix(packaging): declare SimpleITK, pyOpenSSL and huggingface_hub in install_requires The wheel takes its dependencies from setup.cfg, not requirements.txt, so the installed package was missing SimpleITK (imported by datastore/utils/convert.py) and the server failed to start in the packaging job. huggingface_hub is needed at runtime because MONAI's default 'monaihosting' bundle source downloads through it, which the pathology/endoscopy/monaibundle apps do at startup. Signed-off-by: R. Garcia-Dias --- requirements.txt | 1 + setup.cfg | 3 +++ 2 files changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 0447df3a8..137703594 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,6 +30,7 @@ pynetdicom==2.0.2 pynrrd==1.1.3 SimpleITK pyOpenSSL>=26.0.0 +huggingface_hub numpymaxflow==0.0.7; python_version < "3.13" setuptools>=61 setuptools-scm<8.0.0 diff --git a/setup.cfg b/setup.cfg index 0801de599..a1a846106 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,6 +54,9 @@ install_requires = highdicom>=0.26.1 pynetdicom>=2.0.2 pynrrd>=1.1.3 + SimpleITK + pyOpenSSL>=26.0.0 + huggingface_hub girder-client>=3.2.3 ninja>=1.11.1.1 einops>=0.7.0