Skip to content

Commit 74629bc

Browse files
committed
Reject malformed preset download URLs
Preset downloads should fail early when a URL lacks a hostname, even if the scheme is HTTPS. The redirect error now describes any disallowed target instead of implying that only non-HTTPS redirects are blocked.
1 parent 5a77805 commit 74629bc

2 files changed

Lines changed: 51 additions & 2 deletions

File tree

src/specify_cli/presets/_commands.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ def preset_add(
107107
_parsed = _urlparse(from_url)
108108

109109
def _is_allowed_download_url(parsed_url):
110-
host = parsed_url.hostname or ""
110+
host = parsed_url.hostname
111+
if not host:
112+
return False
111113
is_loopback = host == "localhost"
112114
if not is_loopback:
113115
try:
@@ -146,7 +148,11 @@ def _is_allowed_download_url(parsed_url):
146148
with response_context as response:
147149
final_url = response.geturl() if hasattr(response, "geturl") else from_url
148150
if not _is_allowed_download_url(_urlparse(final_url)):
149-
console.print(f"[red]Error:[/red] Preset URL redirected to non-HTTPS URL: {final_url}")
151+
console.print(
152+
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
153+
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
154+
"or HTTP for localhost/loopback."
155+
)
150156
raise typer.Exit(1)
151157
with zip_path.open("wb") as output:
152158
try:

tests/test_presets.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3877,6 +3877,49 @@ def fake_install_from_zip(self, zip_path, speckit_version, priority=10):
38773877
assert exc_info.value.exit_code == 1
38783878
assert installed is False
38793879

3880+
def test_preset_add_from_url_rejects_hostless_https_url(self, project_dir):
3881+
"""URL installs reject HTTPS URLs without a hostname before downloading."""
3882+
from typer.testing import CliRunner
3883+
from unittest.mock import patch
3884+
from specify_cli import app
3885+
3886+
runner = CliRunner()
3887+
with patch.object(Path, "cwd", return_value=project_dir), \
3888+
patch("specify_cli.authentication.http.open_url") as open_url:
3889+
result = runner.invoke(app, ["preset", "add", "--from", "https:///preset.zip"])
3890+
3891+
assert result.exit_code == 1
3892+
assert "URL must use HTTPS" in strip_ansi(result.output)
3893+
open_url.assert_not_called()
3894+
3895+
def test_preset_add_from_url_redirect_error_describes_disallowed_url(self, project_dir, monkeypatch, capsys):
3896+
"""Redirect rejection message covers hostless HTTPS, not only non-HTTPS URLs."""
3897+
import typer
3898+
from specify_cli.presets._commands import preset_add
3899+
3900+
class FakeResponse(io.BytesIO):
3901+
def __enter__(self):
3902+
return self
3903+
3904+
def __exit__(self, exc_type, exc, tb):
3905+
return False
3906+
3907+
def geturl(self):
3908+
return "https:///preset.zip"
3909+
3910+
monkeypatch.setattr("specify_cli._require_specify_project", lambda: project_dir)
3911+
monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.0")
3912+
monkeypatch.setattr("specify_cli.authentication.http.open_url", lambda url, timeout: FakeResponse(b"zip"))
3913+
monkeypatch.setattr(PresetManager, "install_from_zip", lambda *args, **kwargs: None)
3914+
3915+
with pytest.raises(typer.Exit) as exc_info:
3916+
preset_add(preset_id=None, from_url="https://example.com/preset.zip", dev=None, priority=10)
3917+
3918+
assert exc_info.value.exit_code == 1
3919+
output = strip_ansi(capsys.readouterr().out)
3920+
assert "redirected to a disallowed URL" in output
3921+
assert "must use HTTPS with a hostname" in output
3922+
38803923
def test_preset_add_from_url_streams_download_to_zip(self, project_dir, monkeypatch):
38813924
"""URL installs stream response bytes to disk before installing the ZIP."""
38823925
from specify_cli.presets._commands import preset_add

0 commit comments

Comments
 (0)