diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py index 97bf58a04e..0908b89b94 100644 --- a/src/specify_cli/workflows/catalog.py +++ b/src/specify_cli/workflows/catalog.py @@ -157,8 +157,20 @@ def _validate_catalog_url(self, url: str) -> None: """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" from urllib.parse import urlparse - parsed = urlparse(url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + # A malformed authority (e.g. an unterminated IPv6 bracket + # "https://[::1") makes urlparse / hostname access raise ValueError. + # This validator's contract is to raise WorkflowValidationError for a + # bad URL, so surface that rather than leaking a raw ValueError past the + # command handler (which only catches WorkflowValidationError). Mirrors + # specify_cli.catalogs (#3435). + try: + parsed = urlparse(url) + hostname = parsed.hostname + except ValueError: + raise WorkflowValidationError( + f"Catalog URL is malformed: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): @@ -166,7 +178,7 @@ def _validate_catalog_url(self, url: str) -> None: f"Catalog URL must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.hostname: + if not hostname: raise WorkflowValidationError( "Catalog URL must be a valid URL with a host." ) @@ -332,15 +344,26 @@ def _fetch_single_catalog( from specify_cli.authentication.http import open_url as _open_url def _validate_catalog_url(url: str) -> None: - parsed = urlparse(url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + # A malformed authority (e.g. "https://[::1") makes urlparse / + # hostname access raise ValueError; treat it as a refused fetch + # rather than leaking a raw ValueError (this also validates the + # post-redirect resp.geturl(), so a hostile redirect target cannot + # crash the fetch either). + try: + parsed = urlparse(url) + hostname = parsed.hostname + except ValueError: + raise WorkflowCatalogError( + f"Refusing to fetch catalog from malformed URL: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): raise WorkflowCatalogError( f"Refusing to fetch catalog from non-HTTPS URL: {url}" ) - if not parsed.hostname: + if not hostname: raise WorkflowCatalogError( f"Refusing to fetch catalog from URL with no hostname: {url}" ) @@ -774,8 +797,20 @@ def _validate_catalog_url(self, url: str) -> None: """Validate that a catalog URL uses HTTPS (localhost HTTP allowed).""" from urllib.parse import urlparse - parsed = urlparse(url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + # A malformed authority (e.g. an unterminated IPv6 bracket + # "https://[::1") makes urlparse / hostname access raise ValueError. + # This validator's contract is to raise StepValidationError for a bad + # URL, so surface that rather than leaking a raw ValueError past the + # command handler (which only catches StepValidationError). Mirrors + # specify_cli.catalogs (#3435). + try: + parsed = urlparse(url) + hostname = parsed.hostname + except ValueError: + raise StepValidationError( + f"Catalog URL is malformed: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): @@ -783,7 +818,7 @@ def _validate_catalog_url(self, url: str) -> None: f"Catalog URL must use HTTPS (got {parsed.scheme}://). " "HTTP is only allowed for localhost." ) - if not parsed.hostname: + if not hostname: raise StepValidationError( "Catalog URL must be a valid URL with a host." ) @@ -949,15 +984,26 @@ def _fetch_single_catalog( from specify_cli.authentication.http import open_url as _open_url def _validate_url(url: str) -> None: - parsed = urlparse(url) - is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1") + # A malformed authority (e.g. "https://[::1") makes urlparse / + # hostname access raise ValueError; treat it as a refused fetch + # rather than leaking a raw ValueError (this also validates the + # post-redirect resp.geturl(), so a hostile redirect target cannot + # crash the fetch either). + try: + parsed = urlparse(url) + hostname = parsed.hostname + except ValueError: + raise StepCatalogError( + f"Refusing to fetch catalog from malformed URL: {url}" + ) from None + is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): raise StepCatalogError( f"Refusing to fetch catalog from non-HTTPS URL: {url}" ) - if not parsed.hostname: + if not hostname: raise StepCatalogError( f"Refusing to fetch catalog from URL with no hostname: {url}" ) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index d7cff20f6d..ee456deeb7 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -4805,6 +4805,31 @@ def test_validate_url_localhost_http_allowed(self, project_dir): # Should not raise catalog._validate_catalog_url("http://localhost:8080/catalog.json") + @pytest.mark.parametrize( + "url", + [ + "https://[::1", # unterminated IPv6 bracket + "https://[not-an-ip]/x", # bracketed non-IP host + ], + ) + def test_validate_url_malformed_raises_validation_error(self, project_dir, url): + """A malformed authority must raise WorkflowValidationError, not leak a + raw ValueError. + + ``urlparse``/``.hostname`` raise ValueError on a malformed IPv6 + authority. The command handler only catches WorkflowValidationError, + so a raw ValueError would surface as an uncaught traceback instead of a + clean 'Error:' message + exit 1. Mirrors specify_cli.catalogs (#3435). + """ + from specify_cli.workflows.catalog import ( + WorkflowCatalog, + WorkflowValidationError, + ) + + catalog = WorkflowCatalog(project_dir) + with pytest.raises(WorkflowValidationError, match="malformed"): + catalog._validate_catalog_url(url) + def test_add_catalog(self, project_dir): from specify_cli.workflows.catalog import WorkflowCatalog @@ -5251,6 +5276,24 @@ def test_validate_url_localhost_http_allowed(self, project_dir): # Should not raise catalog._validate_catalog_url("http://localhost:8080/step-catalog.json") + @pytest.mark.parametrize( + "url", + [ + "https://[::1", # unterminated IPv6 bracket + "https://[not-an-ip]/x", # bracketed non-IP host + ], + ) + def test_validate_url_malformed_raises_validation_error(self, project_dir, url): + """A malformed authority must raise StepValidationError, not leak a raw + ValueError past the command handler (which only catches + StepValidationError). Mirrors specify_cli.catalogs (#3435). + """ + from specify_cli.workflows.catalog import StepCatalog, StepValidationError + + catalog = StepCatalog(project_dir) + with pytest.raises(StepValidationError, match="malformed"): + catalog._validate_catalog_url(url) + def test_add_catalog(self, project_dir): from specify_cli.workflows.catalog import StepCatalog