fix(extensions): re-validate catalog URL after redirects (HTTPS parity/security) (#3524)

* fix(extensions): re-validate catalog URL after redirects (HTTPS parity)

ExtensionCatalog._fetch_single_catalog opened the catalog URL and trusted the
payload without re-validating response.geturl() after redirects. _open_url
follows redirects (stripping auth only on an HTTPS->HTTP downgrade), so an
https:// catalog entry that 30x-redirects to http://attacker/... was still
fetched and trusted. The payload supplies each extension's download_url +
sha256, so a redirected payload can drive install of an arbitrary archive that
passes sha256 verification.

Add the post-redirect geturl() re-validation via _validate_catalog_url,
mirroring integrations/catalog.py, presets, workflows/catalog.py, and bundler
adapters. Sibling of the same fix in the presets catalog fetcher.

Test: an HTTPS URL whose response.geturl() reports http:// is rejected
(ExtensionError). Completed existing fetch-test mocks that predated this
behavior to report geturl() like a real urllib response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(extensions): correct auth-strip comment; validate every redirect hop + guard legacy fetch_catalog

- Correct the comment: _StripAuthOnRedirect strips auth not only on an
  HTTPS->HTTP downgrade but also whenever the redirect leaves the configured
  trusted hosts. The comment now describes both cases.
- Parity with the presets fix: validate EVERY redirect hop (not just the
  terminal URL) so an https -> http -> attacker-https chain can't slip a
  redirected payload past the final-URL check. _open_url forwards a
  redirect_validator to open_url; _fetch_single_catalog passes
  _validate_catalog_url through it while keeping the final geturl() check.
- Give the legacy public fetch_catalog() single-catalog path the same
  redirect_validator + final geturl() validation (it previously parsed the body
  with no redirect check).

Tests: an intermediate http hop is rejected, and the legacy fetch_catalog()
rejects an HTTPS->http redirected payload (both fail before). Full
test_extensions.py (356) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(extensions): cover legacy fetch_catalog() per-hop redirect validation

The legacy fetch_catalog() regression test only exercised the terminal geturl()
check, so it would still pass if the per-hop redirect_validator were dropped from
that duplicated path. Add test_fetch_catalog_legacy_validates_every_redirect_hop,
which asserts fetch_catalog() supplies a redirect_validator that rejects an
insecure intermediate hop (fails before: the legacy path passed no validator ->
NoneType not callable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali jawwad
2026-07-21 22:30:49 +05:00
committed by GitHub
parent 5760061316
commit 69c8b64301
2 changed files with 137 additions and 3 deletions

View File

@@ -2602,14 +2602,23 @@ class ExtensionCatalog(CatalogStackBase):
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
redirect_validator=None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
*redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
before EACH redirect hop so an HTTPS host guarantee can be enforced on
every intermediate URL, not just the terminal one.
"""
from specify_cli.authentication.http import open_url
return open_url(url, timeout, extra_headers=extra_headers)
return open_url(
url,
timeout,
extra_headers=extra_headers,
redirect_validator=redirect_validator,
)
def _resolve_github_release_asset_api_url(
self,
@@ -2833,7 +2842,24 @@ class ExtensionCatalog(CatalogStackBase):
# Fetch from network
try:
with self._open_url(entry.url, timeout=10) as response:
# Validate EVERY redirect hop, not just the terminal URL. _open_url
# follows redirects; _StripAuthOnRedirect drops auth on an HTTPS->HTTP
# downgrade AND whenever the redirect leaves the configured trusted
# hosts, but the payload itself is still fetched and trusted, and it
# supplies each extension's download_url + sha256 (so a redirected
# payload defeats sha256 verification). A terminal-only check also
# misses an https -> http -> attacker-https chain. redirect_validator
# runs before each hop; the final geturl() check is kept as a
# belt-and-braces guard. Mirrors bundler/services/adapters.py.
def _validate_redirect(_old_url: str, new_url: str) -> None:
self._validate_catalog_url(new_url)
with self._open_url(
entry.url, timeout=10, redirect_validator=_validate_redirect
) as response:
final_url = response.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
self._validate_catalog_payload(catalog_data, entry.url)
@@ -3010,7 +3036,18 @@ class ExtensionCatalog(CatalogStackBase):
try:
import urllib.error
with self._open_url(catalog_url, timeout=10) as response:
# Same redirect hardening as _fetch_single_catalog: validate every
# redirect hop AND the final URL so this legacy single-catalog path
# is not vulnerable to an HTTPS->HTTP redirected payload either.
def _validate_redirect(_old_url: str, new_url: str) -> None:
self._validate_catalog_url(new_url)
with self._open_url(
catalog_url, timeout=10, redirect_validator=_validate_redirect
) as response:
final_url = response.geturl()
if final_url != catalog_url:
self._validate_catalog_url(final_url)
catalog_data = json.loads(response.read())
# Validate catalog structure. Reuses the same helper as

View File

@@ -4447,6 +4447,94 @@ class TestExtensionCatalog:
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
def test_fetch_single_catalog_revalidates_redirected_url(self, temp_dir):
"""An HTTPS catalog URL that redirects to http:// must be rejected AFTER
the redirect. _open_url follows redirects (auth stripped on downgrade),
so without re-validating response.geturl() the http payload would still
be fetched and trusted — and it supplies each extension's download_url +
sha256, defeating sha256 verification. Parity with the
integrations/presets/workflows catalog fetchers."""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
entry = CatalogEntry(
url="https://good.example/catalog.json",
name="c",
priority=1,
install_allowed=True,
)
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="HTTPS"):
catalog._fetch_single_catalog(entry, force_refresh=True)
def test_fetch_single_catalog_validates_every_redirect_hop(self, temp_dir):
"""A redirect_validator is passed to _open_url and rejects a non-HTTPS
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
terminal-URL-only check would miss."""
catalog = self._make_catalog(temp_dir)
captured = {}
def fake_open(url, timeout=None, extra_headers=None, redirect_validator=None):
captured["rv"] = redirect_validator
redirect_validator("https://good.example/catalog.json", "http://evil.test/hop")
raise AssertionError("redirect_validator should have raised")
catalog._open_url = fake_open
entry = CatalogEntry(
url="https://good.example/catalog.json",
name="c",
priority=1,
install_allowed=True,
)
with pytest.raises(ExtensionError, match="HTTPS"):
catalog._fetch_single_catalog(entry, force_refresh=True)
assert captured["rv"] is not None
def test_fetch_catalog_legacy_revalidates_redirected_url(self, temp_dir):
"""The legacy single-catalog fetch_catalog() path also rejects an
HTTPS -> http redirected payload (final geturl() check) — it previously
parsed the body with no redirect check."""
from unittest.mock import patch, MagicMock
catalog = self._make_catalog(temp_dir)
mock_response = MagicMock()
mock_response.read.return_value = json.dumps(
{"schema_version": "1.0", "extensions": {}}
).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "http://evil.test/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="HTTPS"):
catalog.fetch_catalog(force_refresh=True)
def test_fetch_catalog_legacy_validates_every_redirect_hop(self, temp_dir):
"""The legacy fetch_catalog() path also validates every INTERMEDIATE hop
(not just the terminal URL): it must supply a redirect_validator that
rejects an insecure hop, so an https -> http -> https chain is caught."""
catalog = self._make_catalog(temp_dir)
captured = {}
def fake_open(url, timeout=None, extra_headers=None, redirect_validator=None):
captured["rv"] = redirect_validator
redirect_validator(url, "http://evil.test/hop")
raise AssertionError("redirect_validator should have raised")
catalog._open_url = fake_open
with pytest.raises(ExtensionError, match="HTTPS"):
catalog.fetch_catalog(force_refresh=True)
assert captured["rv"] is not None
@pytest.mark.parametrize(
"payload",
[
@@ -4480,6 +4568,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
entry = CatalogEntry(
url="https://example.com/catalog.json",
@@ -4548,6 +4637,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
entry = CatalogEntry(
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
@@ -4595,6 +4685,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
with pytest.raises(ExtensionError, match="Invalid catalog format"):
@@ -4635,6 +4726,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
@@ -4673,6 +4765,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "_open_url", return_value=mock_response):
result = catalog.fetch_catalog(force_refresh=False)
@@ -4747,6 +4840,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
# Record every ``write_text`` call's encoding kwarg so the
# assertion observes the production writer's argument directly.
@@ -4798,6 +4892,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(valid).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
# Simulate an unwritable cache dir: every write_text under the
# cache directory raises PermissionError (an OSError subclass).
@@ -4849,6 +4944,7 @@ class TestExtensionCatalog:
mock_response.read.return_value = json.dumps(payload).encode()
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
entry = CatalogEntry(
url="https://example.com/catalog.json",
@@ -6877,6 +6973,7 @@ class TestDownloadExtensionBundled:
mock_response.read.return_value = b"fake zip data"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
mock_response.geturl.return_value = "https://example.com/catalog.json"
with patch.object(catalog, "get_extension_info", return_value=bundled_with_url), \
patch.object(urllib.request, "urlopen", return_value=mock_response):