Skip to content

Commit 36ad3cd

Browse files
darion-yaphetOmX
andauthored
fix(presets): harden preset URL installs against unsafe redirects (#2911)
* Harden preset URL installs against unsafe redirects Preset URL installs already rejected non-HTTPS source URLs, but the authenticated opener follows redirects. Validate the final response URL before writing the ZIP, preserve GitHub release asset URL resolution after the preset command module split, stream the response to disk, and keep catalog config serialization on safe YAML output. Constraint: open_url follows redirects, so source URL validation alone does not constrain the downloaded target Rejected: Keep response.read() for simplicity | large preset downloads should not be buffered entirely in memory Confidence: high Scope-risk: narrow Directive: Keep preset URL policy aligned with workflow installer redirect validation Tested: uvx ruff check src/specify_cli/__init__.py src/specify_cli/presets/__init__.py src/specify_cli/presets/_commands.py tests/test_presets.py Tested: uv run pytest tests/test_presets.py -q Not-tested: Real network redirect integration against a live HTTP server Co-authored-by: OmX <omx@oh-my-codex.dev> * 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. * Prevent credentialed preset redirects from downgrading transport Preset URL downloads already checked the final URL after urllib followed redirects, but that was too late for authenticated requests because same-host redirects could preserve Authorization during the redirect itself. The authenticated HTTP helper now supports an opt-in redirect validator, and preset downloads use it to reject disallowed redirect targets before following them. The redirect auth handlers also stop preserving credentials across HTTPS to non-HTTPS downgrades as defense in depth. * test(presets): 修复 URL 解析测试 mock 缺少 redirect_validator 参数 重定向安全加固为 open_url 新增 redirect_validator 参数, 两处 fake_open_url mock 签名未同步导致 TypeError。 补齐参数后全部 3717 个测试通过。 --------- Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 5ae7ff5 commit 36ad3cd

5 files changed

Lines changed: 252 additions & 16 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -684,16 +684,44 @@ def preset_add(
684684

685685
elif from_url:
686686
# Validate URL scheme before downloading
687+
from ipaddress import ip_address
687688
from urllib.parse import urlparse as _urlparse
689+
688690
_parsed = _urlparse(from_url)
689-
_is_localhost = _parsed.hostname in ("localhost", "127.0.0.1", "::1")
690-
if _parsed.scheme != "https" and not (_parsed.scheme == "http" and _is_localhost):
691-
console.print(f"[red]Error:[/red] URL must use HTTPS (got {_parsed.scheme}://). HTTP is only allowed for localhost.")
691+
692+
def _is_allowed_download_url(parsed_url):
693+
host = parsed_url.hostname
694+
if not host:
695+
return False
696+
is_loopback = host == "localhost"
697+
if not is_loopback:
698+
try:
699+
is_loopback = ip_address(host).is_loopback
700+
except ValueError:
701+
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
702+
pass
703+
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
704+
705+
def _validate_download_redirect(old_url, new_url):
706+
if not _is_allowed_download_url(_urlparse(new_url)):
707+
import urllib.error
708+
709+
raise urllib.error.URLError(
710+
"redirect target must use HTTPS with a hostname, "
711+
"or HTTP for localhost/loopback"
712+
)
713+
714+
if not _is_allowed_download_url(_parsed):
715+
console.print(
716+
"[red]Error:[/red] URL must use HTTPS with a hostname, "
717+
"or HTTP for localhost/loopback."
718+
)
692719
raise typer.Exit(1)
693720

694721
console.print(f"Installing preset from [cyan]{from_url}[/cyan]...")
695722
import urllib.error
696723
import tempfile
724+
import shutil
697725

698726
with tempfile.TemporaryDirectory() as tmpdir:
699727
zip_path = Path(tmpdir) / "preset.zip"
@@ -707,8 +735,25 @@ def preset_add(
707735
from_url = _resolved_from_url
708736
_preset_extra_headers = {"Accept": "application/octet-stream"}
709737

710-
with _open_url(from_url, timeout=60, extra_headers=_preset_extra_headers) as response:
711-
zip_path.write_bytes(response.read())
738+
with _open_url(
739+
from_url,
740+
timeout=60,
741+
extra_headers=_preset_extra_headers,
742+
redirect_validator=_validate_download_redirect,
743+
) as response:
744+
final_url = response.geturl() if hasattr(response, "geturl") else from_url
745+
if not _is_allowed_download_url(_urlparse(final_url)):
746+
console.print(
747+
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
748+
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
749+
"or HTTP for localhost/loopback."
750+
)
751+
raise typer.Exit(1)
752+
with zip_path.open("wb") as output:
753+
try:
754+
shutil.copyfileobj(response, output)
755+
except TypeError:
756+
output.write(response.read())
712757
except urllib.error.URLError as e:
713758
console.print(f"[red]Error:[/red] Failed to download: {e}")
714759
raise typer.Exit(1)
@@ -1186,7 +1231,7 @@ def preset_catalog_add(
11861231
})
11871232

11881233
config["catalogs"] = catalogs
1189-
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
1234+
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
11901235

11911236
install_label = "install allowed" if install_allowed else "discovery only"
11921237
console.print(f"\n[green]✓[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
@@ -1226,7 +1271,7 @@ def preset_catalog_remove(
12261271
raise typer.Exit(1)
12271272

12281273
config["catalogs"] = catalogs
1229-
config_path.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
1274+
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
12301275

12311276
console.print(f"[green]✓[/green] Removed catalog '{name}'")
12321277
if not catalogs:

src/specify_cli/authentication/http.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import urllib.error
1515
import urllib.request
1616
from fnmatch import fnmatch
17+
from typing import Callable
1718
from urllib.parse import urlparse
1819

1920
from . import get_provider
@@ -56,22 +57,36 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
5657
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
5758

5859

60+
RedirectValidator = Callable[[str, str], None]
61+
62+
5963
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
60-
"""Drop ``Authorization`` when a redirect leaves the entry's declared hosts."""
64+
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
6165

62-
def __init__(self, hosts: tuple[str, ...]) -> None:
66+
def __init__(
67+
self,
68+
hosts: tuple[str, ...],
69+
redirect_validator: RedirectValidator | None = None,
70+
) -> None:
6371
super().__init__()
6472
self._hosts = hosts
73+
self._redirect_validator = redirect_validator
6574

6675
def redirect_request(self, req, fp, code, msg, headers, newurl):
76+
if self._redirect_validator is not None:
77+
self._redirect_validator(req.full_url, newurl)
78+
6779
original_auth = (
6880
req.get_header("Authorization")
6981
or req.unredirected_hdrs.get("Authorization")
7082
)
7183
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
7284
if new_req is not None:
73-
hostname = (urlparse(newurl).hostname or "").lower()
74-
if _hostname_in_hosts(hostname, self._hosts):
85+
old_scheme = urlparse(req.full_url).scheme
86+
new_parsed = urlparse(newurl)
87+
hostname = (new_parsed.hostname or "").lower()
88+
is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https"
89+
if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade:
7590
if original_auth:
7691
new_req.add_unredirected_header("Authorization", original_auth)
7792
else:
@@ -103,7 +118,12 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
103118
return urllib.request.Request(url, headers=headers)
104119

105120

106-
def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None):
121+
def open_url(
122+
url: str,
123+
timeout: int = 10,
124+
extra_headers: dict[str, str] | None = None,
125+
redirect_validator: RedirectValidator | None = None,
126+
):
107127
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
108128
109129
1. Find ``auth.json`` entries whose hosts match the URL.
@@ -113,6 +133,8 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
113133
5. Non-auth errors (404, 500, network) raise immediately.
114134
115135
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
136+
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
137+
before following each redirect and may raise to reject the redirect.
116138
"""
117139
entries = find_entries_for_url(url, _load_config())
118140

@@ -135,7 +157,7 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
135157
continue
136158

137159
req = _make_req(provider.auth_headers(token, entry.auth))
138-
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))
160+
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator))
139161
try:
140162
return opener.open(req, timeout=timeout)
141163
except urllib.error.HTTPError as exc:
@@ -146,4 +168,7 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
146168

147169
# No entry worked (or none matched) — unauthenticated fallback
148170
req = _make_req({})
171+
if redirect_validator is not None:
172+
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
173+
return opener.open(req, timeout=timeout)
149174
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310

tests/test_authentication.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,35 @@ def test_redirect_outside_hosts_strips_auth(self):
793793
assert new_req.headers.get("Authorization") is None
794794
assert new_req.unredirected_hdrs.get("Authorization") is None
795795

796+
def test_https_to_http_same_host_redirect_strips_auth(self):
797+
from specify_cli.authentication.http import _StripAuthOnRedirect
798+
from urllib.request import Request
799+
import io
800+
handler = _StripAuthOnRedirect(("github.com",))
801+
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
802+
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
803+
"http://github.com/org/repo")
804+
assert new_req is not None
805+
assert new_req.headers.get("Authorization") is None
806+
assert new_req.unredirected_hdrs.get("Authorization") is None
807+
808+
def test_redirect_validator_can_reject_before_following_redirect(self):
809+
import urllib.error
810+
from specify_cli.authentication.http import _StripAuthOnRedirect
811+
from urllib.request import Request
812+
import io
813+
814+
def reject_http(old_url, new_url):
815+
if new_url.startswith("http://"):
816+
raise urllib.error.URLError("scheme downgrade")
817+
818+
handler = _StripAuthOnRedirect(("github.com",), reject_http)
819+
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
820+
821+
with pytest.raises(urllib.error.URLError, match="scheme downgrade"):
822+
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
823+
"http://github.com/org/repo")
824+
796825
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
797826
"""Auth survives a multi-hop redirect chain within allowed hosts."""
798827
from specify_cli.authentication.http import _StripAuthOnRedirect

tests/test_github_http.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,4 @@ def capturing_open(url, timeout=None, extra_headers=None):
187187
capturing_open,
188188
)
189189
assert len(captured_urls) == 1
190-
assert "releases/tags/v1%23beta" in captured_urls[0]
190+
assert "releases/tags/v1%23beta" in captured_urls[0]

0 commit comments

Comments
 (0)