-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Validate downloaded file integrity and raise ValueError on hash mismatch #8833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
e-mny
wants to merge
31
commits into
Project-MONAI:dev
Choose a base branch
from
e-mny:8832-hashvalueerror
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+71
−82
Open
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
d02da49
Refactor download_url test into TestDownloadUrl class
e-mny 867287e
Merge branch 'dev' into dev
e-mny 6296784
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 8a6b6f6
Enoch Mok <enochmokny@gmail.com>
e-mny 66af964
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 43f508b
Merge branch 'dev' into dev
e-mny 834a8b4
Validate downloaded file integrity and raise ValueError on hash mismatch
e-mny 51a49b0
Validate downloaded file integrity and raise ValueError on hash misma…
e-mny 98ccecc
amended runtimeerrors -> valueerror
e-mny 29ec2fe
fixing code according to coderabbitai
e-mny 606e7ca
addressed bugs and issues raised in previous commit
e-mny 19ce7f6
Merge branch 'dev' into 8832-hashvalueerror
ericspod 88f8372
addressing Eric's comment in https://github.com/Project-MONAI/MONAI/p…
e-mny e9861d3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] 453b238
removing duplicate line
e-mny 64b8e09
removed duplicated tests for download_url. updated existing tests for…
e-mny 096c6d0
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] e8e1f4b
Merge branch 'dev' into 8832-hashvalueerror
ericspod 5b0b5f4
Apply suggestions from code review
e-mny 3c61005
removed hash checking in a function that checks for download fails
e-mny 44f4d10
Merge branch 'dev' into 8832-hashvalueerror
e-mny 126fcc3
Merge branch 'dev' into 8832-hashvalueerror
ericspod 8974b4d
added HashCheckError
e-mny 1878f5a
Merge branch 'dev' into 8832-hashvalueerror
e-mny 955a3ae
already ran autofix
e-mny 88fb658
can't seem to format this file and pass autofix test
e-mny a85be67
Merge branch 'dev' into 8832-hashvalueerror
e-mny 625cabf
Merge branch 'dev' into 8832-hashvalueerror
e-mny 9d6b2a8
changed to python3.10 to mimic tests' python's version
e-mny fd90b83
Merge branch 'dev' into 8832-hashvalueerror
e-mny 2a02f55
Merge branch 'dev' into 8832-hashvalueerror
ericspod File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -48,6 +48,10 @@ | |||||
| SUPPORTED_HASH_TYPES = {"md5": hashlib.md5, "sha1": hashlib.sha1, "sha256": hashlib.sha256, "sha512": hashlib.sha512} | ||||||
|
|
||||||
|
|
||||||
| class HashCheckError(ValueError): | ||||||
| pass | ||||||
|
|
||||||
|
|
||||||
| def get_logger( | ||||||
| module_name: str = "monai.apps", | ||||||
| fmt: str = DEFAULT_FMT, | ||||||
|
|
@@ -220,18 +224,15 @@ def download_url( | |||||
| HTTPError: See urllib.request.urlretrieve. | ||||||
| ContentTooShortError: See urllib.request.urlretrieve. | ||||||
| IOError: See urllib.request.urlretrieve. | ||||||
| RuntimeError: When the hash validation of the ``url`` downloaded file fails. | ||||||
|
|
||||||
| ValueError: When the hash validation of the ``url`` downloaded file fails. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| """ | ||||||
| if not filepath: | ||||||
| filepath = Path(".", _basename(url)).resolve() | ||||||
| logger.info(f"Default downloading to '{filepath}'") | ||||||
| filepath = Path(filepath) | ||||||
| if filepath.exists(): | ||||||
| if not check_hash(filepath, hash_val, hash_type): | ||||||
| raise RuntimeError( | ||||||
| f"{hash_type} check of existing file failed: filepath={filepath}, expected {hash_type}={hash_val}." | ||||||
| ) | ||||||
| raise HashCheckError(f"{hash_type} hash check of existing file failed: {filepath=}, expected {hash_type=}.") | ||||||
| logger.info(f"File exists: {filepath}, skipped downloading.") | ||||||
| return | ||||||
| try: | ||||||
|
|
@@ -260,18 +261,20 @@ def download_url( | |||||
| raise RuntimeError( | ||||||
| f"Download of file from {url} to {filepath} failed due to network issue or denied permission." | ||||||
| ) | ||||||
| if not check_hash(tmp_name, hash_val, hash_type): | ||||||
| raise HashCheckError( | ||||||
| f"{hash_type} hash check of downloaded file failed: {url=}, " | ||||||
| f"{filepath=}, expected {hash_type}={hash_val}, " | ||||||
| f"The file may be corrupted or tampered with. " | ||||||
| "Please retry the download or verify the source." | ||||||
| ) | ||||||
| file_dir = filepath.parent | ||||||
| if file_dir: | ||||||
| os.makedirs(file_dir, exist_ok=True) | ||||||
| shutil.move(f"{tmp_name}", f"{filepath}") # copy the downloaded to a user-specified cache. | ||||||
| except (PermissionError, NotADirectoryError): # project-monai/monai issue #3613 #3757 for windows | ||||||
| pass | ||||||
| logger.info(f"Downloaded: {filepath}") | ||||||
| if not check_hash(filepath, hash_val, hash_type): | ||||||
| raise RuntimeError( | ||||||
| f"{hash_type} check of downloaded file failed: URL={url}, " | ||||||
| f"filepath={filepath}, expected {hash_type}={hash_val}." | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| def _extract_zip(filepath, output_dir): | ||||||
|
|
@@ -325,10 +328,15 @@ def extractall( | |||||
| be False. | ||||||
|
|
||||||
| Raises: | ||||||
| RuntimeError: When the hash validation of the ``filepath`` compressed file fails. | ||||||
| ValueError: When the hash validation of the ``filepath`` compressed file fails. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| NotImplementedError: When the ``filepath`` file extension is not one of [zip", "tar.gz", "tar"]. | ||||||
|
|
||||||
| """ | ||||||
| filepath = Path(filepath) | ||||||
| if hash_val and not check_hash(filepath, hash_val, hash_type): | ||||||
| raise HashCheckError( | ||||||
| f"{hash_type} hash check of compressed file failed: " f"{filepath=}, expected {hash_type}={hash_val}." | ||||||
| ) | ||||||
| if has_base: | ||||||
| # the extracted files will be in this folder | ||||||
| cache_dir = Path(output_dir, _basename(filepath).split(".")[0]) | ||||||
|
|
@@ -337,11 +345,6 @@ def extractall( | |||||
| if cache_dir.exists() and next(cache_dir.iterdir(), None) is not None: | ||||||
| logger.info(f"Non-empty folder exists in {cache_dir}, skipped extracting.") | ||||||
| return | ||||||
| filepath = Path(filepath) | ||||||
| if hash_val and not check_hash(filepath, hash_val, hash_type): | ||||||
| raise RuntimeError( | ||||||
| f"{hash_type} check of compressed file failed: " f"filepath={filepath}, expected {hash_type}={hash_val}." | ||||||
| ) | ||||||
| logger.info(f"Writing into directory: {output_dir}.") | ||||||
| _file_type = file_type.lower().strip() | ||||||
| if filepath.name.endswith("zip") or _file_type == "zip": | ||||||
|
|
||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -16,7 +16,6 @@ | |||||
| import unittest | ||||||
| import zipfile | ||||||
| from pathlib import Path | ||||||
| from urllib.error import ContentTooShortError, HTTPError | ||||||
|
|
||||||
| from parameterized import parameterized | ||||||
|
|
||||||
|
|
@@ -26,39 +25,62 @@ | |||||
|
|
||||||
| @SkipIfNoModule("requests") | ||||||
| class TestDownloadAndExtract(unittest.TestCase): | ||||||
| def setUp(self): | ||||||
| self.testing_dir = Path(__file__).parents[1] / "testing_data" | ||||||
| self.config = testing_data_config("images", "mednist") | ||||||
| self.url = self.config["url"] | ||||||
| self.hash_val = self.config["hash_val"] | ||||||
| self.hash_type = self.config["hash_type"] | ||||||
|
|
||||||
| @skip_if_quick | ||||||
| def test_actions(self): | ||||||
| testing_dir = Path(__file__).parents[1] / "testing_data" | ||||||
| config_dict = testing_data_config("images", "mednist") | ||||||
| url = config_dict["url"] | ||||||
| filepath = Path(testing_dir) / "MedNIST.tar.gz" | ||||||
| output_dir = Path(testing_dir) | ||||||
| hash_val, hash_type = config_dict["hash_val"], config_dict["hash_type"] | ||||||
| def test_download_and_extract_success(self): | ||||||
| """End-to-end: download and extract should succeed with correct hash.""" | ||||||
| filepath = self.testing_dir / "MedNIST.tar.gz" | ||||||
| output_dir = self.testing_dir | ||||||
|
|
||||||
| with skip_if_downloading_fails(): | ||||||
| download_and_extract(url, filepath, output_dir, hash_val=hash_val, hash_type=hash_type) | ||||||
| download_and_extract(url, filepath, output_dir, hash_val=hash_val, hash_type=hash_type) | ||||||
| download_and_extract(self.url, filepath, output_dir, hash_val=self.hash_val, hash_type=self.hash_type) | ||||||
|
|
||||||
| wrong_md5 = "0" | ||||||
| with self.assertLogs(logger="monai.apps", level="ERROR"): | ||||||
| try: | ||||||
| download_url(url, filepath, wrong_md5) | ||||||
| except (ContentTooShortError, HTTPError, RuntimeError) as e: | ||||||
| if isinstance(e, RuntimeError): | ||||||
| # FIXME: skip MD5 check as current downloading method may fail | ||||||
| self.assertTrue(str(e).startswith("md5 check")) | ||||||
| return # skipping this test due the network connection errors | ||||||
|
|
||||||
| try: | ||||||
| extractall(filepath, output_dir, wrong_md5) | ||||||
| except RuntimeError as e: | ||||||
| self.assertTrue(str(e).startswith("md5 check")) | ||||||
| self.assertTrue(filepath.exists(), "Downloaded file does not exist") | ||||||
| self.assertTrue(any(output_dir.iterdir()), "Extraction output is empty") | ||||||
|
|
||||||
| @skip_if_quick | ||||||
| def test_download_url_hash_mismatch(self): | ||||||
| """download_url should raise ValueError on hash mismatch.""" | ||||||
| filepath = self.testing_dir / "MedNIST.tar.gz" | ||||||
|
|
||||||
| with skip_if_downloading_fails(): | ||||||
| # First ensure file is downloaded correctly | ||||||
| download_url(self.url, filepath, hash_val=self.hash_val, hash_type=self.hash_type) | ||||||
|
|
||||||
| # Now test incorrect hash | ||||||
| with self.assertRaises(ValueError) as ctx: | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| download_url(self.url, filepath, hash_val="0" * len(self.hash_val), hash_type=self.hash_type) | ||||||
|
|
||||||
| self.assertIn("hash check", str(ctx.exception).lower()) | ||||||
|
|
||||||
| @skip_if_quick | ||||||
| @parameterized.expand((("icon", "tar"), ("favicon", "zip"))) | ||||||
| def test_default(self, key, file_type): | ||||||
| def test_extractall_hash_mismatch(self): | ||||||
| """extractall should raise ValueError when hash is incorrect.""" | ||||||
| filepath = self.testing_dir / "MedNIST.tar.gz" | ||||||
| output_dir = self.testing_dir | ||||||
|
|
||||||
| with skip_if_downloading_fails(): | ||||||
| download_url(self.url, filepath, hash_val=self.hash_val, hash_type=self.hash_type) | ||||||
|
|
||||||
| with self.assertRaises(ValueError) as ctx: | ||||||
| extractall(filepath, output_dir, hash_val="0" * len(self.hash_val), hash_type=self.hash_type) | ||||||
|
|
||||||
| self.assertIn("hash check", str(ctx.exception).lower()) | ||||||
|
|
||||||
| @skip_if_quick | ||||||
| @parameterized.expand([("icon", "tar"), ("favicon", "zip")]) | ||||||
| def test_download_and_extract_various_formats(self, key, file_type): | ||||||
| """Verify different archive formats download and extract correctly.""" | ||||||
| with tempfile.TemporaryDirectory() as tmp_dir: | ||||||
| img_spec = testing_data_config("images", key) | ||||||
|
|
||||||
| with skip_if_downloading_fails(): | ||||||
| img_spec = testing_data_config("images", key) | ||||||
| download_and_extract( | ||||||
| img_spec["url"], | ||||||
| output_dir=tmp_dir, | ||||||
|
|
@@ -67,6 +89,8 @@ def test_default(self, key, file_type): | |||||
| file_type=file_type, | ||||||
| ) | ||||||
|
|
||||||
| self.assertTrue(any(Path(tmp_dir).iterdir()), f"Extraction failed for format: {file_type}") | ||||||
|
|
||||||
|
|
||||||
| class TestPathTraversalProtection(unittest.TestCase): | ||||||
| """Test cases for path traversal attack protection in extractall function.""" | ||||||
|
|
||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This class should be added to all in this file and imported in the monai/apps/init.py file.