Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ make worker # Start Launchpad worker (TaskWorker mode)
- **Empty `__init__.py` files** preferred
- **Comments**: Only add comments for complex logic or important business decisions

## Security: Path Traversal Prevention

When handling paths derived from untrusted input (zip member names, plist values, manifest entries, user-supplied filenames), use `SafeDirectory` from `launchpad.artifacts.providers.safe_directory` — never use raw `Path` operations.

- **`SafeDirectory(base).resolve(untrusted)`** — validates and returns a safe `Path`, raises `UnsafePathError` on traversal attempts
- **`SafeDirectory(base).child(untrusted)`** — returns a new `SafeDirectory` scoped to a validated subdirectory
- Trusted operations (glob, `/`, iterdir) are delegated directly and don't need validation
- Import `UnsafePathError` from `launchpad.artifacts.providers.exceptions`

## Testing Strategy

- **Prefer integration tests** using real artifacts from `tests/_fixtures/` over unit tests with mocks
Expand Down
12 changes: 5 additions & 7 deletions src/launchpad/artifacts/android/aab.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
from launchpad.utils.logging import get_logger

from ..artifact import AndroidArtifact
from ..providers.zip_provider import UnsafePathError, ZipProvider, is_safe_path
from ..providers.exceptions import UnsafePathError
from ..providers.zip_provider import ZipProvider
from .apk import APK
from .manifest.manifest import AndroidManifest
from .manifest.proto_xml import ProtoXmlUtils
Expand Down Expand Up @@ -156,11 +157,8 @@ def get_app_icon(self) -> bytes | None:
logger.info("No icon path found in manifest")
return None

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
base_dir = self._extract_dir.child("base")
icon_path = base_dir.resolve(icon_path_str)

if not icon_path.exists():
logger.info(f"Icon not found in AAB: {icon_path_str}")
Expand All @@ -171,7 +169,7 @@ def get_app_icon(self) -> bytes | None:
try:
proto_res_tables = self.get_resource_tables()

proto_xml_drawable_parser = ProtoXmlDrawableParser(self._extract_dir / "base", proto_res_tables)
proto_xml_drawable_parser = ProtoXmlDrawableParser(self._extract_dir.child("base"), proto_res_tables)

icon = proto_xml_drawable_parser.render_from_path(icon_path)
if icon:
Expand Down
11 changes: 5 additions & 6 deletions src/launchpad/artifacts/android/apk.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
from ...parsers.android.dex.types import ClassDefinition
from ...utils.logging import get_logger
from ..artifact import AndroidArtifact
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
from .manifest.axml import AxmlUtils
from .manifest.manifest import AndroidManifest
from .resources.binary import BinaryResourceTable
Expand Down Expand Up @@ -109,7 +111,7 @@ def get_class_definitions(self) -> list[ClassDefinition]:

return self._class_definitions

def get_extract_path(self) -> Path:
def get_extract_path(self) -> SafeDirectory:
return self._extract_dir

def get_apksigner_certs(self) -> str:
Expand All @@ -128,10 +130,7 @@ 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
icon_path = self._extract_dir.resolve(icon_path_str)

if not icon_path.exists():
logger.info(f"Icon not found in APK: {icon_path_str}")
Expand Down
50 changes: 19 additions & 31 deletions src/launchpad/artifacts/apple/zipped_xcarchive.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
import os
import plistlib
import shutil
import subprocess
Expand All @@ -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__)

Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -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
Comment thread
sentry-warden[bot] marked this conversation as resolved.

@sentry_sdk.trace
Expand Down Expand Up @@ -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)
Comment thread
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"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:

  • icon_name = "*" → glob("**.png") matches every PNG in the bundle root.
  • icon_name = "/" → glob("/*.png") matches every PNG anywhere in the bundle (** activates as recursive when it's a path segment).

While not exploitable it's probably something we want to tighten up on.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that something introduced/changed in this PR?

Copy link
Copy Markdown
Contributor

@cameroncooke cameroncooke May 18, 2026

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand All @@ -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:
Expand All @@ -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():
Expand All @@ -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:
Expand Down Expand Up @@ -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."""
Expand All @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions src/launchpad/artifacts/providers/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class UnreasonableZipError(ValueError):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
105 changes: 105 additions & 0 deletions src/launchpad/artifacts/providers/safe_directory.py
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
Comment thread
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The SafeDirectory.rglob method silently skips symlinks that resolve outside the base directory, causing consumers like code signature validation to fail incorrectly.
Severity: HIGH

Suggested Fix

Modify SafeDirectory.rglob to yield symlinks even if their target is outside the base directory. The current implementation filters paths where path.resolve().is_relative_to(self._base) is true. This check should be removed or adjusted to allow consumers to handle symlinks as they see fit, restoring the previous behavior needed for correct code signature validation.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/launchpad/artifacts/providers/safe_directory.py#L68-L72

Potential issue: The `SafeDirectory.rglob` method filters out paths that are symlinks
resolving to a target outside of the base directory. This is a regression from the
previous `Path.rglob` behavior. A key consumer, the code signature validation in
`_check_bundle_resources`, relies on iterating over all resources listed in the
manifest, including valid symlinks pointing outside the app bundle. Because `rglob` now
silently skips these symlinks, they are never processed and are incorrectly reported as
"file missing", leading to false positive validation failures.

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
Comment thread
cursor[bot] marked this conversation as resolved.
Loading
Loading