Skip to content

Commit e736273

Browse files
marcelsafinCopilot
andcommitted
fix(workflows): validate origin and release metadata
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 33baef3 commit e736273

7 files changed

Lines changed: 217 additions & 98 deletions

File tree

src/specify_cli/_github_http.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"api.github.com",
2525
"codeload.github.com",
2626
})
27+
_MAX_RELEASE_METADATA_BYTES = 5 * 1024 * 1024
2728

2829

2930
def build_github_request(url: str) -> urllib.request.Request:
@@ -68,6 +69,8 @@ def resolve_github_release_asset_api_url(
6869
open_url_fn: Callable,
6970
timeout: int = 60,
7071
github_hosts: tuple[str, ...] = (),
72+
redirect_validator: Callable[[str, str], None] | None = None,
73+
max_metadata_bytes: int = _MAX_RELEASE_METADATA_BYTES,
7174
) -> Optional[str]:
7275
"""Resolve a GitHub release browser-download URL to its REST API asset URL.
7376
@@ -91,6 +94,8 @@ def resolve_github_release_asset_api_url(
9194
authenticated release-metadata lookup.
9295
timeout: Per-request timeout in seconds.
9396
github_hosts: Host patterns to treat as GitHub Enterprise Server.
97+
redirect_validator: Optional policy applied to metadata redirects.
98+
max_metadata_bytes: Maximum release-metadata response size.
9499
"""
95100
import json
96101
import urllib.error
@@ -149,13 +154,33 @@ def _is_asset_path(segments: list[str]) -> bool:
149154
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
150155

151156
try:
152-
with open_url_fn(release_url, timeout=timeout) as response:
153-
release_data = json.loads(response.read())
154-
except (urllib.error.URLError, json.JSONDecodeError):
157+
open_kwargs = {"timeout": timeout}
158+
if redirect_validator is not None:
159+
open_kwargs["redirect_validator"] = redirect_validator
160+
with open_url_fn(release_url, **open_kwargs) as response:
161+
raw_release_data = response.read(max_metadata_bytes + 1)
162+
if len(raw_release_data) > max_metadata_bytes:
163+
raise ValueError("GitHub release metadata exceeds size limit")
164+
release_data = json.loads(raw_release_data)
165+
except (
166+
urllib.error.URLError,
167+
json.JSONDecodeError,
168+
TypeError,
169+
ValueError,
170+
):
155171
return None
156172

157-
for asset in release_data.get("assets", []):
158-
if asset.get("name") == asset_name and asset.get("url"):
173+
if not isinstance(release_data, dict):
174+
return None
175+
assets = release_data.get("assets", [])
176+
if not isinstance(assets, list):
177+
return None
178+
for asset in assets:
179+
if (
180+
isinstance(asset, dict)
181+
and asset.get("name") == asset_name
182+
and asset.get("url")
183+
):
159184
return str(asset["url"])
160185

161186
return None

src/specify_cli/workflows/_commands.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,7 +1612,11 @@ def _validate_and_install_local(
16121612

16131613
_wf_url_extra_headers = None
16141614
_resolved_wf_url = _resolve_gh_asset(
1615-
download_url, _open_url, timeout=30, github_hosts=_github_provider_hosts()
1615+
download_url,
1616+
_open_url,
1617+
timeout=30,
1618+
github_hosts=_github_provider_hosts(),
1619+
redirect_validator=_reject_insecure_download_redirect,
16161620
)
16171621
if _resolved_wf_url:
16181622
download_url = _resolved_wf_url
@@ -1825,7 +1829,11 @@ def versions_match(actual: object, expected: str) -> bool:
18251829

18261830
_wf_cat_extra_headers = None
18271831
_resolved_workflow_url = _resolve_gh_asset(
1828-
workflow_url, _open_url, timeout=30, github_hosts=_github_provider_hosts()
1832+
workflow_url,
1833+
_open_url,
1834+
timeout=30,
1835+
github_hosts=_github_provider_hosts(),
1836+
redirect_validator=_reject_insecure_download_redirect,
18291837
)
18301838
if _resolved_workflow_url:
18311839
workflow_url = _resolved_workflow_url

src/specify_cli/workflows/engine.py

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,42 @@ def _validate_run_id(cls, run_id: str) -> None:
423423
"alphanumeric character)."
424424
)
425425

