mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL (#3484)
* fix(workflows): raise catalog error, not raw ValueError, on a malformed catalog URL The four catalog URL validators in `workflows/catalog.py` (`WorkflowCatalog`/`StepCatalog` `_validate_catalog_url`, and the nested fetch-path validators) accessed `urlparse(url).hostname` unguarded. A malformed authority — e.g. an unterminated IPv6 bracket `https://[::1` or a bracketed non-IP host `https://[not-an-ip]` — makes urlparse / hostname raise `ValueError`. Each validator's contract is to raise a domain error (`WorkflowValidationError` / `StepValidationError` / `WorkflowCatalogError` / `StepCatalogError`), and the command handlers catch only those. So `specify workflow catalog add "https://[::1"` surfaced an uncaught `ValueError` traceback instead of the clean `Error: Catalog URL is malformed` + exit 1 that a bad URL should give. The fetch-path validators also run on the post-redirect `resp.geturl()`, so a hostile redirect target could crash the fetch the same way. Guard each `urlparse`/`.hostname` access with `try/except ValueError -> domain error`, mirroring the fixes already applied to `specify_cli.catalogs` (#3435) and the bundler adapters (#3433). Also read `hostname` once and reuse it for the host check, matching those siblings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(workflows): cover post-redirect malformed-URL guard (#3484 review) Copilot review asked for regression tests on the fetch-path validators that re-check resp.geturl() after redirects — the branch that turns a malformed redirect target into a domain error instead of a raw ValueError. - test_fetch_malformed_redirect_target_raises_catalog_error on both TestWorkflowCatalog and TestStepCatalog: stub open_url with a response whose geturl() is malformed (https://[::1 / https://[not-an-ip]/x) while entry.url is valid, so validation only trips on the redirect target, and assert _fetch_single_catalog raises WorkflowCatalogError / StepCatalogError with a "malformed" message (force_refresh + fresh project_dir so no cache masks it). - Test-the-test: both fail on pre-fix source (raw ValueError re-wrapped as "...Invalid IPv6 URL", no "malformed" match) and pass with the guard. Also merges latest upstream/main into the branch. 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:
@@ -299,8 +299,20 @@ class WorkflowCatalog:
|
||||
"""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
|
||||
):
|
||||
@@ -308,7 +320,7 @@ class WorkflowCatalog:
|
||||
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."
|
||||
)
|
||||
@@ -474,15 +486,26 @@ class WorkflowCatalog:
|
||||
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}"
|
||||
)
|
||||
@@ -921,8 +944,20 @@ class StepCatalog:
|
||||
"""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
|
||||
):
|
||||
@@ -930,7 +965,7 @@ class StepCatalog:
|
||||
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."
|
||||
)
|
||||
@@ -1096,15 +1131,26 @@ class StepCatalog:
|
||||
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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user