-
Notifications
You must be signed in to change notification settings - Fork 0
🛡️ Sentinel: [HIGH] Fix Zip Slip in Addon Export #524
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
Changes from all commits
64314b1
4fc0a24
de1bb10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,22 @@ | |
|
|
||
| from models import addon_models as pydantic_addon_models # For type hinting with Pydantic models | ||
|
|
||
| def _sanitize_filename(filename: str) -> str: | ||
| """ | ||
| Sanitizes a filename to prevent path traversal and remove dangerous characters. | ||
| """ | ||
| if not filename: | ||
| return "default_filename" | ||
| # Remove directory paths | ||
| filename = os.path.basename(filename) | ||
| # Remove dangerous characters | ||
| # Allow alphanumeric, dot, underscore, hyphen | ||
| filename = "".join(c for c in filename if c.isalnum() or c in "._-") | ||
| # Ensure not empty | ||
| if not filename: | ||
| return "default_sanitized_filename" | ||
| return filename | ||
|
|
||
| # Constants for manifest versions, can be updated as needed | ||
| MIN_ENGINE_VERSION_RP = [1, 16, 0] | ||
| MIN_ENGINE_VERSION_BP = [1, 16, 0] | ||
|
|
@@ -171,8 +187,9 @@ def generate_terrain_texture_json(addon_assets: List[pydantic_addon_models.Addon | |
| # asset.path might be "textures/blocks/my_block.png" (if stored with this intent) | ||
| # or we derive from original_filename. | ||
|
|
||
| texture_name = os.path.splitext(asset.original_filename)[0] if asset.original_filename else \ | ||
| os.path.splitext(os.path.basename(asset.path))[0] | ||
| original_name = _sanitize_filename(asset.original_filename) if asset.original_filename else \ | ||
| _sanitize_filename(os.path.basename(asset.path)) | ||
| texture_name = os.path.splitext(original_name)[0] | ||
|
Comment on lines
188
to
+192
|
||
|
|
||
| # Path in terrain_texture.json is relative to the "textures" folder of the RP | ||
| # e.g., "textures/blocks/my_custom_block_texture" | ||
|
|
@@ -245,11 +262,14 @@ def generate_sounds_json(sound_assets: List[pydantic_addon_models.AddonAsset]) - | |
|
|
||
| for asset in sound_assets: | ||
| # Extract sound name from original_filename (without extension) | ||
| sound_name = os.path.splitext(asset.original_filename)[0] if asset.original_filename else \ | ||
| os.path.splitext(os.path.basename(asset.path))[0] | ||
| original_name = _sanitize_filename(asset.original_filename) if asset.original_filename else \ | ||
| _sanitize_filename(os.path.basename(asset.path)) | ||
| sound_name = os.path.splitext(original_name)[0] | ||
|
|
||
| # Get the sound file path relative to sounds folder | ||
| sound_path = asset.original_filename if asset.original_filename else os.path.basename(asset.path) | ||
| # Note: In Bedrock resource packs, the "name" in sounds.json is typically the path without extension, | ||
| # relative to the root of the RP (but usually matching the folder structure). | ||
| # We'll stick to the previous logic but with sanitized name. | ||
|
|
||
| sounds_data[sound_name] = { | ||
| "sounds": [ | ||
|
|
@@ -344,9 +364,9 @@ def create_mcaddon_zip( | |
| zip_texture_path_parts = ["textures", "misc"] | ||
|
|
||
| if asset.original_filename: | ||
| zip_texture_path_parts.append(asset.original_filename) | ||
| zip_texture_path_parts.append(_sanitize_filename(asset.original_filename)) | ||
| else: # Fallback if original_filename is somehow missing | ||
| zip_texture_path_parts.append(os.path.basename(asset.path)) | ||
| zip_texture_path_parts.append(_sanitize_filename(os.path.basename(asset.path))) | ||
|
|
||
| zip_path = os.path.join(rp_folder_name, *zip_texture_path_parts) | ||
|
|
||
|
|
@@ -364,10 +384,9 @@ def create_mcaddon_zip( | |
| asset_disk_path = os.path.join(asset_base_path, str(addon_pydantic.id), asset.path) | ||
|
|
||
| # Determine path within ZIP for RP | ||
| if asset.original_filename: | ||
| zip_sound_path = os.path.join(rp_folder_name, "sounds", asset.original_filename) | ||
| else: | ||
| zip_sound_path = os.path.join(rp_folder_name, "sounds", os.path.basename(asset.path)) | ||
| safe_filename = _sanitize_filename(asset.original_filename) if asset.original_filename else \ | ||
| _sanitize_filename(os.path.basename(asset.path)) | ||
| zip_sound_path = os.path.join(rp_folder_name, "sounds", safe_filename) | ||
|
|
||
| if os.path.exists(asset_disk_path): | ||
| zf.write(asset_disk_path, zip_sound_path) | ||
|
|
@@ -386,10 +405,9 @@ def create_mcaddon_zip( | |
| asset_disk_path = os.path.join(asset_base_path, str(addon_pydantic.id), asset.path) | ||
|
|
||
| # Determine path within ZIP for RP | ||
| if asset.original_filename: | ||
| zip_model_path = os.path.join(rp_folder_name, "models", asset.original_filename) | ||
| else: | ||
| zip_model_path = os.path.join(rp_folder_name, "models", os.path.basename(asset.path)) | ||
| safe_filename = _sanitize_filename(asset.original_filename) if asset.original_filename else \ | ||
| _sanitize_filename(os.path.basename(asset.path)) | ||
| zip_model_path = os.path.join(rp_folder_name, "models", safe_filename) | ||
|
|
||
| if os.path.exists(asset_disk_path): | ||
| zf.write(asset_disk_path, zip_model_path) | ||
|
|
@@ -402,10 +420,9 @@ def create_mcaddon_zip( | |
| for asset in entity_assets: | ||
| asset_disk_path = os.path.join(asset_base_path, str(addon_pydantic.id), asset.path) | ||
|
|
||
| if asset.original_filename: | ||
| zip_entity_path = os.path.join(rp_folder_name, "entity", asset.original_filename) | ||
| else: | ||
| zip_entity_path = os.path.join(rp_folder_name, "entity", os.path.basename(asset.path)) | ||
| safe_filename = _sanitize_filename(asset.original_filename) if asset.original_filename else \ | ||
| _sanitize_filename(os.path.basename(asset.path)) | ||
| zip_entity_path = os.path.join(rp_folder_name, "entity", safe_filename) | ||
|
|
||
| if os.path.exists(asset_disk_path): | ||
| zf.write(asset_disk_path, zip_entity_path) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
|
|
||
| import pytest | ||
| import os | ||
| import io | ||
| import zipfile | ||
| import uuid | ||
| import datetime | ||
| from unittest.mock import MagicMock, patch | ||
| from services import addon_exporter | ||
| from models import addon_models as pydantic_addon_models | ||
|
|
||
| def test_create_mcaddon_zip_prevents_zip_slip(tmp_path): | ||
| """ | ||
| Test that create_mcaddon_zip prevents Zip Slip vulnerability | ||
| by sanitizing asset filenames. | ||
| """ | ||
| # Setup mock data | ||
| mock_addon_id = uuid.uuid4() | ||
| mock_asset_base_path = str(tmp_path / "addon_assets") | ||
| mock_addon_asset_dir = os.path.join(mock_asset_base_path, str(mock_addon_id)) | ||
| os.makedirs(mock_addon_asset_dir, exist_ok=True) | ||
|
|
||
| # Create a dummy asset file on disk | ||
| asset_path_on_disk = f"{uuid.uuid4()}_malicious.txt" | ||
| full_asset_path = os.path.join(mock_addon_asset_dir, asset_path_on_disk) | ||
| with open(full_asset_path, "w") as f: | ||
| f.write("malicious content") | ||
|
|
||
| # Malicious filename attempting traversal | ||
| malicious_filename = "../../../../malicious.txt" | ||
|
|
||
|
Comment on lines
+29
to
+31
|
||
| mock_addon = pydantic_addon_models.AddonDetails( | ||
| id=mock_addon_id, | ||
| name="Malicious Addon", | ||
| description="Testing Zip Slip", | ||
| user_id="hacker", | ||
| created_at=datetime.datetime.now(), | ||
| updated_at=datetime.datetime.now(), | ||
| blocks=[], | ||
| assets=[ | ||
| pydantic_addon_models.AddonAsset( | ||
| id=uuid.uuid4(), | ||
| addon_id=mock_addon_id, | ||
| created_at=datetime.datetime.now(), | ||
| updated_at=datetime.datetime.now(), | ||
| type="texture_block", # Uses "textures/blocks" path | ||
| path=asset_path_on_disk, | ||
| original_filename=malicious_filename | ||
| ) | ||
| ], | ||
| recipes=[] | ||
| ) | ||
|
|
||
| # Execute the vulnerable function | ||
| zip_buffer = addon_exporter.create_mcaddon_zip(mock_addon, mock_asset_base_path) | ||
|
|
||
| # Verify the vulnerability is prevented | ||
| found_traversal = False | ||
| found_sanitized = False | ||
| with zipfile.ZipFile(zip_buffer, "r") as zf: | ||
| for name in zf.namelist(): | ||
| # Check if any file path contains traversal characters | ||
| if "../" in name or "..\\" in name: | ||
| found_traversal = True | ||
|
|
||
| # Check if the sanitized version exists (malicious.txt inside textures/blocks) | ||
| if "malicious.txt" in name and not ("../" in name): | ||
| found_sanitized = True | ||
|
|
||
| # After the fix, we expect NO traversal and YES sanitized file | ||
| assert not found_traversal, "Vulnerability persisted: Traversal path found in zip" | ||
| assert found_sanitized, "Sanitized file not found in zip" | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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.
_sanitize_filenamestill allows dangerous path segments like.and..because dots are allowlisted. Iffilenameis..(or sanitizes down to..),os.path.join(..., "..")will produce ZIP entries containing../after normalization by many extractors, so Zip Slip is still possible. Consider explicitly rejecting./..(and optionally any name that becomes only dots) after sanitization and falling back to a safe generated name.