From 40ac309d7848a70067c10b6ff821c46c129aa9cf Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 11:00:04 +0000 Subject: [PATCH 1/2] fix(no-ticket): give a usage error for an api_host or proxy URL that does not parse A proxy such as http://[bad made every API command stop with LocationParseError, and credential-helper install terraform stop with ValueError: Invalid IPv6 URL. The CLI sent both to Sentry, but the cause is the value that the user gave. Parse api_host and proxy with urllib3, the parser that requests uses, when the session starts. A value that does not parse now gives a usage error with exit status 2. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + cloudsmith_cli/cli/decorators.py | 2 ++ cloudsmith_cli/cli/tests/test_decorators.py | 15 +++++++++++++++ cloudsmith_cli/cli/validators.py | 14 ++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad63325c..92782b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith mcp start` now stops without an error when the MCP client closes the connection while the server writes a reply. Previously, the command stopped with `BrokenPipeError` and exit status 1. - The CLI now exits with status 1 when you interrupt it with Ctrl+C after the reader of its stderr has closed the pipe. Before, the CLI sent an error report and exited with status 120. - `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value. +- An `api_host` or `proxy` with a host or port that is not valid, such as `http://[bad` or `https://host:99999`, now gives a usage error. Before, the CLI stopped with `ValueError` or `LocationParseError` and sent an error report. ## [1.30.0] - 2026-10-22 diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 8f790458..d24681eb 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -631,6 +631,8 @@ def wrapper(ctx, *args, **kwargs): opts.api_user_agent = kwargs.pop("api_user_agent") opts.api_headers = kwargs.pop("api_headers") + validators.validate_url("api_host", opts.api_host) + validators.validate_url("proxy", opts.proxy) _guard_untrusted_endpoints( ctx, opts, host_suffixes, proxy_suffixes, proxy_trusted=proxy_from_alias ) diff --git a/cloudsmith_cli/cli/tests/test_decorators.py b/cloudsmith_cli/cli/tests/test_decorators.py index 4ca6be3d..541ce44c 100644 --- a/cloudsmith_cli/cli/tests/test_decorators.py +++ b/cloudsmith_cli/cli/tests/test_decorators.py @@ -388,3 +388,18 @@ def command(opts): assert all(isinstance(choice, str) for choice in color_option.type.choices) assert result.output == "False\n" + + +@pytest.mark.parametrize("option", ["--api-host", "--proxy"]) +def test_malformed_url_is_a_usage_error(option): + @click.command() + @initialise_session + def command(opts): + pass + + result = click.testing.CliRunner().invoke( + command, [option, "http://[bad"], obj=Options() + ) + + assert result.exit_code == 2, result.output + assert '"http://[bad" is not a valid URL' in result.output diff --git a/cloudsmith_cli/cli/validators.py b/cloudsmith_cli/cli/validators.py index 47b53735..3f820fca 100644 --- a/cloudsmith_cli/cli/validators.py +++ b/cloudsmith_cli/cli/validators.py @@ -132,6 +132,20 @@ def is_trusted_api_host(host, extra_suffixes=()): return host_matches_suffixes(host, PUBLIC_API_HOST_SUFFIXES + tuple(extra_suffixes)) +def validate_url(name: str, url: str | None) -> None: + """Raise a usage error if the host or port of ``url`` cannot be parsed.""" + from urllib3.util import parse_url + + if not url: + return + try: + parse_url(url) + except ValueError as error: + raise click.UsageError( + f'{name} "{url}" is not a valid URL. Check the host and the port.' + ) from error + + def validate_untrusted_api_host(host, extra_suffixes=()): """Raise if an api_host from an untrusted config is not an allowed host.""" if is_trusted_api_host(host, extra_suffixes): From 14b81f9286b714dfc52d44e67065a0453e7105ea Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 5 Nov 2026 11:11:45 +0000 Subject: [PATCH 2/2] fix(no-ticket): give a usage error for a malformed proxy in the current directory's config The root callback runs the untrusted-proxy guard before the session check. host_matches_suffixes let the urlsplit ValueError escape, so the CLI still crashed and sent a report. A URL that does not parse now matches no suffix. The guard rejects it, and the session check gives the usage error. Co-Authored-By: Claude Opus 5.5 --- cloudsmith_cli/cli/tests/test_decorators.py | 10 ++++++++++ cloudsmith_cli/cli/validators.py | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/cloudsmith_cli/cli/tests/test_decorators.py b/cloudsmith_cli/cli/tests/test_decorators.py index 541ce44c..fee16ba7 100644 --- a/cloudsmith_cli/cli/tests/test_decorators.py +++ b/cloudsmith_cli/cli/tests/test_decorators.py @@ -403,3 +403,13 @@ def command(opts): assert result.exit_code == 2, result.output assert '"http://[bad" is not a valid URL' in result.output + + +def test_malformed_proxy_in_cwd_config_is_a_usage_error(tmp_path, monkeypatch): + (tmp_path / "config.ini").write_text("[default]\nproxy = http://[bad\n") + monkeypatch.chdir(tmp_path) + + result = click.testing.CliRunner().invoke(main, ["whoami"]) + + assert result.exit_code == 2, result.output + assert '"http://[bad" is not a valid URL' in result.output diff --git a/cloudsmith_cli/cli/validators.py b/cloudsmith_cli/cli/validators.py index 3f820fca..c6a6fbc1 100644 --- a/cloudsmith_cli/cli/validators.py +++ b/cloudsmith_cli/cli/validators.py @@ -121,7 +121,10 @@ def host_matches_suffixes(url, suffixes): Parsing the hostname (rather than substring-matching the URL) ensures userinfo, port, and path components cannot smuggle a host past the check. """ - hostname = (urlsplit(url).hostname or "").lower() + try: + hostname = (urlsplit(url).hostname or "").lower() + except ValueError: + return False return bool(hostname) and any( hostname == suffix or hostname.endswith(f".{suffix}") for suffix in suffixes )