-
-
Notifications
You must be signed in to change notification settings - Fork 3
ref(security): Add SafeDirectory to enforce path traversal checks #623
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
ef7c2f8
c1c6bda
5c49409
3beb0d3
9ce30fa
bf68c6c
a6039c4
31e020e
26e2e5d
659f446
9d743e4
903458f
dac40f8
3bffc53
05ab037
504fbb2
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 |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| import json | ||
| import os | ||
| import plistlib | ||
| import shutil | ||
| import subprocess | ||
|
|
@@ -17,7 +16,9 @@ | |
| from launchpad.utils.logging import get_logger | ||
|
|
||
| from ..artifact import AppleArtifact | ||
| from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path | ||
| from ..providers.exceptions import UnsafePathError | ||
| from ..providers.safe_directory import SafeDirectory | ||
| from ..providers.zip_provider import ZipProvider | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
@@ -66,15 +67,15 @@ def __init__(self, path: Path) -> None: | |
| super().__init__(path) | ||
| self._zip_provider = ZipProvider(path) | ||
| self._extract_dir = self._zip_provider.extract_to_temp_directory() | ||
| self._app_bundle_path: Path | None = None | ||
| self._app_bundle_path: SafeDirectory | None = None | ||
| self._plist: dict[str, Any] | None = None | ||
| self._archive_plist: dict[str, Any] | None = None | ||
| self._provisioning_profile: dict[str, Any] | None = None | ||
| self._dsym_info: dict[str, DsymInfo] | None = None | ||
| self._binary_uuid_cache: dict[Path, str] = {} | ||
| self._lief_cache: dict[Path, lief.MachO.FatBinary] = {} | ||
|
|
||
| def get_extract_dir(self) -> Path: | ||
| def get_extract_dir(self) -> SafeDirectory: | ||
| return self._extract_dir | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
| @sentry_sdk.trace | ||
|
|
@@ -127,16 +128,15 @@ def get_app_icon(self) -> bytes | None: | |
| logger.warning("No icon files found in CFBundleIconFiles") | ||
| return None | ||
|
|
||
| app_bundle_path = self.get_app_bundle_path() | ||
| app_bundle = 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}") | ||
| app_bundle.resolve(icon_name) | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| # 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" | ||
| matching_files = list(app_bundle_path.glob(f"{icon_name}*.png")) | ||
| matching_files = list(app_bundle.glob(f"{icon_name}*.png")) | ||
|
Contributor
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. A malicious plist can put glob metacharacters in CFBundleIconFiles. resolve(icon_name) doesn't catch them because *, ?, [, **/ are all legal filename characters and the resolved path stays inside the base. Examples:
While not exploitable it's probably something we want to tighten up on.
Contributor
Author
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. Is that something introduced/changed in this PR?
Contributor
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. No it's just something Claude pointed out as a vulnerability that we didn't close, though worse case is it just misdirects to another .png in same zip so not high risk, it can't leak out of the zip folder. |
||
|
|
||
| if not matching_files: | ||
| continue | ||
|
|
@@ -287,21 +287,19 @@ def get_binary_path(self) -> Path | None: | |
| if not executable_name: | ||
| return None | ||
|
|
||
| if not is_safe_path(app_bundle_path, executable_name): | ||
| raise UnsafePathError(f"Unsafe CFBundleExecutable in plist: {executable_name}") | ||
|
|
||
| return app_bundle_path / executable_name | ||
| return app_bundle_path.resolve(executable_name) | ||
|
|
||
| @sentry_sdk.trace | ||
| def get_app_bundle_path(self) -> Path: | ||
| def get_app_bundle_path(self) -> SafeDirectory: | ||
| """Get the path to the .app bundle.""" | ||
| if self._app_bundle_path is not None: | ||
| return self._app_bundle_path | ||
|
|
||
| for path in self._extract_dir.rglob("*.xcarchive/Products/**/*.app"): | ||
| if path.is_dir() and "__MACOSX" not in str(path): | ||
| logger.debug(f"Found Apple app bundle: {path}") | ||
| return path | ||
| self._app_bundle_path = SafeDirectory(path) | ||
| return self._app_bundle_path | ||
|
|
||
| raise FileNotFoundError(f"No .app bundle found in {self._extract_dir}") | ||
|
|
||
|
|
@@ -403,7 +401,7 @@ def get_asset_catalog_details(self, relative_path: Path) -> List[AssetCatalogEle | |
| logger.exception(f"Failed to get asset catalog details for {relative_path}") | ||
| return [] | ||
|
|
||
| def _discover_framework_binaries(self, app_bundle_path: Path) -> List[Path]: | ||
| def _discover_framework_binaries(self, app_bundle_path: SafeDirectory) -> List[Path]: | ||
| framework_binaries: List[Path] = [] | ||
| for framework_path in app_bundle_path.rglob("*.framework"): | ||
| if framework_path.is_dir(): | ||
|
|
@@ -415,7 +413,7 @@ def _discover_framework_binaries(self, app_bundle_path: Path) -> List[Path]: | |
| logger.warning("Framework binary not found", extra={"path": framework_binary_path}) | ||
| return framework_binaries | ||
|
|
||
| def _discover_extension_binaries(self, app_bundle_path: Path) -> List[Path]: | ||
| def _discover_extension_binaries(self, app_bundle_path: SafeDirectory) -> List[Path]: | ||
| extension_binaries: List[Path] = [] | ||
| for extension_path in app_bundle_path.rglob("*.appex"): | ||
| if extension_path.is_dir(): | ||
|
|
@@ -426,11 +424,7 @@ def _discover_extension_binaries(self, app_bundle_path: Path) -> List[Path]: | |
| extension_plist = plistlib.load(f) | ||
| extension_executable = extension_plist.get("CFBundleExecutable") | ||
| if extension_executable: | ||
| if not is_safe_path(extension_path, extension_executable): | ||
| raise UnsafePathError( | ||
| f"Unsafe CFBundleExecutable in extension plist: {extension_executable}" | ||
| ) | ||
| extension_binary_path = extension_path / extension_executable | ||
| extension_binary_path = SafeDirectory(extension_path).resolve(extension_executable) | ||
| if extension_binary_path.exists(): | ||
| extension_binaries.append(extension_binary_path) | ||
| else: | ||
|
|
@@ -441,7 +435,7 @@ def _discover_extension_binaries(self, app_bundle_path: Path) -> List[Path]: | |
| logger.exception(f"Failed to read extension Info.plist at {extension_path}") | ||
| return extension_binaries | ||
|
|
||
| def _discover_watch_binaries(self, app_bundle_path: Path) -> List[Path]: | ||
| def _discover_watch_binaries(self, app_bundle_path: SafeDirectory) -> List[Path]: | ||
| watch_binaries: List[Path] = [] | ||
| for watch_path in app_bundle_path.rglob("Watch/*.app"): | ||
| if watch_path.is_dir(): | ||
|
|
@@ -452,9 +446,7 @@ def _discover_watch_binaries(self, app_bundle_path: Path) -> List[Path]: | |
| watch_plist = plistlib.load(f) | ||
| watch_executable = watch_plist.get("CFBundleExecutable") | ||
| if watch_executable: | ||
| if not is_safe_path(watch_path, watch_executable): | ||
| raise UnsafePathError(f"Unsafe CFBundleExecutable in Watch plist: {watch_executable}") | ||
| watch_binary_path = watch_path / watch_executable | ||
| watch_binary_path = SafeDirectory(watch_path).resolve(watch_executable) | ||
| if watch_binary_path.exists(): | ||
| watch_binaries.append(watch_binary_path) | ||
| else: | ||
|
|
@@ -484,9 +476,7 @@ def _get_main_binary_path(self) -> Path: | |
| main_executable = self.get_plist().get("CFBundleExecutable") | ||
| if main_executable is None: | ||
| raise RuntimeError("CFBundleExecutable not found in Info.plist") | ||
| if not is_safe_path(app_bundle_path, main_executable): | ||
| raise UnsafePathError(f"Unsafe CFBundleExecutable in plist: {main_executable}") | ||
| return Path(os.path.join(str(app_bundle_path), main_executable)) | ||
| return app_bundle_path.resolve(main_executable) | ||
|
|
||
| def _parse_asset_element(self, item: dict[str, Any], parent_path: Path) -> AssetCatalogElement: | ||
| """Parse a dictionary item into an AssetCatalogElement.""" | ||
|
|
@@ -504,9 +494,7 @@ def _parse_asset_element(self, item: dict[str, Any], parent_path: Path) -> Asset | |
| file_extension = Path(filename).suffix.lower() | ||
| if filename and file_extension in {".png", ".jpg", ".jpeg", ".heic", ".heif", ".pdf", ".svg"}: | ||
| candidate = f"{image_id}{file_extension}" | ||
| if not is_safe_path(parent_path, candidate): | ||
| raise UnsafePathError(f"Unsafe asset imageId in catalog: {image_id}") | ||
| potential_path = parent_path / candidate | ||
| potential_path = SafeDirectory(parent_path).resolve(candidate) | ||
| if potential_path.exists(): | ||
| full_path = potential_path | ||
| else: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| class UnreasonableZipError(ValueError): | ||
|
Contributor
Author
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. moved from zipprovider to avoid circular imports. |
||
| """Raised when a zip file exceeds reasonable limits.""" | ||
|
|
||
| pass | ||
|
|
||
|
|
||
| class UnsafePathError(ValueError): | ||
| """Raised when a zip file contains unsafe path entries that could lead to path traversal attacks.""" | ||
|
|
||
| pass | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import os | ||
|
|
||
| from pathlib import Path | ||
| from typing import Generator | ||
|
|
||
| from .exceptions import UnsafePathError | ||
|
|
||
|
|
||
| class SafeDirectory: | ||
| """A directory wrapper that validates all path construction stays within bounds. | ||
|
|
||
| Every path built via / or .resolve() is checked against the base directory. | ||
| glob and rglob validate that each result resolves within the base directory, | ||
| since glob patterns containing ".." can escape via recursive wildcards. | ||
| """ | ||
|
|
||
| def __init__(self, base: Path) -> None: | ||
| self._base = base.resolve() | ||
|
|
||
| @property | ||
| def path(self) -> Path: | ||
| return self._base | ||
|
|
||
| def resolve(self, untrusted: str) -> Path: | ||
| """Resolve an untrusted path within this directory. | ||
|
|
||
| Raises UnsafePathError if the resolved path escapes the base. | ||
| """ | ||
| try: | ||
| target = (self._base / untrusted).resolve() | ||
| except RuntimeError: | ||
| raise UnsafePathError(f"Path traversal attempt: {untrusted}") | ||
| if not target.is_relative_to(self._base): | ||
| raise UnsafePathError(f"Path traversal attempt: {untrusted}") | ||
| return target | ||
|
|
||
| def child(self, untrusted: str) -> "SafeDirectory": | ||
| """Return a new SafeDirectory scoped to a validated subdirectory.""" | ||
| return SafeDirectory(self.resolve(untrusted)) | ||
|
|
||
| def __truediv__(self, other: str | os.PathLike[str]) -> Path: | ||
| result = self._base / other | ||
| try: | ||
| resolved = result.resolve() | ||
| except RuntimeError: | ||
| raise UnsafePathError(f"Path traversal attempt: {other}") | ||
| if not resolved.is_relative_to(self._base): | ||
| raise UnsafePathError(f"Path traversal attempt: {other}") | ||
| # Return the non-resolved path so callers can still detect symlinks | ||
| return result | ||
|
sentry[bot] marked this conversation as resolved.
|
||
|
|
||
| def __str__(self) -> str: | ||
| return str(self._base) | ||
|
|
||
| def __repr__(self) -> str: | ||
| return f"SafeDirectory({self._base})" | ||
|
|
||
| def __fspath__(self) -> str: | ||
| return str(self._base) | ||
|
|
||
| def glob(self, pattern: str) -> Generator[Path, None, None]: | ||
| for path in self._base.glob(pattern): | ||
| if path.resolve().is_relative_to(self._base): | ||
| yield path | ||
|
|
||
| def rglob(self, pattern: str) -> Generator[Path, None, None]: | ||
| for path in self._base.rglob(pattern): | ||
| if path.resolve().is_relative_to(self._base): | ||
| yield path | ||
|
|
||
| def iterdir(self) -> Generator[Path, None, None]: | ||
| for path in self._base.iterdir(): | ||
|
Comment on lines
+68
to
+72
Contributor
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. Bug: The Suggested FixModify Prompt for AI Agent |
||
| if path.resolve().is_relative_to(self._base): | ||
| yield path | ||
|
|
||
| def stat(self) -> os.stat_result: | ||
| return self._base.stat() | ||
|
|
||
| def exists(self) -> bool: | ||
| return self._base.exists() | ||
|
|
||
| def is_dir(self) -> bool: | ||
| return self._base.is_dir() | ||
|
|
||
| def is_file(self) -> bool: | ||
| return self._base.is_file() | ||
|
|
||
| def relative_to(self, other: str | os.PathLike[str]) -> Path: | ||
| return self._base.relative_to(other) | ||
|
|
||
| @property | ||
| def name(self) -> str: | ||
| return self._base.name | ||
|
|
||
| @property | ||
| def stem(self) -> str: | ||
| return self._base.stem | ||
|
|
||
| @property | ||
| def suffix(self) -> str: | ||
| return self._base.suffix | ||
|
|
||
| @property | ||
| def parent(self) -> Path: | ||
| return self._base.parent | ||
|
cursor[bot] marked this conversation as resolved.
|
||
Uh oh!
There was an error while loading. Please reload this page.