harden: secure extension and preset archive downloads#3141
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens extension/preset/catalog download and extraction paths by introducing bounded reads, optional SHA-256 verification, and safer ZIP handling, then wiring those protections into the extension/preset install flows and associated tests.
Changes:
- Add
_download_securityhelpers for strict URL validation, bounded HTTP reads, SHA-256 verification, bounded ZIP-member reads, and safe ZIP extraction (zip-slip/zip-bomb/symlink defenses). - Update extension and preset download/install flows to use bounded reads, checksum verification (when
sha256is present), andsafe_extract_zip. - Update auth redirect handling and tests to reflect strict redirect enforcement and stream-like
read(size)semantics.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/_download_security.py |
New security primitives: bounded reads, ZIP extraction/member limits, checksum verification, scheme/host predicate. |
src/specify_cli/authentication/http.py |
Strict redirect validation integrated into redirect handler and exposed via open_url(..., strict_redirects=...). |
src/specify_cli/authentication/azure_devops.py |
Bounded token-response reads and strict redirect protection for client-credentials POST. |
src/specify_cli/_version.py |
Latest-release metadata fetch now uses bounded reads and strict redirects. |
src/specify_cli/_github_http.py |
GitHub release metadata resolution now uses bounded reads to avoid unbounded JSON loads. |
src/specify_cli/catalogs.py |
Catalog URL validation rejects hostless URLs before scheme checks. |
src/specify_cli/extensions/__init__.py |
Extension ZIP installs use safe_extract_zip; catalog and ZIP downloads are bounded and optionally checksum-verified. |
src/specify_cli/extensions/_commands.py |
Extension manifest pre-read from ZIP is now bounded (read_zip_member_limited). |
src/specify_cli/presets/__init__.py |
Preset ZIP installs use safe_extract_zip; catalog and ZIP downloads are bounded and optionally checksum-verified. |
src/specify_cli/presets/_commands.py |
Shared URL predicate for download/redirect validation; preset ZIP downloads use bounded reads and strict redirects for GitHub metadata resolution. |
tests/test_download_security.py |
New tests covering bounded reads, checksum verification, safe ZIP extraction, and bounded ZIP member reads. |
tests/test_authentication.py |
Tests updated for strict redirect rejection and bounded read semantics; Azure AD token tests patch opener behavior. |
tests/test_github_http.py |
Tests updated for stream-like reads; add multi-hop GitHub redirect auth-preservation test. |
tests/http_helpers.py |
Stream-backed read() mocks and an autouse fixture to route build_opener().open() through patched urlopen. |
tests/test_upgrade.py |
Add regression test asserting _fetch_latest_release_tag reads via read_response_limited(max_bytes=...). |
tests/self_upgrade_helpers.py |
Re-export the opener-routing fixture for self-upgrade test modules. |
tests/test_self_upgrade_detection.py |
Enable opener-routing fixture to keep existing urlopen patches effective. |
tests/test_self_upgrade_execution.py |
Enable opener-routing fixture to keep existing urlopen patches effective. |
tests/test_self_upgrade_verification.py |
Enable opener-routing fixture to keep existing urlopen patches effective. |
tests/test_extensions.py |
Stream-like read(size) mocks and one assertion update for host-before-scheme URL validation. |
tests/test_presets.py |
Stream-like read(size) mocks and open_url fake signature updated for strict_redirects. |
tests/integrations/test_integration_catalog.py |
One assertion updated for host-before-scheme URL validation. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 22/22 changed files
- Comments generated: 2
|
Converting to draft as #3140 has to land first |
Adopt the shared download-security primitives from github#3140 across extension and preset catalog, package, direct-URL, and ZIP-install flows: - bound catalog, package, and inline manifest reads; - verify catalog SHA-256 values when present; - replace path-only extraction with bounded traversal/symlink-safe extraction; - validate malformed hosts and ports before opening download URLs; - handle normalized trailing-backslash directory entries consistently. Redirect enforcement and checksum verification remain owned by the shared helpers already on main; this commit wires them into extension and preset behavior. Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
Preflight ZIP central directories before ZipFile allocates them, bound both declared and actual payload sizes, and reject ambiguous or non-portable archive paths before extraction. Keep extension update manifest selection consistent with extraction, reject unsafe catalog-derived output filenames and malformed URL types, and escape untrusted values in download errors. Add regression coverage for parser differentials, collisions, platform-specific filenames, bounded call sites, and failure ordering. Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
99773ba to
b52570e
Compare
|
Rebased this PR onto current The refreshed diff removes the duplicated #3140 history, retains the shared redirect/checksum behavior from Validation on the published tree: full Python 3.12 suite Posted on behalf of @PascalThuet by OpenAI Codex (model: GPT-5). |
| for name in namelist: | ||
| normalized_name = name.replace("\\", "/") | ||
| parts = normalized_name.split("/") | ||
| path_key = portable_zip_path_key(normalized_name) |
| reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ") | ||
| if ( | ||
| len(part.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES | ||
| or any(ord(character) < 32 for character in part) |
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback. Also what about workflow, workflow steps and bundle downloads? Do they need this too?
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
| filename_size, extra_size, comment_size = struct.unpack_from( | ||
| "<HHH", header, 28 | ||
| ) | ||
| variable_size = filename_size + extra_size + comment_size | ||
| record_size = _ZIP_CENTRAL_HEADER_SIZE + variable_size | ||
| if record_size > remaining: | ||
| _raise(error_type, f"Invalid ZIP archive: {zip_path}") | ||
| archive_file.seek(variable_size, 1) |
|
Please address Copilot feedback and fix test & lint errors |
Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
Originally rebased onto
mainat4d3a428. The latest head was also validated by a clean synthetic merge with currentmain(c0fe0e4). #3140 and #3671 are merged, so this PR reuses their shared URL, redirect, bounded-read, and checksum primitives instead of carrying the #3140 commit.What
ZipFilematerializes entries, including declared/actual entry counts, a central-directory size cap, and archive-level or per-entry ZIP64 indicators.Security and compatibility boundaries
ZipFilebecause the standard-library read path cannot enforce the requested decompressed-output bound for those methods.Validation
main(c0fe0e4):5418 passed, 172 skipped.5355 passed, 166 skipped.1766 passed.162 passed.uvx ruff@0.15.0 check src tests: clean.git diff --check: clean.Deliberately out of scope
Authorship disclosure: updated on behalf of @PascalThuet by OpenAI Codex (model: GPT-5).