From baf954ac7e59c460f0a325f9fdcffa3fb7e549e5 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 17 Jun 2026 20:52:57 +0800 Subject: [PATCH 1/2] fix(extensions): close path traversal and make `list --available` query the catalog - extension add --from: sanitize the extension label before building the download filename so "../" path separators can no longer escape the downloads dir and overwrite arbitrary files - extension list --available/--all: actually query the catalog and list uninstalled extensions (filtering out installed IDs), instead of only printing a static install hint that contradicted the CLI help and docs --- src/specify_cli/extensions/_commands.py | 53 +++++++++++++++++++++---- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 4df76f0596..8c713037f7 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -9,6 +9,7 @@ from __future__ import annotations import os +import re import shutil import zipfile from pathlib import Path @@ -181,19 +182,24 @@ def extension_list( all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"), ): """List installed extensions.""" - from . import ExtensionManager + from . import ExtensionManager, ExtensionCatalog, ExtensionError project_root = _require_specify_project() manager = ExtensionManager(project_root) installed = manager.list_installed() - if not installed and not (available or all_extensions): + # Default (no flags) lists installed; --all also lists installed. + # --available alone lists only catalog extensions, not installed. + show_installed = all_extensions or not available + show_available = available or all_extensions + + if not installed and not show_available: console.print("[yellow]No extensions installed.[/yellow]") console.print("\nInstall an extension with:") console.print(" specify extension add ") return - if installed: + if show_installed and installed: console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n") for ext in installed: @@ -206,9 +212,36 @@ def extension_list( console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}") console.print() - if available or all_extensions: - console.print("\nInstall an extension:") - console.print(" [cyan]specify extension add [/cyan]") + if show_available: + # Query the catalog and show extensions that are not already installed. + catalog = ExtensionCatalog(project_root) + installed_ids = {ext["id"] for ext in installed} + + try: + results = catalog.search() + except ExtensionError as e: + console.print(f"\n[red]Error:[/red] Could not query extension catalog: {e}") + console.print("[dim]The catalog may be temporarily unavailable. Try again later.[/dim]") + raise typer.Exit(1) + + available_exts = [ext for ext in results if ext.get("id") not in installed_ids] + + console.print("\n[bold cyan]Available Extensions:[/bold cyan]\n") + if not available_exts: + console.print(" [dim]No additional extensions available in the catalog.[/dim]") + else: + for ext in available_exts: + verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else "" + console.print(f" [bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}") + console.print(f" [dim]{ext['id']}[/dim]") + console.print(f" {ext.get('description', '')}") + install_allowed = ext.get("_install_allowed", True) + if install_allowed: + console.print(f" [cyan]Install:[/cyan] specify extension add {ext['id']}") + else: + catalog_name = ext.get("_catalog_name", "") + console.print(f" [yellow]Discovery only — not installable from '{catalog_name}'[/yellow]") + console.print() @catalog_app.command("list") @@ -463,7 +496,13 @@ def extension_add( # Download ZIP to temp location download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads" download_dir.mkdir(parents=True, exist_ok=True) - zip_path = download_dir / f"{extension}-url-download.zip" + # Sanitize the extension label before using it in a filename: + # the raw argument may contain path separators (e.g. "../x") that + # would let the download escape download_dir and overwrite + # arbitrary files (path traversal). Keep only safe characters and + # fall back to a fixed stem if nothing usable remains. + safe_label = re.sub(r"[^A-Za-z0-9._-]", "_", extension).strip("._") or "extension" + zip_path = download_dir / f"{safe_label}-url-download.zip" try: from specify_cli.authentication.http import open_url as _open_url From 094f5eeb6c20da9975161cecfd4e6b7c0b95d4d9 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Thu, 18 Jun 2026 12:01:54 +0800 Subject: [PATCH 2/2] test(extensions): cover list --available catalog query and add --from path traversal Add regression coverage for the two behaviors wired up in the preceding fix: - list --available/--all: queries the catalog, filters installed IDs, marks discovery-only entries, reports an empty catalog, and exits 1 on catalog failure. - add --from : a label containing path separators is sanitized so the download cannot escape the downloads cache dir. Both suites were verified red against the pre-fix behavior and green after. --- tests/test_extension_add_path_traversal.py | 69 +++++++++++ tests/test_extension_list_available.py | 135 +++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 tests/test_extension_add_path_traversal.py create mode 100644 tests/test_extension_list_available.py diff --git a/tests/test_extension_add_path_traversal.py b/tests/test_extension_add_path_traversal.py new file mode 100644 index 0000000000..e426e34b1e --- /dev/null +++ b/tests/test_extension_add_path_traversal.py @@ -0,0 +1,69 @@ +"""Security test for `specify extension add