feat(plugins): installs fetch only what the catalog names, verified (#5032)
#5032 "Plugin install takes its URL and checksum from the catalog, requires the checksum, and extracts only inside the plugin folder". The install form carried the download URL and checksum as hidden fields, so any admin POST could make Steward fetch and import code from anywhere, unchecked, and a zip member like ../x.py was written wherever it pointed. - The route takes only the plugin name and reads download_url and checksum_sha256 from the configured catalogs; a name no repository lists is refused. Installs are audited (plugin.install / plugin.install_failed). - The checksum is required; the name must be a plain identifier. - extract_plugin refuses any member that resolves outside the plugin folder, caps the download at 50 MB and the unpacked size at 200 MB. - The zip unpacks into a staging folder and is swapped in whole: a refused or failed install leaves the installed copy untouched, and an update no longer leaves the old version's deleted files behind. - The catalog shows "Can't install: the catalog lists no checksum" in place of the button for an entry without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -820,23 +820,34 @@ async def plugins_catalog():
|
||||
@settings_bp.post("/plugins/install/<name>")
|
||||
@require_role(UserRole.admin)
|
||||
async def install_plugin(name: str):
|
||||
"""Download and install a plugin from the catalog, then hot-reload it."""
|
||||
form = await request.form
|
||||
download_url = form.get("download_url", "").strip()
|
||||
checksum = form.get("checksum_sha256", "").strip()
|
||||
"""Download and install a plugin from the catalog, then hot-reload it.
|
||||
|
||||
if not download_url:
|
||||
Only the plugin's name comes from the request. The download URL and
|
||||
checksum are read from the configured catalogs, so a request cannot point
|
||||
Steward at a file of its own choosing.
|
||||
"""
|
||||
from steward.core.plugin_index import fetch_catalog
|
||||
from steward.core.plugin_manager import download_and_install_plugin, hot_reload_plugin
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
settings = await get_all_settings(db)
|
||||
repo_urls = [r["url"] for r in _get_plugin_repos(settings) if r.get("url")]
|
||||
entry = next((p for p in await fetch_catalog(repo_urls) if p.name == name), None)
|
||||
if entry is None:
|
||||
return await render_template(
|
||||
"settings/_install_result.html",
|
||||
name=name, success=False,
|
||||
message="No download URL provided.",
|
||||
message="No configured plugin repository lists this plugin.",
|
||||
)
|
||||
|
||||
from steward.core.plugin_manager import download_and_install_plugin, hot_reload_plugin
|
||||
|
||||
ok, msg = await download_and_install_plugin(
|
||||
current_app._get_current_object(), name, download_url, checksum
|
||||
current_app._get_current_object(), entry.name, entry.download_url, entry.checksum_sha256
|
||||
)
|
||||
await log_audit(
|
||||
current_app, session.get("user_id"), session.get("username", ""),
|
||||
"plugin.install" if ok else "plugin.install_failed", entity_type="plugin",
|
||||
entity_id=name, detail={"version": entry.version, "url": entry.download_url,
|
||||
"message": msg})
|
||||
if not ok:
|
||||
return await render_template(
|
||||
"settings/_install_result.html",
|
||||
|
||||
Reference in New Issue
Block a user