Skip to content

Commit cb169ad

Browse files
trevor-eclaude
andauthored
Add path traversal guards to plist-derived paths (#624)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c84dec5 commit cb169ad

2 files changed

Lines changed: 114 additions & 1 deletion

File tree

src/launchpad/artifacts/apple/zipped_xcarchive.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,9 @@ def get_binary_path(self) -> Path | None:
287287
if not executable_name:
288288
return None
289289

290+
if not is_safe_path(app_bundle_path, executable_name):
291+
raise UnsafePathError(f"Unsafe CFBundleExecutable in plist: {executable_name}")
292+
290293
return app_bundle_path / executable_name
291294

292295
@sentry_sdk.trace
@@ -394,6 +397,8 @@ def get_asset_catalog_details(self, relative_path: Path) -> List[AssetCatalogEle
394397
data = json.load(f)
395398

396399
return [self._parse_asset_element(item, parent_path) for item in data]
400+
except UnsafePathError:
401+
raise
397402
except Exception:
398403
logger.exception(f"Failed to get asset catalog details for {relative_path}")
399404
return []
@@ -421,11 +426,17 @@ def _discover_extension_binaries(self, app_bundle_path: Path) -> List[Path]:
421426
extension_plist = plistlib.load(f)
422427
extension_executable = extension_plist.get("CFBundleExecutable")
423428
if extension_executable:
429+
if not is_safe_path(extension_path, extension_executable):
430+
raise UnsafePathError(
431+
f"Unsafe CFBundleExecutable in extension plist: {extension_executable}"
432+
)
424433
extension_binary_path = extension_path / extension_executable
425434
if extension_binary_path.exists():
426435
extension_binaries.append(extension_binary_path)
427436
else:
428437
logger.warning("Extension binary not found", extra={"path": extension_binary_path})
438+
except UnsafePathError:
439+
raise
429440
except Exception:
430441
logger.exception(f"Failed to read extension Info.plist at {extension_path}")
431442
return extension_binaries
@@ -441,11 +452,15 @@ def _discover_watch_binaries(self, app_bundle_path: Path) -> List[Path]:
441452
watch_plist = plistlib.load(f)
442453
watch_executable = watch_plist.get("CFBundleExecutable")
443454
if watch_executable:
455+
if not is_safe_path(watch_path, watch_executable):
456+
raise UnsafePathError(f"Unsafe CFBundleExecutable in Watch plist: {watch_executable}")
444457
watch_binary_path = watch_path / watch_executable
445458
if watch_binary_path.exists():
446459
watch_binaries.append(watch_binary_path)
447460
else:
448461
logger.warning("Watch binary not found", extra={"path": watch_binary_path})
462+
except UnsafePathError:
463+
raise
449464
except Exception:
450465
logger.exception(f"Failed to read Watch app Info.plist at {watch_path}")
451466
return watch_binaries
@@ -469,6 +484,8 @@ def _get_main_binary_path(self) -> Path:
469484
main_executable = self.get_plist().get("CFBundleExecutable")
470485
if main_executable is None:
471486
raise RuntimeError("CFBundleExecutable not found in Info.plist")
487+
if not is_safe_path(app_bundle_path, main_executable):
488+
raise UnsafePathError(f"Unsafe CFBundleExecutable in plist: {main_executable}")
472489
return Path(os.path.join(str(app_bundle_path), main_executable))
473490

474491
def _parse_asset_element(self, item: dict[str, Any], parent_path: Path) -> AssetCatalogElement:
@@ -486,7 +503,10 @@ def _parse_asset_element(self, item: dict[str, Any], parent_path: Path) -> Asset
486503

487504
file_extension = Path(filename).suffix.lower()
488505
if filename and file_extension in {".png", ".jpg", ".jpeg", ".heic", ".heif", ".pdf", ".svg"}:
489-
potential_path = parent_path / f"{image_id}{file_extension}"
506+
candidate = f"{image_id}{file_extension}"
507+
if not is_safe_path(parent_path, candidate):
508+
raise UnsafePathError(f"Unsafe asset imageId in catalog: {image_id}")
509+
potential_path = parent_path / candidate
490510
if potential_path.exists():
491511
full_path = potential_path
492512
else:

tests/unit/artifacts/apple/test_zipped_xcarchive.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import json
2+
import plistlib
23
import tempfile
34

45
from pathlib import Path
56
from unittest.mock import patch
67

8+
import pytest
9+
710
from launchpad.artifacts.apple.zipped_xcarchive import ZippedXCArchive
11+
from launchpad.artifacts.providers.zip_provider import UnsafePathError
812

913

1014
class TestZippedXCArchive:
@@ -145,3 +149,92 @@ def test_framework_bundle_asset_catalog_parsing(self) -> None:
145149
assert not wrong_path.exists(), "Image should NOT exist at top-level"
146150

147151
assert "MyFramework.bundle" in str(element.full_path)
152+
153+
def test_get_binary_path_rejects_path_traversal(self) -> None:
154+
with tempfile.TemporaryDirectory() as tmpdir:
155+
app_bundle_path = Path(tmpdir) / "Test.app"
156+
app_bundle_path.mkdir()
157+
158+
with patch.object(ZippedXCArchive, "__init__", lambda self, path: None):
159+
archive = ZippedXCArchive(Path("dummy"))
160+
161+
with (
162+
patch.object(archive, "get_app_bundle_path", return_value=app_bundle_path),
163+
patch.object(archive, "get_plist", return_value={"CFBundleExecutable": "../../../etc/passwd"}),
164+
):
165+
with pytest.raises(UnsafePathError):
166+
archive.get_binary_path()
167+
168+
def test_get_main_binary_path_rejects_path_traversal(self) -> None:
169+
with tempfile.TemporaryDirectory() as tmpdir:
170+
app_bundle_path = Path(tmpdir) / "Test.app"
171+
app_bundle_path.mkdir()
172+
173+
with patch.object(ZippedXCArchive, "__init__", lambda self, path: None):
174+
archive = ZippedXCArchive(Path("dummy"))
175+
176+
with (
177+
patch.object(archive, "get_app_bundle_path", return_value=app_bundle_path),
178+
patch.object(archive, "get_plist", return_value={"CFBundleExecutable": "../../../etc/passwd"}),
179+
):
180+
with pytest.raises(UnsafePathError):
181+
archive._get_main_binary_path()
182+
183+
def test_discover_extension_binaries_rejects_path_traversal(self) -> None:
184+
with tempfile.TemporaryDirectory() as tmpdir:
185+
app_bundle_path = Path(tmpdir) / "Test.app"
186+
extension_path = app_bundle_path / "PlugIns" / "Malicious.appex"
187+
extension_path.mkdir(parents=True)
188+
with open(extension_path / "Info.plist", "wb") as f:
189+
plistlib.dump({"CFBundleExecutable": "../../../etc/passwd"}, f)
190+
191+
with patch.object(ZippedXCArchive, "__init__", lambda self, path: None):
192+
archive = ZippedXCArchive(Path("dummy"))
193+
with pytest.raises(UnsafePathError):
194+
archive._discover_extension_binaries(app_bundle_path)
195+
196+
def test_discover_watch_binaries_rejects_path_traversal(self) -> None:
197+
with tempfile.TemporaryDirectory() as tmpdir:
198+
app_bundle_path = Path(tmpdir) / "Test.app"
199+
watch_path = app_bundle_path / "Watch" / "Malicious.app"
200+
watch_path.mkdir(parents=True)
201+
with open(watch_path / "Info.plist", "wb") as f:
202+
plistlib.dump({"CFBundleExecutable": "../../../etc/passwd"}, f)
203+
204+
with patch.object(ZippedXCArchive, "__init__", lambda self, path: None):
205+
archive = ZippedXCArchive(Path("dummy"))
206+
with pytest.raises(UnsafePathError):
207+
archive._discover_watch_binaries(app_bundle_path)
208+
209+
def test_asset_catalog_rejects_path_traversal_in_image_id(self) -> None:
210+
with tempfile.TemporaryDirectory() as tmpdir:
211+
tmpdir_path = Path(tmpdir)
212+
213+
xcarchive_dir = tmpdir_path / "Test.xcarchive"
214+
parsed_assets_dir = xcarchive_dir / "ParsedAssets" / "Products" / "Applications" / "Test.app"
215+
parsed_assets_dir.mkdir(parents=True)
216+
217+
assets_json = parsed_assets_dir / "Assets.json"
218+
assets_data = [
219+
{
220+
"name": "icon.png",
221+
"imageId": "../../../etc/passwd",
222+
"size": 1024,
223+
"type": 0,
224+
"vector": False,
225+
"filename": "icon.png",
226+
}
227+
]
228+
assets_json.write_text(json.dumps(assets_data))
229+
230+
with patch.object(ZippedXCArchive, "__init__", lambda self, path: None):
231+
archive = ZippedXCArchive(Path("dummy"))
232+
archive._extract_dir = tmpdir_path
233+
234+
with patch.object(
235+
archive,
236+
"get_app_bundle_path",
237+
return_value=xcarchive_dir / "Products" / "Applications" / "Test.app",
238+
):
239+
with pytest.raises(UnsafePathError):
240+
archive.get_asset_catalog_details(Path("Assets.car"))

0 commit comments

Comments
 (0)