Skip to content

Commit 30e99ec

Browse files
fix(workflows): validate every redirect hop when fetching workflow/step catalogs (#3637)
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 717d7c4 commit 30e99ec

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
@@ -6321,7 +6321,9 @@ def geturl(self):
63216321
return "https://[::1"
63226322

63236323
monkeypatch.setattr(
6324-
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
6324+
auth_http,
6325+
"open_url",
6326+
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
63256327
)
63266328

63276329
catalog = WorkflowCatalog(project_dir)
@@ -6336,6 +6338,41 @@ def geturl(self):
63366338
with pytest.raises(WorkflowCatalogError, match="malformed"):
63376339
catalog._fetch_single_catalog(entry, force_refresh=True)
63386340

6341+
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
6342+
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
6343+
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
6344+
terminal-URL-only check would miss. Mirrors presets/extensions
6345+
(#3523 / #3524)."""
6346+
from specify_cli.workflows.catalog import (
6347+
WorkflowCatalog,
6348+
WorkflowCatalogEntry,
6349+
WorkflowCatalogError,
6350+
)
6351+
from specify_cli.authentication import http as auth_http
6352+
6353+
captured = {}
6354+
6355+
def fake_open(url, timeout=30, redirect_validator=None):
6356+
captured["rv"] = redirect_validator
6357+
# Simulate the hop urllib validates before following the redirect.
6358+
redirect_validator(
6359+
"https://good.example/catalog.json", "http://evil.test/hop"
6360+
)
6361+
raise AssertionError("redirect_validator should have raised")
6362+
6363+
monkeypatch.setattr(auth_http, "open_url", fake_open)
6364+
6365+
catalog = WorkflowCatalog(project_dir)
6366+
entry = WorkflowCatalogEntry(
6367+
url="https://good.example/catalog.json",
6368+
name="test",
6369+
priority=1,
6370+
install_allowed=True,
6371+
)
6372+
with pytest.raises(WorkflowCatalogError, match="HTTPS"):
6373+
catalog._fetch_single_catalog(entry, force_refresh=True)
6374+
assert captured["rv"] is not None
6375+
63396376
def test_add_catalog(self, project_dir):
63406377
from specify_cli.workflows.catalog import WorkflowCatalog
63416378

@@ -6880,7 +6917,9 @@ def geturl(self):
68806917
return "https://[not-an-ip]/x"
68816918

68826919
monkeypatch.setattr(
6883-
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
6920+
auth_http,
6921+
"open_url",
6922+
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
68846923
)
68856924

68866925
catalog = StepCatalog(project_dir)
@@ -6895,6 +6934,41 @@ def geturl(self):
68956934
with pytest.raises(StepCatalogError, match="malformed"):
68966935
catalog._fetch_single_catalog(entry, force_refresh=True)
68976936

6937+
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
6938+
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
6939+
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
6940+
terminal-URL-only check would miss. Mirrors presets/extensions
6941+
(#3523 / #3524)."""
6942+
from specify_cli.workflows.catalog import (
6943+
StepCatalog,
6944+
StepCatalogEntry,
6945+
StepCatalogError,
6946+
)
6947+
from specify_cli.authentication import http as auth_http
6948+
6949+
captured = {}
6950+
6951+
def fake_open(url, timeout=30, redirect_validator=None):
6952+
captured["rv"] = redirect_validator
6953+
# Simulate the hop urllib validates before following the redirect.
6954+
redirect_validator(
6955+
"https://good.example/steps.json", "http://evil.test/hop"
6956+
)
6957+
raise AssertionError("redirect_validator should have raised")
6958+
6959+
monkeypatch.setattr(auth_http, "open_url", fake_open)
6960+
6961+
catalog = StepCatalog(project_dir)
6962+
entry = StepCatalogEntry(
6963+
url="https://good.example/steps.json",
6964+
name="test",
6965+
priority=1,
6966+
install_allowed=True,
6967+
)
6968+
with pytest.raises(StepCatalogError, match="HTTPS"):
6969+
catalog._fetch_single_catalog(entry, force_refresh=True)
6970+
assert captured["rv"] is not None
6971+
68986972
def test_add_catalog(self, project_dir):
68996973
from specify_cli.workflows.catalog import StepCatalog
69006974

0 commit comments

Comments
 (0)