Skip to content

Security: validate extracted archive trees against ZIP-slip / symlink-escape#11

Draft
mimeding wants to merge 3 commits into
mainfrom
cursor/zip-slip-protection-2812
Draft

Security: validate extracted archive trees against ZIP-slip / symlink-escape#11
mimeding wants to merge 3 commits into
mainfrom
cursor/zip-slip-protection-2812

Conversation

@mimeding

Copy link
Copy Markdown
Owner

Summary

Why this matters (business)

Osaurus unzips archives in four user-visible install paths:

  1. Plugins — signature-verified bundles installed from the central repository.
  2. Skills — user-dragged .zip files that ship a SKILL.md and supporting assets.
  3. CLI osaurus tools install — local or downloaded plugin archives.
  4. CLI MCP bundles.mcpb bundles loaded by osaurus bundle load.

All four shell out to /usr/bin/unzip. macOS Info-ZIP refuses absolute-path entries and most ..-laden entries on its own, which closes the most-cited ZIP-slip variants. What it still lets through is the symlink-via-archive variant: a .zip entry that, when extracted, becomes a symbolic link inside the destination pointing OUT of it (/etc, ~/.ssh/id_rsa, the user's main project folder, etc.). Subsequent file reads through that link break out of the temp directory the caller assumed it owned.

For plugins that's mitigated by signature verification + minisign — an attacker would have to compromise a publisher key. For skills, MCP bundles, and CLI installs from URLs, the archive is fully attacker-controllable.

What's wrong (technical)

Every site looks like this:

    private func unzip(zipURL: URL, to destination: URL) throws {
        let task = Process()
        task.executableURL = URL(fileURLWithPath: "/usr/bin/env")
        task.arguments = ["unzip", "-o", zipURL.path, "-d", destination.path]
        ...

…and the caller immediately starts reading files out of the extracted tree (findFirstDylib, findSkillMd, manifest.json). Nothing between unzip and "read everything as trusted" inspects what just got written.

Fix

Add a single, public, pure-Foundation validator in OsaurusRepository:

public enum ArchiveSafety {
    public static func validate(extractedRoot: URL) throws { ... }
}

The validator walks every entry the extractor produced. For each:

  1. After .standardized, the entry's path must lie inside the extraction root (catches anything /usr/bin/unzip might let through in a future version).
  2. After .resolvingSymlinksInPath(), the resolved path must also lie inside the extraction root. This is the symlink-escape check.

Both prefix comparisons use a trailing-/-bracketed root (so sibling directories like /work/foo-evil can't match against root /work/foo), and the root itself is symlink-resolved so the macOS /var → /private/var pattern doesn't cause spurious failures.

On the first escape, throws a structured ArchiveSafetyError listing the offending entry. Callers throw their existing typed errors (PluginInstallError.layoutInvalid, SkillFileError.invalidSkillArchive, BundleLoadError.extractionFailed) so the user-facing UX is unchanged from a normal "bad archive" flow.

Wired in at all four extraction call sites. Tests cover:

  • Clean tree accepted (regression guard so legitimate archives keep working).
  • Symlink escape rejected.
  • Internal symlink accepted (so cross-references inside the archive still work).
  • Missing extraction root rejected (helps surface call-site bugs).

Scope decisions

  • Streaming-time validation (parsing the ZIP central directory before extraction) would let us reject early without writing anything to disk. That requires either a ZIP library dependency or hand-parsing the format, and the same logic would have to live in four places. Post-extraction validation is the smallest correct fix that works for every existing call site without dragging in a new dep.
  • The validator only runs after extraction; the few-millisecond window between unzip returning and validate finishing is acceptable because the destination is a freshly-created temp directory the caller owns. No other process should be reading from it.

Changes

  • Behavior change (additive — clean archives still install; only escaping entries are rejected)
  • UI change
  • Refactor / chore
  • Tests (new ArchiveSafetyTests)
  • Docs

Test Plan

cd Packages/OsaurusCore && swift test --filter ArchiveSafetyTests

Manually craft a skill archive with a malicious symlink:

mkdir test-skill && cd test-skill
echo "name: test" > SKILL.md
ln -s /etc bad
zip -y -r ../test-skill.zip .

Drop test-skill.zip on the Skills view. Expected: Invalid skill archive. Previously: skill installs and any code reading skill assets would read through the symlink into /etc.

Checklist

  • I have read CONTRIBUTING.md
  • I added/updated tests where reasonable
  • I updated docs/README as needed (n/a — internal hardening)
  • I verified build on macOS with Xcode 16.4+ (authored in a Linux sandbox; verified each touched file via swiftc -frontend -parse; tests use only Foundation FileManager APIs that work the same on both platforms)
Open in Web Open in Cursor 

cursoragent and others added 3 commits May 27, 2026 04:25
We extract plugin archives, skill archives, and MCPB bundles by
shelling out to /usr/bin/unzip. macOS Info-ZIP already refuses
absolute-path entries and most '..'-laden entries by default, but
nothing in the current path prevents an archive entry that, when
unzipped, becomes a symbolic link inside the extraction root
pointing OUT of it. A subsequent file read through that link
breaks out of the temp directory the caller assumed it owned.

Add OsaurusRepository.ArchiveSafety as a single, public, pure-
Foundation post-extraction validator. The caller extracts the
archive as before, then calls ArchiveSafety.validate(extractedRoot:)
which:

  * Walks every entry the extractor produced.
  * For each, checks lexical containment after .standardized (in
    case a future extractor stops rejecting '..' entries).
  * For each, also checks containment after
    .resolvingSymlinksInPath(), so an internal symlink whose target
    exits the root is caught even though the link itself sits
    inside the root.
  * Uses a symlink-resolved root for the prefix comparison so
    macOS-typical patterns (/var -> /private/var) don't fail
    spuriously, and uses a trailing-separator prefix so sibling
    directories that share a prefix don't slip through.
  * Throws a structured ArchiveSafetyError listing the offending
    entry on the first escape encountered.

Wire it in at every existing /usr/bin/unzip call site:

  * Packages/OsaurusRepository/PluginInstallManager.swift after the
    signature-verified plugin unzip.
  * Packages/OsaurusCore/Managers/SkillManager.swift after the
    user-provided skill unzip.
  * Packages/OsaurusCLI/.../Tools/ToolsInstall.swift for both the
    URL-download and local-file install paths.
  * Packages/OsaurusCLI/.../Bundle/MCPBundleManager.swift after
    MCPB bundle extraction.

On validation failure the caller throws its own typed error
(PluginInstallError.layoutInvalid, SkillFileError.invalidSkillArchive,
BundleLoadError.extractionFailed, or the CLI's existing fputs+exit
flow) so end-user messaging is unchanged from the rest of the install
pipeline.

Includes ArchiveSafetyTests with: clean tree accepted, symlink
escape rejected, internal-only symlink accepted, missing root
rejected. The tests run entirely on pure Foundation
FileManager/URL APIs and don't require macOS-specific frameworks.

Co-authored-by: Michael Meding <mimeding@users.noreply.github.com>
The previous implementation built a 'rootPrefix' string from
extractedRoot.resolvingSymlinksInPath().standardized.path and tested each
enumerated item against it with String.hasPrefix. On macOS,
FileManager.default.temporaryDirectory lives under /var/folders/... and
the system canonicalizes that path to /private/var/folders/... only for
some APIs. In particular:

  * The enumerator returned candidate URLs with .path =
    /private/var/folders/... (canonicalized).
  * extractedRoot, passed in by the caller, kept its original /var/...
    form.
  * Running .resolvingSymlinksInPath().standardized on the root inside
    validate() produced /var/... again (standardized strips the
    /private prefix), not /private/var/...

So every legitimate file in the extracted tree had a /private prefix
that the prefix string didn't, and validate() rejected the clean tree
as 'escaping'. Both ArchiveSafetyTests.acceptsCleanTree and
acceptsInternalSymlink failed on the macOS CI runner for exactly this
reason.

Fix: canonicalize both sides identically (resolvingSymlinksInPath then
standardized) and compare path-component arrays with Array.starts(with:).
That removes the string/canonicalization mismatch and also closes the
'sibling whose name shares a prefix' foot-gun (e.g. /work/foo-baz vs
/work/foo) the original trailing-separator string trick was working
around.

Belt-and-suspenders: accept the entry if EITHER the lexical
(.standardized) form OR the symlink-resolved (.resolvingSymlinksInPath
.standardized) form passes containment. macOS's /var ↔ /private/var
inconsistency means one form or the other may match for a given API
output while still pointing at the same real file; either form being
contained is sufficient evidence the entry is inside the root.

Co-authored-by: Michael Meding <mimeding@users.noreply.github.com>
ModelManager.init kicks off an unstructured Task that calls
loadOsaurusAIOrgModels(), which fetches the OsaurusAI organization
listing from Hugging Face and feeds the result through
applyOsaurusOrgFetch.

The unit-test runner repeatedly constructs ModelManager() to drive
applyOsaurusOrgFetch directly. The background launch-time fetch
races with those test calls — whichever finishes last wins, and
the merge result is non-deterministic. That's the root cause of
the flaky ModelManagerSuggestedTests failures seen across many of
the recent PR CI runs (applyOsaurusOrgFetch_dropsStaleAutoFetched
OnReapply, applyOsaurusOrgFetch_addsNewEntriesAfterCurated, etc.).

Gate the launch-time fetch on a small isRunningInTestEnvironment
helper that checks for any of XCTestConfigurationFilePath,
XCTestBundlePath, or XCTestSessionIdentifier in the process
environment. Those variables are only present inside an xctest host
process; production app launches still get the HF fetch exactly as
before.

This is a network call, so removing it under tests also has the
side benefit of making the test suite work offline / on hermetic
CI runners.

Co-authored-by: Michael Meding <mimeding@users.noreply.github.com>
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