diff --git a/src/launchpad/artifacts/android/aab.py b/src/launchpad/artifacts/android/aab.py index d2d69fbd..332c191b 100644 --- a/src/launchpad/artifacts/android/aab.py +++ b/src/launchpad/artifacts/android/aab.py @@ -19,7 +19,7 @@ from launchpad.utils.logging import get_logger from ..artifact import AndroidArtifact -from ..providers.zip_provider import ZipProvider +from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path from .apk import APK from .manifest.manifest import AndroidManifest from .manifest.proto_xml import ProtoXmlUtils @@ -156,7 +156,11 @@ def get_app_icon(self) -> bytes | None: logger.info("No icon path found in manifest") return None - icon_path = self._extract_dir / "base" / icon_path_str + base_dir = self._extract_dir / "base" + if not is_safe_path(base_dir, icon_path_str): + raise UnsafePathError(f"Unsafe icon path in manifest: {icon_path_str}") + + icon_path = base_dir / icon_path_str if not icon_path.exists(): logger.info(f"Icon not found in AAB: {icon_path_str}") @@ -175,6 +179,8 @@ def get_app_icon(self) -> bytes | None: logger.info(f"Could not process XML drawable for icon: {icon_path_str}") return None + except UnsafePathError: + raise except Exception: logger.exception(f"Error processing XML drawable for icon: {icon_path_str}") return None diff --git a/src/launchpad/artifacts/android/apk.py b/src/launchpad/artifacts/android/apk.py index aace6779..436180f8 100644 --- a/src/launchpad/artifacts/android/apk.py +++ b/src/launchpad/artifacts/android/apk.py @@ -18,7 +18,7 @@ from ...parsers.android.dex.types import ClassDefinition from ...utils.logging import get_logger from ..artifact import AndroidArtifact -from ..providers.zip_provider import ZipProvider +from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path from .manifest.axml import AxmlUtils from .manifest.manifest import AndroidManifest from .resources.binary import BinaryResourceTable @@ -128,6 +128,9 @@ def get_app_icon(self) -> bytes | None: logger.info("No icon path found in manifest") return None + if not is_safe_path(self._extract_dir, icon_path_str): + raise UnsafePathError(f"Unsafe icon path in manifest: {icon_path_str}") + icon_path = self._extract_dir / icon_path_str if not icon_path.exists(): @@ -147,6 +150,8 @@ def get_app_icon(self) -> bytes | None: logger.info(f"Could not process XML drawable for icon: {icon_path_str}") return None + except UnsafePathError: + raise except Exception: logger.exception(f"Error processing XML drawable for icon: {icon_path_str}") return None diff --git a/src/launchpad/artifacts/apple/zipped_xcarchive.py b/src/launchpad/artifacts/apple/zipped_xcarchive.py index f1ee7cdf..608daa31 100644 --- a/src/launchpad/artifacts/apple/zipped_xcarchive.py +++ b/src/launchpad/artifacts/apple/zipped_xcarchive.py @@ -17,7 +17,7 @@ from launchpad.utils.logging import get_logger from ..artifact import AppleArtifact -from ..providers.zip_provider import ZipProvider +from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path logger = get_logger(__name__) @@ -130,6 +130,9 @@ def get_app_icon(self) -> bytes | None: app_bundle_path = self.get_app_bundle_path() for icon_name in icon_info.primary_icon_files: + if not is_safe_path(app_bundle_path, icon_name): + raise UnsafePathError(f"Unsafe icon name in plist: {icon_name}") + # iOS lists base names without extensions or resolution modifiers (@2x, @3x, ~ipad) # Search for files matching the base name with any suffix # e.g., "AppIcon60x60" matches "AppIcon60x60@2x.png" or "AppIcon60x60.png" diff --git a/src/launchpad/parsers/android/icon/icon_parser.py b/src/launchpad/parsers/android/icon/icon_parser.py index 0b019f7a..153b6e1d 100644 --- a/src/launchpad/parsers/android/icon/icon_parser.py +++ b/src/launchpad/parsers/android/icon/icon_parser.py @@ -10,6 +10,7 @@ from PIL import Image, ImageDraw +from launchpad.artifacts.providers.zip_provider import UnsafePathError, is_safe_path from launchpad.parsers.android.binary.types import XmlNode from launchpad.utils.logging import get_logger @@ -79,6 +80,8 @@ def render_from_path(self, xml_file_path: Path) -> bytes | None: return self._render_adaptive_icon(root_node) return self._render_vector_drawable(root_node) + except UnsafePathError: + raise except Exception: logger.exception("Error rendering icon from path") return None @@ -458,6 +461,9 @@ def _interpolate_gradient_color( return None def _find_file(self, filename: str) -> Path | None: + if not is_safe_path(self.extract_dir, filename): + raise UnsafePathError(f"Unsafe file path in drawable: {filename}") + # Try exact match first exact_path = self.extract_dir / filename if exact_path.exists(): diff --git a/tests/unit/artifacts/android/test_aab.py b/tests/unit/artifacts/android/test_aab.py index 7d1f8f21..48ac0834 100644 --- a/tests/unit/artifacts/android/test_aab.py +++ b/tests/unit/artifacts/android/test_aab.py @@ -1,8 +1,11 @@ from pathlib import Path +from unittest.mock import patch import pytest from launchpad.artifacts.android.aab import AAB +from launchpad.artifacts.android.manifest.manifest import AndroidApplication, AndroidManifest +from launchpad.artifacts.providers.zip_provider import UnsafePathError @pytest.fixture @@ -32,3 +35,10 @@ def test_get_app_icon(self, test_aab: AAB) -> None: assert len(icon) > 0 assert icon.startswith(b"\x89PNG") assert icon.endswith(b"IEND\xae\x42\x60\x82") + + def test_get_app_icon_rejects_path_traversal(self, test_aab: AAB) -> None: + malicious_app = AndroidApplication.model_construct(icon_path="../../../etc/passwd") + malicious_manifest = AndroidManifest.model_construct(application=malicious_app) + with patch.object(test_aab, "get_manifest", return_value=malicious_manifest): + with pytest.raises(UnsafePathError): + test_aab.get_app_icon() diff --git a/tests/unit/artifacts/android/test_apk.py b/tests/unit/artifacts/android/test_apk.py index 8d7b7002..feea0895 100644 --- a/tests/unit/artifacts/android/test_apk.py +++ b/tests/unit/artifacts/android/test_apk.py @@ -1,8 +1,11 @@ from pathlib import Path +from unittest.mock import patch import pytest from launchpad.artifacts.android.apk import APK +from launchpad.artifacts.android.manifest.manifest import AndroidApplication, AndroidManifest +from launchpad.artifacts.providers.zip_provider import UnsafePathError @pytest.fixture @@ -43,3 +46,10 @@ def test_get_app_icon(self, test_apk: APK) -> None: assert len(icon) > 0 assert icon.startswith(b"\x89PNG") assert icon.endswith(b"IEND\xae\x42\x60\x82") + + def test_get_app_icon_rejects_path_traversal(self, test_apk: APK) -> None: + malicious_app = AndroidApplication.model_construct(icon_path="../../../etc/passwd") + malicious_manifest = AndroidManifest.model_construct(application=malicious_app) + with patch.object(test_apk, "get_manifest", return_value=malicious_manifest): + with pytest.raises(UnsafePathError): + test_apk.get_app_icon() diff --git a/tests/unit/parsers/android/icon/__init__.py b/tests/unit/parsers/android/icon/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/parsers/android/icon/test_icon_parser.py b/tests/unit/parsers/android/icon/test_icon_parser.py new file mode 100644 index 00000000..6d50ca96 --- /dev/null +++ b/tests/unit/parsers/android/icon/test_icon_parser.py @@ -0,0 +1,21 @@ +import tempfile + +from pathlib import Path + +import pytest + +from launchpad.artifacts.providers.zip_provider import UnsafePathError +from launchpad.parsers.android.icon.icon_parser import IconParser + + +class TestIconParserFindFile: + def test_find_file_rejects_path_traversal(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + extract_dir = Path(tmpdir) + parser = IconParser(extract_dir) + + with pytest.raises(UnsafePathError): + parser._find_file("../../etc/passwd") + + with pytest.raises(UnsafePathError): + parser._find_file("/etc/passwd")