Skip to content

Commit cce47f6

Browse files
Noor-ul-ain001claudeCopilot
authored
fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
* fix(cli): guard lazy .hostname ValueError in extension/preset add --from `extension add --from <url>` and `preset add --from <url>` validated the URL by reading `parsed.hostname` OUTSIDE their `try/except ValueError` guards. A bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/x.zip") parses cleanly under urlparse() on Python < 3.14 and only raises ValueError lazily on the first .hostname access. On the interpreters spec-kit supports (>=3.11) that raw ValueError leaked past the CLI, printing an uncaught traceback instead of the clean "Invalid URL" error. (The raise moved eager into urlparse() only in 3.14.) Same bug class as the catalog/download fixes #3433/#3435/#3437/#3577. - extensions/_commands.py: read parsed.hostname inside the existing try and reuse it for the localhost check. - presets/_commands.py: guard the up-front `urlparse(from_url).hostname` read (preserves the "Invalid URL" message), and harden the nested `_is_allowed_download_url` to take a URL string and parse+read .hostname inside its own try/except -> returns False on malformed input. This also covers the redirect-validator and final-URL (post-redirect) checks, where the URL is server-controlled. Regression tests for each command: a bracketed-non-IP URL, plus a monkeypatched lazy-.hostname raiser that reproduces the pre-3.14 shape independently of the running interpreter (fails with a raw ValueError before the fix, verified via test-the-test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(cli): address Copilot review on --from URL guard comments/tests Copilot's review on #3651 flagged two accuracy problems: 1. The guard comments asserted a specific (and incorrect) CPython version history -- that "https://[not-an-ip]/..." parses cleanly under urlparse() on Python < 3.14 and only raises ValueError lazily on the first .hostname access. In fact the eager bracketed-host check (gh-103848, CVE-2024-11168) was backported to the 3.11 branch and shipped in 3.11.4, so on every interpreter spec-kit supports (>=3.11) that URL is rejected eagerly at urlparse(). Reworded the three source comments to state the guard as a defensive policy (parsing OR the .hostname read can raise ValueError, guard both) without asserting version history. 2. The two monkeypatched lazy-.hostname tests were described as reproducing "the exact production path" / "the Python < 3.14 shape". They are synthetic defensive cases. Relabeled them as synthetic defensive coverage that does not reproduce any specific CPython behavior, and dropped the version-history claims from the bracketed-non-IP test docstrings. The second-round suggestion (_is_allowed_download_url(final_url) instead of _is_allowed_download_url(_urlparse(final_url))) was already applied in the original commit. Behavior unchanged; comments/docstrings only. URL-guard tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent e14561f commit cce47f6

3 files changed

Lines changed: 134 additions & 1 deletion

File tree

