fix(apps): fail fast on invalid custom-app zip + zip-slip hardening#163
Merged
Conversation
Custom-app uploads are user input, but nothing inspected them beyond the file extension. Add app_zip.py as the single place that decides whether an archive is a plausible app: - validate_app_zip() requires app_meta.json + docker-compose.yml.template at the archive root, parses the manifest into AppMeta, and checks the app name is safe to use as a directory name and subdomain. - Files nested under a single top-level directory are accepted; that prefix is stripped. This is the shape a user gets from zipping a folder, and it is unambiguous enough to fix silently rather than reject. - extract_app_zip() writes members one by one and refuses any that resolve outside the target dir (zip slip), instead of a blind extractall(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every install path (store, upload, reinstall) unpacked with a blind extractall(), and a zip whose files sit in a top-level directory landed at installed_apps/<name>/<name>/, leaving the app permanently "Unknown App". Route all of them through extract_app_zip(), which strips that directory and rejects members escaping the app dir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The upload endpoint wrote the zip straight into installed_apps/<filename>/, created a DB row and queued an install task, and only then found out whether the archive was an app at all. A malformed upload therefore left an "Unknown App" row plus a directory behind, which a pilot user hit and which ended in core failing to start. Buffer the upload to a temp dir, validate it there, and only move it into installed_apps once it is known to be installable. The app name now comes from app_meta.json rather than the uploaded filename, so it no longer depends on what the user called the file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #157.
Custom-app upload validated nothing but the
.zipfile extension. The archive was written straight intoinstalled_apps/<uploaded-filename>/, a DB row was created and an install task queued — and only then did the worker find out whether the zip was an app at all. A zip whose files sat inside a top-level directory extracted toinstalled_apps/<name>/<name>/, so no manifest was found at the expected path and the app became "Unknown App", with a row and a directory already on disk. That is what a pilot user hit, and it ended in core failing to start.What changed
Validate before creating any state. The upload is buffered into a temp dir and inspected there. It must be a readable zip carrying
app_meta.jsonanddocker-compose.yml.templateat its root, and the manifest must parse intoAppMeta. Anything else gets a400with a specific message and leaves no row, no directory, no queued task. Only a valid archive is moved intoinstalled_apps/.Files under a single top-level directory are flattened, not rejected. That is the shape you get from zipping a folder — the pilot user's exact mistake — and it is unambiguous, so
extract_app_zipstrips the prefix rather than failing. macOS__MACOSX/resource forks and.DS_Storefiles are ignored; without that, a folder compressed in Finder looks like it has two top-level directories and the flattening would not have helped the very users this is for.App name comes from
app_meta.json, not from the uploaded filename.Zip-slip hardening.
extract_app_zipreplaces the blindextractall(): members are written one at a time and any whose resolved path is not inside the target dir is refused. Store installs and reinstalls go through the same extractor. The app name from the manifest is also path-checked, since it becomes a directory name and a subdomain.One note for the reviewer:
zipfile.extractall()sanitizes../members itself, so the original code was not actually exploitable by classic zip slip. The check still earns its place — my per-member extraction gives up that built-in sanitization, and I verified the check has teeth by removing it and watchingtest_extract_rejects_path_traversalwrite a file outside the app dir.Not addressed
Decompression bombs (a small zip declaring a huge
app_meta.json) are unchanged from before — out of scope here, happy to file it separately if you want it.Verification
tests/test_app_zip.py(24 unit tests) plus 5 new endpoint tests intests/test_app_installation.py, covering each acceptance criterion end-to-end: a nested zip installs and runs, a zip withoutapp_meta.jsongets a 400 and leaves no row or directory, and the name comes from the manifest. 42 tests pass acrosstest_app_zip,test_app_installation,test_app_store,test_service_init_apps,test_app_error.Recommended reading order
shard_core/service/app_installation/exceptions.py— newInvalidAppZipshard_core/service/app_installation/app_zip.py— the new validation + extraction moduleshard_core/service/app_installation/worker.py— extraction now goes through itshard_core/service/app_installation/__init__.py—install_app_from_uploaded_ziptakes the validated zipshard_core/web/protected/apps.py— the endpointtests/test_app_zip.py,tests/test_app_installation.py,agents.md🤖 Generated with Claude Code