Skip to content

ref(security): Add SafeDirectory to enforce path traversal checks#623

Merged
runningcode merged 16 commits into
mainfrom
no/safe-directory
May 19, 2026
Merged

ref(security): Add SafeDirectory to enforce path traversal checks#623
runningcode merged 16 commits into
mainfrom
no/safe-directory

Conversation

@runningcode
Copy link
Copy Markdown
Contributor

@runningcode runningcode commented May 15, 2026

Summary

Introduces SafeDirectory — a Path-like wrapper that validates untrusted input stays within bounds — and removes is_safe_path() entirely.

  • ZipProvider.extract_to_temp_directory() returns SafeDirectory instead of Path
  • SafeDirectory.resolve(untrusted) replaces all manual is_safe_path() + raise UnsafePathError patterns — developers can't accidentally skip the check
  • SafeDirectory.child(untrusted) creates a scoped subdirectory (e.g. AAB's base/ dir)
  • Trusted Path operations (glob, rglob, /, exists, relative_to, etc.) are delegated directly, so SafeDirectory is a drop-in wherever Path was used before
  • Icon parsers (IconParser, BinaryXmlDrawableParser, ProtoXmlDrawableParser) now accept only SafeDirectory, enforcing the safety boundary at the type level
  • UnsafePathError / UnreasonableZipError moved to exceptions.py; all imports updated to use the canonical location
  • is_safe_path() removed — SafeDirectory.resolve() is the single path validation mechanism
  • Added SafeDirectory guidance to CLAUDE.md for future development

Fixes EME-1158

@sentry
Copy link
Copy Markdown
Contributor

sentry Bot commented May 15, 2026

📲 Install Builds

iOS

🔗 App Name App ID Version Configuration
HackerNews com.emergetools.hackernews 3.8 (1) Release

Android

🔗 App Name App ID Version Configuration
Hacker News com.emergetools.hackernews 1.0.2 (13) Release

⚙️ launchpad-test-android Build Distribution Settings

@runningcode runningcode marked this pull request as ready for review May 15, 2026 15:00
@@ -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.

@runningcode runningcode requested a review from chromy May 15, 2026 15:03
Comment thread src/launchpad/artifacts/apple/zipped_xcarchive.py
Comment thread src/launchpad/artifacts/apple/zipped_xcarchive.py
# 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.

runningcode and others added 2 commits May 18, 2026 13:19
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>
Comment thread src/launchpad/artifacts/apple/zipped_xcarchive.py
runningcode and others added 6 commits May 18, 2026 13:24
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>
Comment thread src/launchpad/artifacts/providers/safe_directory.py
Comment thread src/launchpad/artifacts/providers/safe_directory.py
runningcode and others added 3 commits May 18, 2026 14:12
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>
Comment thread src/launchpad/size/analyzers/android.py
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>
Comment thread src/launchpad/artifacts/apple/zipped_xcarchive.py
runningcode and others added 2 commits May 19, 2026 11:11
…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>
Comment thread src/launchpad/artifacts/providers/safe_directory.py Outdated
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>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread tests/unit/artifacts/apple/test_zipped_xcarchive.py
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>
Comment on lines +68 to +72
if path.resolve().is_relative_to(self._base):
yield path

def iterdir(self) -> Generator[Path, None, None]:
for path in self._base.iterdir():
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.

@runningcode runningcode merged commit 017bbd3 into main May 19, 2026
25 checks passed
@runningcode runningcode deleted the no/safe-directory branch May 19, 2026 11:51
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 19, 2026

EME-1158

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants