Skip to content

Commit bc6ee95

Browse files
committed
fix: reject non-HTTPS redirects before following, validate workflow ID format
- Replace urlopen with custom HTTPRedirectHandler that validates each redirect URL scheme before following it (prevents non-HTTPS requests) - Validate workflow ID against _ID_PATTERN (lowercase alnum + hyphens) before any filesystem operations in both add and update commands, preventing accidental deletion of reserved directories
1 parent 7414b5f commit bc6ee95

1 file changed

Lines changed: 23 additions & 10 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4893,17 +4893,22 @@ def _validate_url_scheme(url: str) -> None:
48934893

48944894

48954895
def _download_validated(source_url: str, destination: Path) -> None:
4896-
"""Download a URL to *destination*, validating HTTPS before and after redirects."""
4896+
"""Download a URL to *destination*, rejecting non-HTTPS redirects before following them."""
48974897
import shutil as _shutil
4898-
from urllib.request import urlopen # noqa: S310
4898+
from urllib.request import build_opener, HTTPRedirectHandler, Request # noqa: S310
48994899

49004900
_validate_url_scheme(source_url)
4901-
with urlopen(source_url, timeout=30) as resp: # noqa: S310
4902-
final_url = resp.geturl()
4903-
try:
4904-
_validate_url_scheme(final_url)
4905-
except ValueError:
4906-
raise ValueError(f"Redirected to non-HTTPS: {final_url}")
4901+
4902+
class _SafeRedirectHandler(HTTPRedirectHandler):
4903+
def redirect_request(self, req, fp, code, msg, headers, newurl):
4904+
try:
4905+
_validate_url_scheme(newurl)
4906+
except ValueError:
4907+
raise ValueError(f"Redirected to non-HTTPS: {newurl}")
4908+
return super().redirect_request(req, fp, code, msg, headers, newurl)
4909+
4910+
opener = build_opener(_SafeRedirectHandler)
4911+
with opener.open(source_url, timeout=30) as resp:
49074912
with destination.open("wb") as dest_file:
49084913
_shutil.copyfileobj(resp, dest_file)
49094914

@@ -5088,7 +5093,11 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
50885093
raise typer.Exit(1)
50895094

50905095
workflow_dir = workflows_dir / source
5091-
# Validate that source is a safe directory name (no path traversal)
5096+
# Validate workflow ID format and path safety
5097+
import re as _re
5098+
if not _re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", source):
5099+
console.print(f"[red]Error:[/red] Invalid workflow ID: {source!r}")
5100+
raise typer.Exit(1)
50925101
try:
50935102
workflow_dir.resolve().relative_to(workflows_dir.resolve())
50945103
except ValueError:
@@ -5399,7 +5408,11 @@ def workflow_update(
53995408
continue
54005409

54015410
wf_dir = workflows_dir / wf_id
5402-
# Validate that wf_id is a safe directory name (no path traversal)
5411+
# Validate workflow ID format and path safety
5412+
import re as _re
5413+
if not _re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", wf_id):
5414+
console.print(f"⚠ {wf_id}: Invalid workflow ID format (skipping)")
5415+
continue
54035416
try:
54045417
wf_dir.resolve().relative_to(workflows_dir.resolve())
54055418
except ValueError:

0 commit comments

Comments
 (0)