Skip to content

Commit ddcc165

Browse files
committed
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
1 parent 31c7151 commit ddcc165

1 file changed

Lines changed: 46 additions & 7 deletions

File tree

src/specify_cli/extensions/_commands.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from __future__ import annotations
1010

1111
import os
12+
import re
1213
import shutil
1314
import zipfile
1415
from pathlib import Path
@@ -181,19 +182,24 @@ def extension_list(
181182
all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"),
182183
):
183184
"""List installed extensions."""
184-
from . import ExtensionManager
185+
from . import ExtensionManager, ExtensionCatalog, ExtensionError
185186

186187
project_root = _require_specify_project()
187188
manager = ExtensionManager(project_root)
188189
installed = manager.list_installed()
189190

190-
if not installed and not (available or all_extensions):
191+
# Default (no flags) lists installed; --all also lists installed.
192+
# --available alone lists only catalog extensions, not installed.
193+
show_installed = all_extensions or not available
194+
show_available = available or all_extensions
195+
196+
if not installed and not show_available:
191197
console.print("[yellow]No extensions installed.[/yellow]")
192198
console.print("\nInstall an extension with:")
193199
console.print(" specify extension add <extension-name>")
194200
return
195201

196-
if installed:
202+
if show_installed and installed:
197203
console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n")
198204

199205
for ext in installed:
@@ -206,9 +212,36 @@ def extension_list(
206212
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
207213
console.print()
208214

209-
if available or all_extensions:
210-
console.print("\nInstall an extension:")
211-
console.print(" [cyan]specify extension add <name>[/cyan]")
215+
if show_available:
216+
# Query the catalog and show extensions that are not already installed.
217+
catalog = ExtensionCatalog(project_root)
218+
installed_ids = {ext["id"] for ext in installed}
219+
220+
try:
221+
results = catalog.search()
222+
except ExtensionError as e:
223+
console.print(f"\n[red]Error:[/red] Could not query extension catalog: {e}")
224+
console.print("[dim]The catalog may be temporarily unavailable. Try again later.[/dim]")
225+
raise typer.Exit(1)
226+
227+
available_exts = [ext for ext in results if ext.get("id") not in installed_ids]
228+
229+
console.print("\n[bold cyan]Available Extensions:[/bold cyan]\n")
230+
if not available_exts:
231+
console.print(" [dim]No additional extensions available in the catalog.[/dim]")
232+
else:
233+
for ext in available_exts:
234+
verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else ""
235+
console.print(f" [bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}")
236+
console.print(f" [dim]{ext['id']}[/dim]")
237+
console.print(f" {ext.get('description', '')}")
238+
install_allowed = ext.get("_install_allowed", True)
239+
if install_allowed:
240+
console.print(f" [cyan]Install:[/cyan] specify extension add {ext['id']}")
241+
else:
242+
catalog_name = ext.get("_catalog_name", "")
243+
console.print(f" [yellow]Discovery only — not installable from '{catalog_name}'[/yellow]")
244+
console.print()
212245

213246

214247
@catalog_app.command("list")
@@ -463,7 +496,13 @@ def extension_add(
463496
# Download ZIP to temp location
464497
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
465498
download_dir.mkdir(parents=True, exist_ok=True)
466-
zip_path = download_dir / f"{extension}-url-download.zip"
499+
# Sanitize the extension label before using it in a filename:
500+
# the raw argument may contain path separators (e.g. "../x") that
501+
# would let the download escape download_dir and overwrite
502+
# arbitrary files (path traversal). Keep only safe characters and
503+
# fall back to a fixed stem if nothing usable remains.
504+
safe_label = re.sub(r"[^A-Za-z0-9._-]", "_", extension).strip("._") or "extension"
505+
zip_path = download_dir / f"{safe_label}-url-download.zip"
467506

468507
try:
469508
from specify_cli.authentication.http import open_url as _open_url

0 commit comments

Comments
 (0)