From 814bc903d8ec9bf9cc7ea10e6e65e5ddd1a522bb Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Wed, 17 Jun 2026 20:52:57 +0800 Subject: [PATCH 1/4] 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 | 44 +++++++++++++++++++++---- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py index 3b60b6d52d..dc40df6ec9 100644 --- a/src/specify_cli/extensions/_commands.py +++ b/src/specify_cli/extensions/_commands.py @@ -208,19 +208,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: @@ -233,9 +238,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") From d468c0220d28f657f74e0da24e6b9c5d30d57b05 Mon Sep 17 00:00:00 2001 From: wangchenguang Date: Thu, 18 Jun 2026 12:01:54 +0800 Subject: [PATCH 2/4] 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