src/specify_cli/extensions/_commands.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,10 +428,17 @@ def extension_add(
428428

429429
try:
430430
parsed = urlparse(from_url)
431+
# Read .hostname inside the try: parsing a malformed authority -- or
432+
# accessing .hostname on one, e.g. an invalid bracketed IPv6 host like
433+
# "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the
434+
# parse and the .hostname read inside the guard surfaces a clean
435+
# "Invalid URL" message instead of leaking a raw traceback past the
436+
# CLI. Reuse the value below.
437+
hostname = parsed.hostname
431438
except ValueError:
432439
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
433440
raise typer.Exit(1)
434-
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
441+
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
435442

436443
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
437444
console.print("[red]Error:[/red] URL must use HTTPS for security.")

tests/test_extensions.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6607,6 +6607,80 @@ def test_add_from_malformed_ipv6_url_exits_cleanly(self, tmp_path):
66076607
plain = strip_ansi(result.output)
66086608
assert "Invalid URL" in plain
66096609

6610+
def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path):
6611+
"""A bracketed-but-invalid IPv6 host must produce a clean error, not a
6612+
ValueError traceback. "https://[not-an-ip]/ext.zip" is a malformed
6613+
authority that raises ValueError during URL validation; the try/except
6614+
guard around parsing and the .hostname read must turn that into a clean
6615+
"Invalid URL" message.
6616+
"""
6617+
from typer.testing import CliRunner
6618+
from unittest.mock import patch
6619+
from specify_cli import app
6620+
6621+
project_dir = tmp_path / "test-project"
6622+
project_dir.mkdir()
6623+
(project_dir / ".specify").mkdir()
6624+
6625+
runner = CliRunner()
6626+
with patch.object(Path, "cwd", return_value=project_dir):
6627+
result = runner.invoke(
6628+
app,
6629+
["extension", "add", "my-ext", "--from", "https://[not-an-ip]/ext.zip"],
6630+
catch_exceptions=True,
6631+
)
6632+
6633+
assert result.exit_code == 1
6634+
assert result.exception is None or isinstance(result.exception, SystemExit)
6635+
plain = strip_ansi(result.output)
6636+
assert "Invalid URL" in plain
6637+
6638+
def test_add_from_url_lazy_hostname_valueerror_exits_cleanly(self, tmp_path, monkeypatch):
6639+
"""Synthetic defensive coverage: monkeypatch urlparse() to return an
6640+
object whose .hostname raises ValueError lazily. This does not reproduce
6641+
any specific CPython behavior -- it just exercises the case where the
6642+
ValueError surfaces on the .hostname read rather than at parse time, so a
6643+
raw ValueError would leak if .hostname were read outside the try/except.
6644+
"""
6645+
import urllib.parse
6646+
from typer.testing import CliRunner
6647+
from unittest.mock import patch
6648+
from specify_cli import app
6649+
6650+
real_urlparse = urllib.parse.urlparse
6651+
6652+
class _LazyHostnameRaiser:
6653+
def __init__(self, parsed):
6654+
self._parsed = parsed
6655+
6656+
@property
6657+
def hostname(self):
6658+
raise ValueError("simulated lazy IPv6 hostname failure")
6659+
6660+
def __getattr__(self, name):
6661+
return getattr(self._parsed, name)
6662+
6663+
def _fake_urlparse(url, *args, **kwargs):
6664+
return _LazyHostnameRaiser(real_urlparse(url, *args, **kwargs))
6665+
6666+
monkeypatch.setattr(urllib.parse, "urlparse", _fake_urlparse)
6667+
6668+
project_dir = tmp_path / "test-project"
6669+
project_dir.mkdir()
6670+
(project_dir / ".specify").mkdir()
6671+
6672+
runner = CliRunner()
6673+
with patch.object(Path, "cwd", return_value=project_dir):
6674+
result = runner.invoke(
6675+
app,
6676+
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
6677+
catch_exceptions=True,
6678+
)
6679+
6680+
assert result.exit_code == 1
6681+
assert result.exception is None or isinstance(result.exception, SystemExit)
6682+
assert "Invalid URL" in strip_ansi(result.output)
6683+
66106684
def test_add_status_escapes_extension_markup(self, tmp_path):
66116685
"""User-controlled extension names must not be parsed as Rich markup."""
66126686
from rich.markup import escape as escape_markup

tests/test_presets.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5605,6 +5605,58 @@ def test_preset_add_from_malformed_ipv6_url_exits_cleanly(self, project_dir):
56055605
assert "Invalid URL" in output
56065606
open_url.assert_not_called()
56075607

5608+
def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir):
5609+
"""A bracketed-but-invalid IPv6 host in --from must exit cleanly.
5610+
5611+
"https://[not-an-ip]/preset.zip" is a malformed authority that raises
5612+
ValueError during URL validation; the try/except guard around parsing
5613+
and the .hostname read must turn that into a clean "Invalid URL" message.
5614+
"""
5615+
from typer.testing import CliRunner
5616+
from unittest.mock import patch
5617+
from specify_cli import app
5618+
5619+
runner = CliRunner()
5620+
with patch.object(Path, "cwd", return_value=project_dir), \
5621+
patch("specify_cli.authentication.http.open_url") as open_url:
5622+
result = runner.invoke(
5623+
app,
5624+
["preset", "add", "--from", "https://[not-an-ip]/preset.zip"],
5625+
catch_exceptions=True,
5626+
)
5627+
5628+
assert result.exit_code == 1
5629+
assert result.exception is None or isinstance(result.exception, SystemExit)
5630+
output = strip_ansi(result.output)
5631+
assert "Invalid URL" in output
5632+
open_url.assert_not_called()
5633+
5634+
def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir):
5635+
"""An out-of-range port raises ValueError lazily on .port access.
5636+
5637+
The up-front guard reads ``_parsed.port`` (urllib validates the port
5638+
range/syntax there) inside its try/except, so "https://example.com:99999/
5639+
preset.zip" must produce a clean "Invalid URL" message rather than
5640+
leaking a raw ValueError traceback past the CLI.
5641+
"""
5642+
from typer.testing import CliRunner
5643+
from unittest.mock import patch
5644+
from specify_cli import app
5645+
5646+
runner = CliRunner()
5647+
with patch.object(Path, "cwd", return_value=project_dir), \
5648+
patch("specify_cli.authentication.http.open_url") as open_url:
5649+
result = runner.invoke(
5650+
app,
5651+
["preset", "add", "--from", "https://example.com:99999/preset.zip"],
5652+
catch_exceptions=True,
5653+
)
5654+
5655+
assert result.exit_code == 1
5656+
assert result.exception is None or isinstance(result.exception, SystemExit)
5657+
assert "Invalid URL" in strip_ansi(result.output)
5658+
open_url.assert_not_called()
5659+
56085660
def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir):
56095661
"""A catalog download_url with a bracketed non-IP host must render cleanly.
56105662

0 commit comments

Comments
 (0)