Skip to content

fix(catalogs): validate the port in the shared catalog-URL validator, like its mirrors do - #3804

Open
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix-catalog-url-port-validation
Open

fix(catalogs): validate the port in the shared catalog-URL validator, like its mirrors do#3804
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix-catalog-url-port-validation

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

CatalogStackBase._validate_catalog_url() (src/specify_cli/catalogs.py) 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.

This is a parity gap, not a design choice. Every implementation that documents itself as mirroring this function reads .port inside the same try, with the same comment:

Site Comment in source
workflows/catalog.py 313, 511, 996, 1193 "Mirrors specify_cli.catalogs (#3435)"
bundler/services/adapters.py 81, 127 "Mirrors specify_cli.catalogs URL validation"
bundler/commands_impl/catalog_config.py 156 "Mirror specify_cli.catalogs._validate_catalog_url (#3209/#3210)"
commands/bundle/__init__.py 849

The shared base — inherited by ExtensionCatalog and IntegrationCatalog — is the only one of the five validators without it. The invariant is already maintainer-accepted: tests/test_workflows.py:6401 and tests/unit/test_bundler_adapters.py:97 both parametrize the exact string https://example.com:notaport/catalog.json for their mirrors.

Reproduction on current main (2e44ed6)

URL https://example.invalid:notaport/c.json
    base CatalogStackBase  -> ACCEPTED  <-- bug
    ExtensionCatalog       -> ACCEPTED  <-- bug
    IntegrationCatalog     -> ACCEPTED  <-- bug
    WorkflowCatalog mirror -> rejected: WorkflowValidationError

The accepted URL is unusable, and it fails late:

http.client.InvalidURL: nonnumeric port: 'notaport'
   is URLError? False | is JSONDecodeError? False

Those are the only two exceptions the fetcher converts, so it is not turned into a catalog error — it escapes as an unhandled traceback. The unusable entry is also persisted to .specify/*-catalogs.yml first, so catalog add exits 0 and the failure appears later on an unrelated command.

Fix

Two lines inside the existing try, matching the mirrors:

 parsed = urlparse(url)
 hostname = parsed.hostname
+# Accessing ``port`` performs urllib's syntax/range validation; ...
+_ = parsed.port
 except ValueError:
     raise cls._error(f"Catalog URL is malformed: {url}") from None

No new exception type, no new message, no signature change — it routes into the error the function already raises for a malformed authority.

Scope: base only, following #3435#3576

PresetCatalog._validate_catalog_url has the identical gap, but PresetCatalog is a standalone class, not a CatalogStackBase subclass, so this fix does not reach it. That split is the repo's documented pattern rather than my judgment call:

I'll follow up on the preset twin separately, same as #3576 did.

Verification

  • Fail-before / pass-after: the two new parametrize cases fail on unpatched src (DID NOT RAISE) and pass with the fix. Full file: 120 passed.
  • No regression: https://example.com:8443, http://localhost:3000, http://127.0.0.1:8080, :0, :65535 all still validate. parsed.port returns None with no port and an int for any in-range port, so every URL that works today still works.
  • tests/integrations/test_integration_catalog.py tests/test_extensions.py tests/test_download_security.py684 passed, 7 skipped
  • uvx ruff@0.15.0 check → clean

Only inputs that can never be fetched change behaviour: a non-numeric port (previously a raw InvalidURL traceback) and an out-of-range port (previously a DNS/socket failure). Both now raise the existing Catalog URL is malformed: <url>.

Note for reviewers

I deliberately did not widen except ValueError to also catch TypeError. The base still leaks TypeError for a non-str url from hand-edited YAML, but that is a separate concern and a behaviour change for non-str input — happy to do it as a follow-up if you'd prefer.


Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main; every claim above is from output I ran.

`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) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner July 28, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant