ref(security): Add SafeDirectory to enforce path traversal checks#623
Conversation
📲 Install BuildsiOS
Android
|
| @@ -0,0 +1,10 @@ | |||
| class UnreasonableZipError(ValueError): | |||
There was a problem hiding this comment.
moved from zipprovider to avoid circular imports.
| # 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")) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Is that something introduced/changed in this PR?
There was a problem hiding this comment.
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.
ZipProvider.extract_to_temp_directory() now returns a SafeDirectory instead of a raw Path. SafeDirectory.resolve(untrusted) validates that attacker-controlled paths (manifest values, plist entries) stay within the extraction directory, replacing the manual is_safe_path() + raise pattern at each call site. This makes the safe path the default path — developers can't accidentally skip the check when joining untrusted input to an extract directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SafeDirectory now delegates glob, rglob, iterdir, /, exists, is_dir, relative_to, __fspath__, and common properties (name, stem, suffix, parent) to the underlying Path. This eliminates the .path accessor from all call sites. Icon parsers (IconParser, BinaryXmlDrawableParser, ProtoXmlDrawableParser) now accept only SafeDirectory instead of Path | SafeDirectory, enforcing the safety boundary at the type level. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8a90e9f to
c1c6bda
Compare
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the last usage of is_safe_path in ZipProvider with SafeDirectory.resolve() and update all imports of UnsafePathError to come from the exceptions module. Add SafeDirectory guidance to CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… SafeDirectory from get_app_bundle_path SafeDirectory / now validates that the result stays within bounds, so developers can't accidentally bypass traversal checks with the / operator. get_app_bundle_path() returns SafeDirectory instead of Path, eliminating ad-hoc SafeDirectory wrapping at every call site. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Validate traversal using the resolved path but return the original non-resolved path so callers can still detect symlinks via is_symlink(). This fixes code signature validation which hashes symlinks by their target string rather than following them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pydantic validates that full_path is a Path instance. Use .path to unwrap the SafeDirectory at the call site. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract .path at the top of analyze_apple_files() and the apple analyzer so downstream code receives a plain Path. Update test fixtures to return SafeDirectory to match get_app_bundle_path(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tion (VULN-1761) Path.resolve() treats ** as a literal directory name while Path.glob() treats it as a recursive wildcard. An attacker could craft a CFBundleIconFiles entry like **/**/../../../etc that passes is_safe_path (resolve sees **/.. as cancelling) but escapes the extraction root during glob (** matches zero directories, so .. ascends past the base). Two-layer fix: - Reject icon names containing glob metacharacters before they reach glob(), since valid iOS icon names never contain *, ?, [, or ] - SafeDirectory.glob/rglob now validate each result resolves within the base directory, as defense-in-depth for all callers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SafeDirectory.resolve() and SafeDirectory.glob() already constrain paths to the base directory, making the explicit metacharacter filtering unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match the safety filtering already applied by glob() and rglob() so that iterdir() also excludes entries whose resolved path falls outside the base directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 05ab037. Configure here.
The mocks were returning plain Path objects which don't match the production return type. This would mask regressions if code starts calling SafeDirectory-specific methods on the return value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| if path.resolve().is_relative_to(self._base): | ||
| yield path | ||
|
|
||
| def iterdir(self) -> Generator[Path, None, None]: | ||
| for path in self._base.iterdir(): |
There was a problem hiding this comment.
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.

Summary
Introduces
SafeDirectory— aPath-like wrapper that validates untrusted input stays within bounds — and removesis_safe_path()entirely.ZipProvider.extract_to_temp_directory()returnsSafeDirectoryinstead ofPathSafeDirectory.resolve(untrusted)replaces all manualis_safe_path()+raise UnsafePathErrorpatterns — developers can't accidentally skip the checkSafeDirectory.child(untrusted)creates a scoped subdirectory (e.g. AAB'sbase/dir)Pathoperations (glob,rglob,/,exists,relative_to, etc.) are delegated directly, soSafeDirectoryis a drop-in whereverPathwas used beforeIconParser,BinaryXmlDrawableParser,ProtoXmlDrawableParser) now accept onlySafeDirectory, enforcing the safety boundary at the type levelUnsafePathError/UnreasonableZipErrormoved toexceptions.py; all imports updated to use the canonical locationis_safe_path()removed —SafeDirectory.resolve()is the single path validation mechanismFixes EME-1158