harden: secure extension and preset archive downloads (#3141)

* harden: secure extension and preset archive downloads

Adopt the shared download-security primitives from #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)

* harden: close archive and catalog download edge cases

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)

* harden: address download security review feedback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* harden: close ZIP preflight review gaps

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update preflight and rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)

* fix: harden extension update rollback

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
This commit is contained in:
Pascal THUET
2026-07-28 07:52:07 -05:00
committed by GitHub
parent 0117a7b977
commit 118062eac4
24 changed files with 6637 additions and 434 deletions
+48 -24
View File
@@ -12,7 +12,6 @@ import json
import hashlib
import os
import tempfile
import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
@@ -27,6 +26,13 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .._download_security import (
MAX_JSON_CATALOG_BYTES,
build_safe_download_path,
is_https_or_localhost_http,
read_response_limited,
safe_extract_zip,
)
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
from .._init_options import (
MISSING_INIT_OPTIONS_FILE,
@@ -3523,18 +3529,7 @@ class PresetManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
with zipfile.ZipFile(zip_path, 'r') as zf:
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise PresetValidationError(
f"Unsafe path in ZIP archive: {member} "
"(potential path traversal)"
)
zf.extractall(temp_path)
safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
pack_dir = temp_path
manifest_path = pack_dir / "preset.yml"
@@ -4270,7 +4265,14 @@ class PresetCatalog:
final_url = response.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
catalog_data = json.loads(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=PresetError,
label=f"preset catalog {entry.url}",
)
)
self._validate_catalog_payload(catalog_data, entry.url)
@@ -4432,7 +4434,14 @@ class PresetCatalog:
final_url = response.geturl()
if final_url != catalog_url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
catalog_data = json.loads(
read_response_limited(
response,
max_bytes=MAX_JSON_CATALOG_BYTES,
error_type=PresetError,
label=f"preset catalog {catalog_url}",
)
)
# Validate catalog structure. Reuses the same helper as
# ``_fetch_single_catalog`` so all three branches (root type,
@@ -4593,6 +4602,10 @@ class PresetCatalog:
raise PresetError(
f"Preset '{pack_id}' has no download URL"
)
if not isinstance(download_url, str):
raise PresetError(
f"Preset download URL is malformed: {download_url}"
)
from urllib.parse import urlparse
@@ -4605,25 +4618,32 @@ class PresetCatalog:
try:
parsed = urlparse(download_url)
hostname = parsed.hostname
parsed.port
except ValueError:
raise PresetError(
f"Preset download URL is malformed: {download_url}"
) from None
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (
parsed.scheme == "http" and is_localhost
):
if not hostname:
raise PresetError(
f"Preset download URL is malformed: {download_url}"
)
if not is_https_or_localhost_http(download_url):
raise PresetError(
f"Preset download URL must use HTTPS: {download_url}"
)
if target_dir is None:
target_dir = self.cache_dir / "downloads"
target_dir.mkdir(parents=True, exist_ok=True)
target_dir = Path(target_dir)
version = pack_info.get("version", "unknown")
zip_filename = f"{pack_id}-{version}.zip"
zip_path = target_dir / zip_filename
zip_path = build_safe_download_path(
target_dir,
pack_id,
version,
error_type=PresetError,
label="preset",
)
target_dir.mkdir(parents=True, exist_ok=True)
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
@@ -4633,7 +4653,11 @@ class PresetCatalog:
try:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
zip_data = read_response_limited(
response,
error_type=PresetError,
label=f"preset '{pack_id}' download",
)
verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError
+17 -11
View File
@@ -19,6 +19,7 @@ from .._console import console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
read_response_limited,
)
preset_app = typer.Typer(
@@ -126,15 +127,15 @@ def preset_add(
if not is_https_or_localhost_http(from_url):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
"[red]Error:[/red] URL must use HTTPS with a hostname and be "
"a valid URL with a host. HTTP is only allowed for localhost, "
"127.0.0.1, and ::1."
)
raise typer.Exit(1)
console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...")
import urllib.error
import tempfile
import shutil
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
@@ -162,16 +163,21 @@ def preset_add(
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
"or HTTP for localhost (127.0.0.1, ::1)."
)
raise typer.Exit(1)
with zip_path.open("wb") as output:
try:
shutil.copyfileobj(response, output)
except TypeError:
output.write(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {_escape_markup(str(e))}")
zip_path.write_bytes(
read_response_limited(
response,
error_type=PresetError,
label=f"preset {from_url}",
)
)
except (urllib.error.URLError, PresetError) as e:
console.print(
f"[red]Error:[/red] Failed to download: "
f"{_escape_markup(str(e))}"
)
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)