Skip to content

Commit 1200bb0

Browse files
fix(workflows): validate every redirect hop when fetching workflow/step catalogs
WorkflowCatalog._fetch_single_catalog and StepCatalog._fetch_single_catalog opened the catalog URL with open_url(entry.url, timeout=30) and validated only the final resp.geturl(). open_url follows redirects, so an https:// catalog entry that 30x-redirects through a non-HTTPS host mid-chain could let a network attacker rewrite the next hop and slip a payload past the terminal-URL-only check. The payload then drives step/workflow catalog data. Pass a redirect_validator that runs the existing HTTPS/hostname check before every redirect hop, keeping the final geturl() check as a defense-in-depth backstop. This brings both workflow catalog loaders to parity with the presets (#3523) and extensions (#3524) catalog fetchers. Tests: add per-hop redirect-validation tests for both WorkflowCatalog and StepCatalog (a non-HTTPS intermediate hop is rejected); both fail before the fix ("NoneType object is not callable" — no validator passed). Update the two existing malformed-redirect tests whose open_url stub lacked the redirect_validator kwarg.
1 parent d7699c3 commit 1200bb0

2 files changed

Lines changed: 102 additions & 4 deletions

File tree

src/specify_cli/workflows/catalog.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,8 +523,20 @@ def _validate_catalog_url(url: str) -> None:
523523

524524
_validate_catalog_url(entry.url)
525525

526+
# Validate EVERY redirect hop, not just the final URL: _open_url follows
527+
# redirects, so an https:// entry that 30x-redirects through http:// (or
528+
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
529+
# rewrite the next hop and slip a payload past a final-URL-only check.
530+
# redirect_validator runs before each hop; the geturl() check below is
531+
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
532+
# catalog fix (#3523 / #3524).
533+
def _validate_redirect(_old_url: str, new_url: str) -> None:
534+
_validate_catalog_url(new_url)
535+
526536
try:
527-
with _open_url(entry.url, timeout=30) as resp:
537+
with _open_url(
538+
entry.url, timeout=30, redirect_validator=_validate_redirect
539+
) as resp:
528540
_validate_catalog_url(resp.geturl())
529541
data = json.loads(resp.read().decode("utf-8"))
530542
except Exception as exc:
@@ -1180,8 +1192,20 @@ def _validate_url(url: str) -> None:
11801192

11811193
_validate_url(entry.url)
11821194

1195+
# Validate EVERY redirect hop, not just the final URL: _open_url follows
1196+
# redirects, so an https:// entry that 30x-redirects through http:// (or
1197+
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
1198+
# rewrite the next hop and slip a payload past a final-URL-only check.
1199+
# redirect_validator runs before each hop; the geturl() check below is
1200+
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
1201+
# catalog fix (#3523 / #3524).
1202+
def _validate_redirect(_old_url: str, new_url: str) -> None:
1203+
_validate_url(new_url)
1204+
11831205
try:
1184-
with _open_url(entry.url, timeout=30) as resp:
1206+
with _open_url(
1207+
entry.url, timeout=30, redirect_validator=_validate_redirect
1208+
) as resp:
11851209
_validate_url(resp.geturl())
11861210
data = json.loads(resp.read().decode("utf-8"))
11871211
except Exception as exc:

tests/test_workflows.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6161,7 +6161,9 @@ def geturl(self):
61616161
return "https://[::1"
61626162

61636163
monkeypatch.setattr(
6164-
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
6164+
auth_http,
6165+
"open_url",
6166+
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
61656167
)
61666168

61676169
catalog = WorkflowCatalog(project_dir)
@@ -6176,6 +6178,41 @@ def geturl(self):
61766178
with pytest.raises(WorkflowCatalogError, match="malformed"):
61776179
catalog._fetch_single_catalog(entry, force_refresh=True)
61786180

6181+
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
6182+
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
6183+
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
6184+
terminal-URL-only check would miss. Mirrors presets/extensions
6185+
(#3523 / #3524)."""
6186+
from specify_cli.workflows.catalog import (
6187+
WorkflowCatalog,
6188+
WorkflowCatalogEntry,
6189+
WorkflowCatalogError,
6190+
)
6191+
from specify_cli.authentication import http as auth_http
6192+
6193+
captured = {}
6194+
6195+
def fake_open(url, timeout=30, redirect_validator=None):
6196+
captured["rv"] = redirect_validator
6197+
# Simulate the hop urllib validates before following the redirect.
6198+
redirect_validator(
6199+
"https://good.example/catalog.json", "http://evil.test/hop"
6200+
)
6201+
raise AssertionError("redirect_validator should have raised")
6202+
6203+
monkeypatch.setattr(auth_http, "open_url", fake_open)
6204+
6205+
catalog = WorkflowCatalog(project_dir)
6206+
entry = WorkflowCatalogEntry(
6207+
url="https://good.example/catalog.json",
6208+
name="test",
6209+
priority=1,
6210+
install_allowed=True,
6211+
)
6212+
with pytest.raises(WorkflowCatalogError, match="HTTPS"):
6213+
catalog._fetch_single_catalog(entry, force_refresh=True)
6214+
assert captured["rv"] is not None
6215+
61796216
def test_add_catalog(self, project_dir):
61806217
from specify_cli.workflows.catalog import WorkflowCatalog
61816218

@@ -6720,7 +6757,9 @@ def geturl(self):
67206757
return "https://[not-an-ip]/x"
67216758

67226759
monkeypatch.setattr(
6723-
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
6760+
auth_http,
6761+
"open_url",
6762+
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
67246763
)
67256764

67266765
catalog = StepCatalog(project_dir)
@@ -6735,6 +6774,41 @@ def geturl(self):
67356774
with pytest.raises(StepCatalogError, match="malformed"):
67366775
catalog._fetch_single_catalog(entry, force_refresh=True)
67376776

6777+
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
6778+
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
6779+
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
6780+
terminal-URL-only check would miss. Mirrors presets/extensions
6781+
(#3523 / #3524)."""
6782+
from specify_cli.workflows.catalog import (
6783+
StepCatalog,
6784+
StepCatalogEntry,
6785+
StepCatalogError,
6786+
)
6787+
from specify_cli.authentication import http as auth_http
6788+
6789+
captured = {}
6790+
6791+
def fake_open(url, timeout=30, redirect_validator=None):
6792+
captured["rv"] = redirect_validator
6793+
# Simulate the hop urllib validates before following the redirect.
6794+
redirect_validator(
6795+
"https://good.example/steps.json", "http://evil.test/hop"
6796+
)
6797+
raise AssertionError("redirect_validator should have raised")
6798+
6799+
monkeypatch.setattr(auth_http, "open_url", fake_open)
6800+
6801+
catalog = StepCatalog(project_dir)
6802+
entry = StepCatalogEntry(
6803+
url="https://good.example/steps.json",
6804+
name="test",
6805+
priority=1,
6806+
install_allowed=True,
6807+
)
6808+
with pytest.raises(StepCatalogError, match="HTTPS"):
6809+
catalog._fetch_single_catalog(entry, force_refresh=True)
6810+
assert captured["rv"] is not None
6811+
67386812
def test_add_catalog(self, project_dir):
67396813
from specify_cli.workflows.catalog import StepCatalog
67406814

0 commit comments

Comments
 (0)