fix: harden bounded reads and redirect validation (#3671)

Assisted-by: OpenAI Codex (model: GPT-5, autonomous)
This commit is contained in:
Pascal THUET
2026-07-22 22:18:10 +02:00
committed by GitHub
parent c0f4cee25a
commit 3a7a8758f7
10 changed files with 557 additions and 143 deletions

View File

@@ -20,6 +20,10 @@ import yaml
from rich.markup import escape as _escape_markup
from .._console import console, err_console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
)
from .._project import _resolve_init_dir_override
workflow_app = typer.Typer(
@@ -383,27 +387,12 @@ _RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"}
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
"""Reject insecure redirects before they are followed."""
import urllib.error
from ipaddress import ip_address
from urllib.parse import urlparse
def _is_loopback_http(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "http":
return False
host = parsed.hostname or ""
if host == "localhost":
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
if urlparse(new_url).scheme == "https":
return
if _is_loopback_http(old_url) and _is_loopback_http(new_url):
if is_safe_download_redirect(old_url, new_url):
return
raise urllib.error.URLError(
"redirect target must use HTTPS; loopback HTTP may only redirect from loopback HTTP"
"redirect target must use HTTPS without entering a local target; "
"loopback HTTP may only redirect from another loopback URL"
)
@@ -1579,24 +1568,15 @@ def workflow_add(
else (source if source.startswith(("http://", "https://")) else None)
)
if download_url is not None:
from ipaddress import ip_address
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
try:
parsed_src = urlparse(download_url)
urlparse(download_url).port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(download_url)}")
raise typer.Exit(1)
src_host = parsed_src.hostname or ""
src_loopback = src_host == "localhost"
if not src_loopback:
try:
src_loopback = ip_address(src_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a DNS name); keep default non-loopback.
pass
if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
if not is_https_or_localhost_http(download_url):
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
raise typer.Exit(1)
@@ -1647,16 +1627,7 @@ def workflow_add(
redirect_validator=_reject_insecure_download_redirect,
) as resp:
final_url = resp.geturl()
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
final_lb = final_host == "localhost"
if not final_lb:
try:
final_lb = ip_address(final_host).is_loopback
except ValueError:
# Redirect host is not an IP literal; keep loopback as determined above.
pass
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
if not is_https_or_localhost_http(final_url):
console.print(
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
)
@@ -1788,27 +1759,17 @@ def _install_workflow_from_catalog(
raise typer.Exit(1)
# Validate URL scheme (HTTPS required, HTTP allowed for localhost only)
from ipaddress import ip_address
from urllib.parse import urlparse
try:
parsed_url = urlparse(workflow_url)
url_host = parsed_url.hostname or ""
parsed_url.port
except ValueError:
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL."
)
raise typer.Exit(1)
is_loopback = False
if url_host == "localhost":
is_loopback = True
else:
try:
is_loopback = ip_address(url_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
if not is_https_or_localhost_http(workflow_url):
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has an invalid install URL. "
"Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
@@ -1862,16 +1823,7 @@ def _install_workflow_from_catalog(
) as response:
# Validate final URL after redirects
final_url = response.geturl()
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
final_loopback = final_host == "localhost"
if not final_loopback:
try:
final_loopback = ip_address(final_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
if not is_https_or_localhost_http(final_url):
_safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before)
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
@@ -2694,28 +2646,17 @@ def workflow_step_add(
)
raise typer.Exit(1)
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
def _safe_fetch(url: str) -> bytes:
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
if not is_https_or_localhost_http(url):
raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}")
if not parsed.hostname:
raise ValueError(f"Refusing to fetch from URL with no hostname: {url}")
with _open_url(
url, timeout=30, redirect_validator=_reject_insecure_download_redirect
) as resp:
final_url = resp.geturl()
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
):
if not is_https_or_localhost_http(final_url):
raise ValueError(f"Redirect to non-HTTPS URL: {final_url}")
if not final_parsed.hostname:
raise ValueError(f"Redirect to URL with no hostname: {final_url}")
return _read_response_within_limit(resp)
_validate_step_id_or_exit(step_id)