From 5d75366fd5be93b9cad2a26dd1ae8e0377fd2208 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 16:23:24 +0000 Subject: [PATCH] Validate post-redirect URL scheme before reading response body --- src/specify_cli/__init__.py | 30 ++++++++++++++++-------------- src/specify_cli/extensions.py | 26 ++++++++++++++++---------- src/specify_cli/presets.py | 26 ++++++++++++++++---------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 2304dabb3..95e442b3e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2637,6 +2637,14 @@ def preset_add( try: with urllib.request.urlopen(from_url, timeout=60) as response: final_url = response.geturl() + # Re-validate scheme after any redirect (scheme-downgrade + # guard). Check BEFORE reading the body so an insecure + # redirect cannot cause us to fetch the payload. + _fp = _urlparse(final_url) + _fl = _fp.hostname in ("localhost", "127.0.0.1", "::1") + if _fp.scheme != "https" and not (_fp.scheme == "http" and _fl): + console.print(f"[red]Error:[/red] URL was redirected to a non-HTTPS URL: {final_url}") + raise typer.Exit(1) content_type = response.headers.get("Content-Type", "") # Prefer the post-redirect URL for format detection; # fall back to the original URL only as a last hint. @@ -2648,13 +2656,6 @@ def preset_add( console.print(f"[red]Error:[/red] Failed to download: {e}") raise typer.Exit(1) - # Re-validate scheme after any redirect (scheme-downgrade guard). - _fp = _urlparse(final_url) - _fl = _fp.hostname in ("localhost", "127.0.0.1", "::1") - if _fp.scheme != "https" and not (_fp.scheme == "http" and _fl): - console.print(f"[red]Error:[/red] URL was redirected to a non-HTTPS URL: {final_url}") - raise typer.Exit(1) - if not archive_fmt: console.print("[red]Error:[/red] Could not determine archive format from URL or Content-Type.") console.print("Ensure the URL points to a .zip or .tar.gz/.tgz file.") @@ -3671,19 +3672,20 @@ def extension_add( try: with urllib.request.urlopen(from_url, timeout=60) as response: final_url = response.geturl() + # Re-validate scheme after any redirect (scheme-downgrade + # guard). Check BEFORE reading the body so an insecure + # redirect cannot cause us to fetch the payload. + _fp = urlparse(final_url) + _fl = _fp.hostname in ("localhost", "127.0.0.1", "::1") + if _fp.scheme != "https" and not (_fp.scheme == "http" and _fl): + console.print(f"[red]Error:[/red] URL was redirected to a non-HTTPS URL: {final_url}") + raise typer.Exit(1) content_type = response.headers.get("Content-Type", "") archive_fmt = detect_archive_format(final_url, content_type) if not archive_fmt: archive_fmt = detect_archive_format(from_url) archive_data = response.read() - # Re-validate scheme after any redirect (scheme-downgrade guard). - _fp = urlparse(final_url) - _fl = _fp.hostname in ("localhost", "127.0.0.1", "::1") - if _fp.scheme != "https" and not (_fp.scheme == "http" and _fl): - console.print(f"[red]Error:[/red] URL was redirected to a non-HTTPS URL: {final_url}") - raise typer.Exit(1) - if not archive_fmt: console.print("[red]Error:[/red] Could not determine archive format from URL or Content-Type.") console.print("Ensure the URL points to a .zip or .tar.gz/.tgz file.") diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index 489d19dea..d623cd327 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -2165,6 +2165,22 @@ class ExtensionCatalog: try: with self._open_url(download_url, timeout=60) as response: final_url = response.geturl() + # Re-validate scheme after any redirect to guard against + # scheme-downgrade. Validate BEFORE reading the body so a + # malicious redirect cannot cause us to fetch the payload + # over an insecure scheme. + _final_parsed = urlparse(final_url) + _final_is_localhost = _final_parsed.hostname in ( + "localhost", + "127.0.0.1", + "::1", + ) + if _final_parsed.scheme != "https" and not ( + _final_parsed.scheme == "http" and _final_is_localhost + ): + raise ExtensionError( + f"Extension download URL was redirected to a non-HTTPS URL: {final_url}" + ) content_type = response.headers.get("Content-Type", "") archive_fmt = detect_archive_format(final_url, content_type) if not archive_fmt: @@ -2176,16 +2192,6 @@ class ExtensionCatalog: except IOError as e: raise ExtensionError(f"Failed to read extension archive from {download_url}: {e}") - # Re-validate scheme after any redirect to guard against scheme-downgrade. - _final_parsed = urlparse(final_url) - _final_is_localhost = _final_parsed.hostname in ("localhost", "127.0.0.1", "::1") - if _final_parsed.scheme != "https" and not ( - _final_parsed.scheme == "http" and _final_is_localhost - ): - raise ExtensionError( - f"Extension download URL was redirected to a non-HTTPS URL: {final_url}" - ) - # Choose file extension based on detected format. if not archive_fmt: raise ExtensionError( diff --git a/src/specify_cli/presets.py b/src/specify_cli/presets.py index 12ab9dca0..e9fadc765 100644 --- a/src/specify_cli/presets.py +++ b/src/specify_cli/presets.py @@ -2321,6 +2321,22 @@ class PresetCatalog: try: with self._open_url(download_url, timeout=60) as response: final_url = response.geturl() + # Re-validate scheme after any redirect to guard against + # scheme-downgrade. Validate BEFORE reading the body so a + # malicious redirect cannot cause us to fetch the payload + # over an insecure scheme. + _final_parsed = urlparse(final_url) + _final_is_localhost = _final_parsed.hostname in ( + "localhost", + "127.0.0.1", + "::1", + ) + if _final_parsed.scheme != "https" and not ( + _final_parsed.scheme == "http" and _final_is_localhost + ): + raise PresetError( + f"Preset download URL was redirected to a non-HTTPS URL: {final_url}" + ) content_type = response.headers.get("Content-Type", "") archive_fmt = detect_archive_format(final_url, content_type) if not archive_fmt: @@ -2334,16 +2350,6 @@ class PresetCatalog: except IOError as e: raise PresetError(f"Failed to read preset archive from {download_url}: {e}") - # Re-validate scheme after any redirect to guard against scheme-downgrade. - _final_parsed = urlparse(final_url) - _final_is_localhost = _final_parsed.hostname in ("localhost", "127.0.0.1", "::1") - if _final_parsed.scheme != "https" and not ( - _final_parsed.scheme == "http" and _final_is_localhost - ): - raise PresetError( - f"Preset download URL was redirected to a non-HTTPS URL: {final_url}" - ) - # Choose file extension based on detected format. if not archive_fmt: raise PresetError(