mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user