426+
@staticmethod
427+
def _validate_installed_origin(
428+
installed_workflow_id: str | None,
429+
installed_registry_root: str | None,
430+
) -> None:
431+
"""Validate persisted installed-workflow ownership metadata."""
432+
if installed_workflow_id is not None:
433+
if not isinstance(installed_workflow_id, str):
434+
raise ValueError(
435+
"Invalid run state: 'installed_workflow_id' must be a "
436+
f"string or null, got {type(installed_workflow_id).__name__}"
437+
)
438+
if not _ID_PATTERN.fullmatch(installed_workflow_id):
439+
raise ValueError(
440+
"Invalid run state: 'installed_workflow_id' must be a "
441+
"lowercase alphanumeric workflow ID with hyphens"
442+
)
443+
if installed_registry_root is not None:
444+
if not isinstance(installed_registry_root, str):
445+
raise ValueError(
446+
"Invalid run state: 'installed_registry_root' must be a "
447+
f"string or null, got {type(installed_registry_root).__name__}"
448+
)
449+
if not installed_registry_root or not Path(
450+
installed_registry_root
451+
).is_absolute():
452+
raise ValueError(
453+
"Invalid run state: 'installed_registry_root' must be "
454+
"an absolute path or null"
455+
)
456+
if installed_workflow_id is None:
457+
raise ValueError(
458+
"Invalid run state: 'installed_registry_root' requires "
459+
"'installed_workflow_id'"
460+
)
461+
426462
def __init__(
427463
self,
428464
run_id: str | None = None,
@@ -442,6 +478,9 @@ def __init__(
442478
else:
443479
self.run_id = run_id
444480
self._validate_run_id(self.run_id)
481+
self._validate_installed_origin(
482+
installed_workflow_id, installed_registry_root
483+
)
445484
self.workflow_id = workflow_id
446485
self.project_root = project_root or Path(".")
447486
# Identifies the installed workflow (if any) this run was started
@@ -601,36 +640,7 @@ def load(cls, run_id: str, project_root: Path) -> RunState:
601640
)
602641

603642
installed_workflow_id = state_data.get("installed_workflow_id")
604-
if installed_workflow_id is not None:
605-
if not isinstance(installed_workflow_id, str):
606-
raise ValueError(
607-
"Invalid run state: 'installed_workflow_id' must be a "
608-
f"string or null, got {type(installed_workflow_id).__name__}"
609-
)
610-
if not _ID_PATTERN.fullmatch(installed_workflow_id):
611-
raise ValueError(
612-
"Invalid run state: 'installed_workflow_id' must be a "
613-
"lowercase alphanumeric workflow ID with hyphens"
614-
)
615643
installed_registry_root = state_data.get("installed_registry_root")
616-
if installed_registry_root is not None:
617-
if not isinstance(installed_registry_root, str):
618-
raise ValueError(
619-
"Invalid run state: 'installed_registry_root' must be a "
620-
f"string or null, got {type(installed_registry_root).__name__}"
621-
)
622-
if not installed_registry_root or not Path(
623-
installed_registry_root
624-
).is_absolute():
625-
raise ValueError(
626-
"Invalid run state: 'installed_registry_root' must be "
627-
"an absolute path or null"
628-
)
629-
if installed_workflow_id is None:
630-
raise ValueError(
631-
"Invalid run state: 'installed_registry_root' requires "
632-
"'installed_workflow_id'"
633-
)
634644

635645
state = cls(
636646
run_id=state_data["run_id"],

tests/contract/test_bundle_cli.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77
from __future__ import annotations
88

9+
import io
910
import json
1011
from pathlib import Path
1112
from unittest.mock import patch
@@ -468,25 +469,16 @@ def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
468469
# ===== Private GitHub release asset URL resolution =====
469470

470471

471-
class FakeBundleResponse:
472+
class FakeBundleResponse(io.BytesIO):
472473
"""Minimal context-manager response stub for open_url fakes."""
473474

474475
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
475-
self._data = data
476+
super().__init__(data)
476477
self._url = url
477478

478-
def read(self) -> bytes:
479-
return self._data
480-
481479
def geturl(self) -> str:
482480
return self._url
483481

484-
def __enter__(self):
485-
return self
486-
487-
def __exit__(self, *_):
488-
return False
489-
490482

491483
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
492484
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""

tests/test_github_http.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,44 @@ def failing_open(url, timeout=None, extra_headers=None):
152152
)
153153
assert result is None
154154

155+
def test_metadata_lookup_is_bounded_and_redirect_validated(self):
156+
"""Release metadata reads stay bounded and use the caller's policy."""
157+
captured = {}
158+
159+
class OversizedResponse:
160+
def read(self, amount=None):
161+
captured["read_amount"] = amount
162+
return b"x" * amount
163+
164+
def __enter__(self):
165+
return self
166+
167+
def __exit__(self, *args):
168+
return False
169+
170+
def redirect_validator(old_url, new_url):
171+
return None
172+
173+
def fake_open(
174+
url,
175+
timeout=None,
176+
extra_headers=None,
177+
redirect_validator=None,
178+
):
179+
captured["redirect_validator"] = redirect_validator
180+
return OversizedResponse()
181+
182+
result = resolve_github_release_asset_api_url(
183+
"https://github.com/org/repo/releases/download/v1/pack.zip",
184+
fake_open,
185+
redirect_validator=redirect_validator,
186+
max_metadata_bytes=8,
187+
)
188+
189+
assert result is None
190+
assert captured["read_amount"] == 9
191+
assert captured["redirect_validator"] is redirect_validator
192+
155193
def test_tag_with_special_characters_is_url_encoded(self):
156194
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
157195
captured_urls = []

tests/test_presets.py

Lines changed: 5 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5016,26 +5016,13 @@ def test_preset_add_from_github_release_url_resolves_and_downloads(self, project
50165016

50175017
captured_urls = []
50185018

5019-
class FakeResponse:
5020-
def __init__(self, data):
5021-
self._data = data
5022-
5023-
def read(self):
5024-
return self._data
5025-
5026-
def __enter__(self):
5027-
return self
5028-
5029-
def __exit__(self, *a):
5030-
return False
5031-
50325019
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
50335020
captured_urls.append((url, extra_headers))
50345021
if "releases/tags/" in url:
5035-
return FakeResponse(json.dumps({
5022+
return io.BytesIO(json.dumps({
50365023
"assets": [{"name": "preset.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/42"}]
50375024
}).encode())
5038-
return FakeResponse(zip_bytes)
5025+
return io.BytesIO(zip_bytes)
50395026

50405027
runner = CliRunner()
50415028
with patch.object(Path, "cwd", return_value=project_dir), \
@@ -5074,22 +5061,9 @@ def test_preset_add_from_direct_api_asset_url_passes_through(self, project_dir):
50745061

50755062
captured_urls = []
50765063

5077-
class FakeResponse:
5078-
def __init__(self, data):
5079-
self._data = data
5080-
5081-
def read(self):
5082-
return self._data
5083-
5084-
def __enter__(self):
5085-
return self
5086-
5087-
def __exit__(self, *a):
5088-
return False
5089-
50905064
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
50915065
captured_urls.append((url, extra_headers))
5092-
return FakeResponse(zip_bytes)
5066+
return io.BytesIO(zip_bytes)
50935067

50945068
runner = CliRunner()
50955069
with patch.object(Path, "cwd", return_value=project_dir), \
@@ -5131,26 +5105,13 @@ def test_preset_add_from_ghes_release_url_resolves_via_api_v3(self, project_dir,
51315105

51325106
captured_urls = []
51335107

5134-
class FakeResponse:
5135-
def __init__(self, data):
5136-
self._data = data
5137-
5138-
def read(self):
5139-
return self._data
5140-
5141-
def __enter__(self):
5142-
return self
5143-
5144-
def __exit__(self, *a):
5145-
return False
5146-
51475108
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
51485109
captured_urls.append((url, extra_headers))
51495110
if "releases/tags/" in url:
5150-
return FakeResponse(json.dumps({
5111+
return io.BytesIO(json.dumps({
51515112
"assets": [{"name": "preset.zip", "url": "https://ghes.example/api/v3/repos/org/repo/releases/assets/42"}]
51525113
}).encode())
5153-
return FakeResponse(zip_bytes)
5114+
return io.BytesIO(zip_bytes)
51545115

51555116
runner = CliRunner()
51565117
with patch.object(Path, "cwd", return_value=project_dir), \

0 commit comments

Comments
 (0)