Skip to content

Commit f3a3b5b

Browse files
committed
harden: reject hostless URLs in is_https_or_localhost_http
A URL without a hostname (e.g. https:///x) has no real target; reject it regardless of scheme. Folds main's hostless-HTTPS guard into the shared predicate so every download/redirect call site benefits.
1 parent 396004a commit f3a3b5b

2 files changed

Lines changed: 26 additions & 0 deletions

File tree

src/specify_cli/_download_security.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,18 @@ def is_https_or_localhost_http(url: str) -> bool:
4242
by the direct URL validations in the CLI download flows, so the rule (and
4343
any future tightening of it) lives in one place.
4444
45+
A hostname is always required: a URL without one (e.g. ``https:///x``)
46+
has no real target and is rejected regardless of scheme.
47+
4548
The loopback allowance is a deliberate *exact-string* match on
4649
``localhost`` / ``127.0.0.1`` / ``::1``, not an IP-range check: other
4750
loopback addresses (e.g. ``127.0.0.2``) are intentionally not covered.
4851
``urlparse`` already lower-cases the hostname, so the comparison is
4952
case-insensitive.
5053
"""
5154
parsed = urlparse(url)
55+
if not parsed.hostname:
56+
return False
5257
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
5358
return parsed.scheme == "https" or (parsed.scheme == "http" and is_localhost)
5459

tests/test_download_security.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111

1212
from specify_cli._download_security import (
13+
is_https_or_localhost_http,
1314
read_response_limited,
1415
read_zip_member_limited,
1516
safe_extract_zip,
@@ -25,6 +26,26 @@
2526
}
2627

2728

29+
@pytest.mark.parametrize(
30+
"url, allowed",
31+
[
32+
("https://example.com/preset.zip", True),
33+
("http://localhost:8000/preset.zip", True),
34+
("http://127.0.0.1/preset.zip", True),
35+
("http://[::1]/preset.zip", True),
36+
# Non-loopback HTTP is rejected.
37+
("http://example.com/preset.zip", False),
38+
# Loopback allowance is an exact-string match: 127.0.0.2 is not covered.
39+
("http://127.0.0.2/preset.zip", False),
40+
# A hostname is always required, even for HTTPS.
41+
("https:///preset.zip", False),
42+
("https://", False),
43+
],
44+
)
45+
def test_is_https_or_localhost_http(url, allowed):
46+
assert is_https_or_localhost_http(url) is allowed
47+
48+
2849
class _Response:
2950
"""Faithful stream stand-in: read() advances a cursor and returns b"" at EOF."""
3051

0 commit comments

Comments
 (0)