diff --git a/docs/plugins.md b/docs/plugins.md index 8690475..9abb05c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -246,9 +246,10 @@ curl -X DELETE -H "Authorization: Bearer $TOKEN" \ ## Plugin catalogue -Repeater browses a curated static catalogue and installs the exact plugin wheel -approved there. The catalogue and wheels are hosted on openHop's R2-backed -origin, so normal installs and update checks do not call the GitHub API. +Repeater browses the curated static catalogue from openHop's R2-backed endpoint. +Update checks read only that catalogue and do not query the GitHub API. When a +user installs an approved plugin, Repeater downloads the exact wheel directly +from the plugin's GitHub Release. Default catalogue URL: @@ -264,11 +265,12 @@ plugins: ``` Catalogue schema 2 records each plugin's currently approved `version`, exact -`wheel_url`, and lowercase `sha256`. The manager downloads only from -`https://repeater-plugins.openhop.dev/plugins/`, verifies the checksum before -installation, and confirms the wheel manifest ID and version match the -catalogue. Publishing a newer GitHub Release does not make it available until -the catalogue maintainers approve and publish that exact wheel. +GitHub Release `wheel_url`, and lowercase `sha256`. The manager validates that +the URL belongs to the plugin repository, follows only GitHub's HTTPS release +asset redirect, verifies the checksum before installation, and confirms the +wheel manifest ID and version match the catalogue. Publishing a newer GitHub +Release does not make it available until the catalogue maintainers approve its +metadata in the R2 catalogue. Schema 1 repository-only catalogues remain readable for compatibility with custom deployments, but the default openHop catalogue uses schema 2 and needs @@ -297,6 +299,6 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \ http://127.0.0.1:8000/api/plugins/update ``` -R2 catalogue or wheel outages do not affect already-installed plugins or -Repeater startup. Local `.whl` install continues to work unchanged. +R2 catalogue or GitHub Release download outages do not affect already-installed +plugins or Repeater startup. Local `.whl` install continues to work unchanged. diff --git a/repeater/plugins/catalogue.py b/repeater/plugins/catalogue.py index 90fe7d7..3eb71de 100644 --- a/repeater/plugins/catalogue.py +++ b/repeater/plugins/catalogue.py @@ -23,7 +23,8 @@ DEFAULT_CATALOGUE_URL = "https://repeater-plugins.openhop.dev/catalogue.json" DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes SUPPORTED_SCHEMAS = (1, 2) USER_AGENT = "openhop-repeater-plugin-manager/1.0" -APPROVED_WHEEL_ORIGIN = "https://repeater-plugins.openhop.dev" +GITHUB_RELEASE_ORIGIN = "https://github.com" +GITHUB_RELEASE_ASSET_HOST = "release-assets.githubusercontent.com" MAX_WHEEL_BYTES = 100 * 1024 * 1024 _REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") @@ -45,24 +46,62 @@ def _ssl_context() -> ssl.SSLContext: return _ssl_ctx -def _validate_approved_wheel_url(plugin_id: str, version: str, wheel_url: str) -> None: +def _validate_approved_wheel_url(repository: str, version: str, wheel_url: str) -> None: parsed = urlsplit(wheel_url) origin = f"{parsed.scheme}://{parsed.netloc}" - expected_prefix = f"/plugins/{plugin_id}/{version}/" + expected_prefix = f"/{repository}/releases/download/" + path_tail = ( + parsed.path[len(expected_prefix) :] if parsed.path.startswith(expected_prefix) else "" + ) + path_parts = path_tail.split("/") + filename = path_parts[-1] if path_parts else "" if ( - origin != APPROVED_WHEEL_ORIGIN - or not parsed.path.startswith(expected_prefix) - or not parsed.path.endswith(".whl") + origin != GITHUB_RELEASE_ORIGIN + or len(path_parts) != 2 + or not path_parts[0] + or not filename.endswith(".whl") + or f"-{version}-" not in filename or parsed.query or parsed.fragment ): raise CatalogueError( - f"wheel_url must be an approved R2 wheel under " - f"{APPROVED_WHEEL_ORIGIN}{expected_prefix}", + f"wheel_url must be a version-matched GitHub Release wheel in {repository}", code=400, ) +def _validate_redirect_target(source_url: str, target_url: str) -> None: + source = urlsplit(source_url) + target = urlsplit(target_url) + same_origin = ( + source.scheme == "https" and target.scheme == "https" and source.netloc == target.netloc + ) + github_asset_redirect = ( + source.scheme == "https" + and source.hostname in {"github.com", GITHUB_RELEASE_ASSET_HOST} + and target.scheme == "https" + and target.hostname == GITHUB_RELEASE_ASSET_HOST + and target.port is None + ) + if not (same_origin or github_asset_redirect): + raise CatalogueError( + f"download redirect is not allowed: {source_url} -> {target_url}", + code=502, + ) + + +def _validate_download_response_url(source_url: str, final_url: str) -> None: + if final_url == source_url: + return + _validate_redirect_target(source_url, final_url) + + +class _ApprovedRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + _validate_redirect_target(req.full_url, newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + @dataclass(frozen=True) class CataloguePlugin: id: str @@ -204,7 +243,7 @@ def parse_catalogue(data: Any) -> Catalogue: wheel_url = raw_wheel_url.strip() sha256 = raw_sha256 try: - _validate_approved_wheel_url(plugin_id, version, wheel_url) + _validate_approved_wheel_url(repository, version, wheel_url) except CatalogueError as exc: raise CatalogueError(f"plugins[{idx}].{exc}", code=exc.code) from exc if wheel_url in seen_wheel_urls: @@ -248,9 +287,11 @@ class CatalogueClient: self._cached: Optional[Catalogue] = None def _default_open(self, request: urllib.request.Request, timeout: float = 30.0): - return urllib.request.urlopen( # nosec B310 - validated HTTPS URLs - request, timeout=timeout, context=_ssl_context() + opener = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=_ssl_context()), + _ApprovedRedirectHandler(), ) + return opener.open(request, timeout=timeout) # nosec B310 - validated HTTPS URLs def clear_cache(self) -> None: self._cached = None @@ -310,13 +351,13 @@ class CatalogueClient: return plugin def download_wheel(self, plugin: CataloguePlugin, dest_dir: Path | str) -> Path: - """Download and verify one schema-2 approved wheel from R2.""" + """Download and verify one schema-2 approved GitHub Release wheel.""" if not plugin.version or not plugin.wheel_url or not plugin.sha256: raise CatalogueError( f"catalogue entry {plugin.id} does not contain approved wheel metadata", code=400, ) - _validate_approved_wheel_url(plugin.id, plugin.version, plugin.wheel_url) + _validate_approved_wheel_url(plugin.repository, plugin.version, plugin.wheel_url) if not _SHA256_RE.fullmatch(plugin.sha256): raise CatalogueError(f"invalid approved checksum for {plugin.id}", code=400) @@ -339,9 +380,8 @@ class CatalogueClient: try: with self._opener(request, timeout=120.0) as response: final_url = getattr(response, "geturl", lambda: plugin.wheel_url)() - _validate_approved_wheel_url( - plugin.id, - plugin.version, + _validate_download_response_url( + plugin.wheel_url, final_url or plugin.wheel_url, ) while True: diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 2c82c94..cc8a2c1 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -4626,8 +4626,8 @@ paths: - Plugins summary: Install a plugin from the catalogue description: > - Download the exact R2 wheel approved by catalogue version and SHA-256, - verify its manifest, install it, and enable the plugin. + Download the exact GitHub Release wheel approved by the R2 catalogue + version and SHA-256, verify its manifest, install it, and enable the plugin. requestBody: required: true content: diff --git a/tests/test_plugin_catalogue.py b/tests/test_plugin_catalogue.py index ed332fb..7de6b53 100644 --- a/tests/test_plugin_catalogue.py +++ b/tests/test_plugin_catalogue.py @@ -14,6 +14,7 @@ from repeater.plugins.catalogue import ( DEFAULT_CATALOGUE_URL, CatalogueClient, CatalogueError, + _validate_redirect_target, parse_catalogue, ) @@ -42,7 +43,7 @@ VALID_V2 = { "repository": "openhop-dev/openhop-nomad-plugin", "version": "0.1.0", "wheel_url": ( - "https://repeater-plugins.openhop.dev/plugins/openhop.nomad/0.1.0/" + "https://github.com/openhop-dev/openhop-nomad-plugin/releases/download/v0.1.0/" "openhop_nomad_plugin-0.1.0-py3-none-any.whl" ), "sha256": hashlib.sha256(APPROVED_WHEEL).hexdigest(), @@ -99,11 +100,28 @@ def test_download_v2_wheel_rejects_redirect_outside_approved_origin(tmp_path): return _FakeResp(APPROVED_WHEEL, final_url="https://example.com/plugin.whl") plugin = parse_catalogue(VALID_V2).plugins[0] - with pytest.raises(CatalogueError, match="approved R2"): + with pytest.raises(CatalogueError, match="not allowed"): CatalogueClient(opener=opener).download_wheel(plugin, tmp_path) assert list(tmp_path.iterdir()) == [] +def test_github_release_asset_redirect_is_allowed(): + _validate_redirect_target( + "https://github.com/openhop-dev/openhop-nomad-plugin/releases/download/v0.1.0/" + "openhop_nomad_plugin-0.1.0-py3-none-any.whl", + "https://release-assets.githubusercontent.com/github-production-release-asset.whl", + ) + + +def test_https_downgrade_redirect_is_rejected(): + with pytest.raises(CatalogueError, match="not allowed"): + _validate_redirect_target( + "https://github.com/openhop-dev/openhop-nomad-plugin/releases/download/v0.1.0/" + "openhop_nomad_plugin-0.1.0-py3-none-any.whl", + "http://release-assets.githubusercontent.com/asset.whl", + ) + + def test_unsupported_schema(): with pytest.raises(CatalogueError, match="unsupported catalogue schema"): parse_catalogue({"schema": 99, "plugins": []}) diff --git a/tests/test_plugin_r2_catalogue_install.py b/tests/test_plugin_github_catalogue_install.py similarity index 63% rename from tests/test_plugin_r2_catalogue_install.py rename to tests/test_plugin_github_catalogue_install.py index 7bf9b46..d2f92cd 100644 --- a/tests/test_plugin_r2_catalogue_install.py +++ b/tests/test_plugin_github_catalogue_install.py @@ -1,4 +1,4 @@ -"""Approved R2 catalogue install and update flows.""" +"""R2 catalogue lookup with approved GitHub Release install and update flows.""" from __future__ import annotations @@ -63,7 +63,7 @@ def _entry(version: str, wheel: bytes) -> dict: "category": "test", "version": version, "wheel_url": ( - f"https://repeater-plugins.openhop.dev/plugins/openhop.demo/{version}/" + f"https://github.com/openhop-dev/demo-plugin/releases/download/v{version}/" f"demo-{version}-py3-none-any.whl" ), "sha256": hashlib.sha256(wheel).hexdigest(), @@ -71,7 +71,7 @@ def _entry(version: str, wheel: bytes) -> dict: @pytest.fixture -def r2_manager(tmp_path: Path): +def catalogue_manager(tmp_path: Path): wheels = { "0.1.0": _make_wheel(tmp_path / "demo-0.1.0.whl", "openhop.demo", "0.1.0"), "0.2.0": _make_wheel(tmp_path / "demo-0.2.0.whl", "openhop.demo", "0.2.0"), @@ -94,48 +94,50 @@ def r2_manager(tmp_path: Path): catalogue_client=catalogue, github_client=github, ) - manager._r2_test_state = state # type: ignore[attr-defined] - manager._r2_test_wheels = wheels # type: ignore[attr-defined] + manager._catalogue_test_state = state # type: ignore[attr-defined] + manager._catalogue_test_wheels = wheels # type: ignore[attr-defined] return manager -def test_schema2_install_uses_only_approved_r2_wheel(r2_manager: PluginManager): - status = r2_manager.install_from_catalogue("openhop.demo") +def test_schema2_install_uses_only_approved_github_wheel(catalogue_manager: PluginManager): + status = catalogue_manager.install_from_catalogue("openhop.demo") assert status["version"] == "0.1.0" assert status["enabled"] is True assert status["source"] == "catalogue" - assert all("api.github.com" not in url for url in r2_manager._r2_test_state["requests"]) - r2_manager.github.latest_stable.assert_not_called() - r2_manager.github.download_latest_wheel.assert_not_called() + assert all( + "api.github.com" not in url for url in catalogue_manager._catalogue_test_state["requests"] + ) + catalogue_manager.github.latest_stable.assert_not_called() + catalogue_manager.github.download_latest_wheel.assert_not_called() -def test_schema2_catalogue_version_controls_updates(r2_manager: PluginManager): - r2_manager.install_from_catalogue("openhop.demo") - wheels = r2_manager._r2_test_wheels - r2_manager._r2_test_state["entry"] = _entry("0.2.0", wheels["0.2.0"]) - r2_manager.catalogue.clear_cache() +def test_schema2_catalogue_version_controls_updates(catalogue_manager: PluginManager): + catalogue_manager.install_from_catalogue("openhop.demo") + wheels = catalogue_manager._catalogue_test_wheels + catalogue_manager._catalogue_test_state["entry"] = _entry("0.2.0", wheels["0.2.0"]) + catalogue_manager.catalogue.clear_cache() - check = r2_manager.check_update("openhop.demo", force_refresh=True) + check = catalogue_manager.check_update("openhop.demo", force_refresh=True) assert check["latestVersion"] == "0.2.0" assert check["updateAvailable"] is True - updated = r2_manager.update_plugin("openhop.demo", force_refresh=True) + updated = catalogue_manager.update_plugin("openhop.demo", force_refresh=True) assert updated["version"] == "0.2.0" assert updated["enabled"] is True - r2_manager.github.latest_stable.assert_not_called() - r2_manager.github.download_wheel.assert_not_called() + catalogue_manager.github.latest_stable.assert_not_called() + catalogue_manager.github.download_wheel.assert_not_called() -def test_schema2_rejects_unapproved_requested_version(r2_manager: PluginManager): +def test_schema2_rejects_unapproved_requested_version(catalogue_manager: PluginManager): with pytest.raises(PluginManagerError, match="not approved"): - r2_manager.install_from_catalogue("openhop.demo", version="9.9.9") + catalogue_manager.install_from_catalogue("openhop.demo", version="9.9.9") -def test_schema2_checksum_failure_does_not_install(r2_manager: PluginManager): - r2_manager._r2_test_state["entry"]["sha256"] = "0" * 64 - r2_manager.catalogue.clear_cache() +def test_schema2_checksum_failure_does_not_install(catalogue_manager: PluginManager): + catalogue_manager._catalogue_test_state["entry"]["sha256"] = "0" * 64 + catalogue_manager.catalogue.clear_cache() with pytest.raises(PluginManagerError, match="checksum"): - r2_manager.install_from_catalogue("openhop.demo", force_refresh=True) - assert r2_manager.storage.list_plugin_ids() == [] + catalogue_manager.install_from_catalogue("openhop.demo", force_refresh=True) + assert catalogue_manager.storage.list_plugin_ids() == []