Skip to content

Judge zip entry names, not standardized paths - #20

Merged
TerrifiedBug merged 1 commit into
mainfrom
fix/zip-containment
Sep 4, 2026
Merged

Judge zip entry names, not standardized paths#20
TerrifiedBug merged 1 commit into
mainfrom
fix/zip-containment

Conversation

@TerrifiedBug

Copy link
Copy Markdown
Owner

Follow-up to #19, found while verifying the updater against a stapled app rather than a merely signed one.

The containment check

ZipArchive.resolve compared two standardizedFileURL paths. That makes a security check depend on the filesystem and on Foundation's willingness to resolve the /tmp/private/tmp class of symlink — and it does so asymmetrically:

relative dest: root=/tmp/staple/out  target=/private/tmp/staple/out/yap.app  contained=false
absolute dest: root=/tmp/staple/out  target=/tmp/staple/out/yap.app          contained=true

So with a relative destination, an ordinary yap.app/ entry is rejected as an escape.

Nothing shipped was broken. Paths.updatesDirectory is absolute and it is the only caller; I hit this in a throwaway harness that passed a relative path. This is not a fix for a live defect — it is a rule that should not rest on the caller's spelling staying right forever.

Judging the components instead is symmetric, touches no filesystem, and says what is meant: every written path is the destination plus a sequence of plain names, with .., ., absolute names and empty names refused. Symlink entries were already refused, so together that is the whole containment argument — nothing inside the destination can point outside it.

The new test was confirmed to fail against the old implementation (escapingPath("bundle/MacOS/tool")) and pass against the new one.

Stapling, which was never covered

build-release.sh runs xattr -cr before signing, but stapler staple runs after, and ZipArchive deliberately drops every AppleDouble/xattr entry — so "nothing needs those sidecars" did not actually cover the notarization ticket. Checked properly, against the published notarized 0.2.0 build rather than a local unstapled one:

  • the ticket for a bundle is a regular file at Contents/CodeResources, not an extended attribute;
  • zipped with the release command, extracted through ZipArchive: stapler validate → "The validate action worked!";
  • codesign --verify --strict → valid on disk, satisfies its Designated Requirement;
  • spctl --assess --type executeaccepted, source=Notarized Developer ID;
  • diff -r against the original bundle → byte-identical tree, ticket included.

So an in-app update installs a still-notarized app, and Gatekeeper is satisfied offline.

48 tests.

Extraction containment compared two `standardizedFileURL` paths, which
makes a security check depend on the filesystem and on Foundation's
willingness to resolve the `/tmp` → `/private/tmp` class of symlink. It
does that asymmetrically: hand it a relative destination and an ordinary
`yap.app/` entry is rejected as an escape.

Nothing shipped was broken — `Paths.updatesDirectory` is absolute, and
that is the only caller — so this is not a fix for a live defect. It is
a rule that should not rest on the caller's spelling being right
forever. Judging the components is symmetric, touches no filesystem, and
says what is meant: every written path is the destination plus a
sequence of plain names, with `..`, `.`, absolute names and empty names
refused. Symlink entries were already refused, so together that is the
whole containment argument.

The test pins it and was confirmed to fail on the old implementation.

Also verified, because it was never covered: a *stapled* app survives
the round trip. The notarization ticket for a bundle is a regular file
at `Contents/CodeResources`, not an extended attribute, so dropping
AppleDouble sidecars does not drop it. Checked against the published
notarized 0.2.0 build rather than a local unstapled one — zipped with
the release command, extracted through `ZipArchive`, then
`stapler validate` passes, `spctl --assess` reports "accepted,
source=Notarized Developer ID", and the tree is byte-identical to the
original.
@TerrifiedBug
TerrifiedBug merged commit b511da1 into main Sep 4, 2026
1 check passed
@TerrifiedBug
TerrifiedBug deleted the fix/zip-containment branch September 4, 2026 07:47
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.

1 participant