From 0094eb6503bd7a202b339b65f0d2b4aab3bf5ca7 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Tue, 28 Jul 2026 20:51:06 +0500 Subject: [PATCH] fix(catalogs): validate the port in the shared catalog-URL validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CatalogStackBase._validate_catalog_url()` reads `parsed.hostname` inside its `try/except ValueError` but never reads `parsed.port`. `urlparse()` and `.hostname` do not perform port validation — only `.port` does — so a catalog URL with a non-numeric or out-of-range port passes validation. Every implementation that documents itself as mirroring this function already reads `.port` inside the same try: workflows/catalog.py (4 sites), bundler/services/adapters.py (2), bundler/commands_impl/catalog_config.py, and commands/bundle/__init__.py. The shared base — inherited by ExtensionCatalog and IntegrationCatalog — is the only one without it. The accepted URL then escapes as a raw `http.client.InvalidURL`, which is neither `urllib.error.URLError` nor `json.JSONDecodeError` (the only two the fetcher converts), so it surfaces as an unhandled traceback rather than the validator's normal error. Co-Authored-By: Claude Opus 5 (1M context) --- src/specify_cli/catalogs.py | 5 +++++ tests/integrations/test_integration_catalog.py | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/catalogs.py b/src/specify_cli/catalogs.py index 774aaa51d7..00c875ca27 100644 --- a/src/specify_cli/catalogs.py +++ b/src/specify_cli/catalogs.py @@ -74,6 +74,11 @@ def _validate_catalog_url(cls, url: str) -> None: try: parsed = urlparse(url) hostname = parsed.hostname + # Accessing ``port`` performs urllib's syntax/range validation; + # ``hostname`` alone does not, so a non-numeric or out-of-range + # port would otherwise pass validation here and escape as a raw + # http.client.InvalidURL at fetch time. + _ = parsed.port except ValueError: raise cls._error(f"Catalog URL is malformed: {url}") from None is_localhost = hostname in ("localhost", "127.0.0.1", "::1") diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 2e9706e77d..40c777ff15 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -116,12 +116,15 @@ def test_hostless_url_with_truthy_netloc_rejected(self, url): [ "https://[::1", # unclosed ipv6 bracket "https://[not-an-ip]/c.json", # bracketed non-ip host + "https://example.com:notaport/c.json", # non-numeric port + "https://example.com:65536/c.json", # out-of-range port ], ) def test_malformed_url_rejected_cleanly(self, url): - # A malformed authority makes urlparse/hostname raise ValueError. The - # validator must turn that into its normal catalog error, not leak a - # raw ValueError to the caller. + # A malformed authority makes urlparse/hostname raise ValueError, and a + # bad port makes ``parsed.port`` raise it. The validator must turn that + # into its normal catalog error, not leak a raw ValueError to the caller + # (or, for a bad port, accept the URL and fail later at fetch time). with pytest.raises(IntegrationCatalogError, match="malformed"): IntegrationCatalog._validate_catalog_url(url)