Skip to content

Commit d636aaa

Browse files
committed
fix: validate redirect URL and reject drive-qualified paths
- Validate final URL after redirects with _validate_catalog_url() - Reject paths with Path.drive or Path.anchor for Windows safety - Update FakeResponse mocks with geturl() method
1 parent b621567 commit d636aaa

File tree

2 files changed

+17
-6
lines changed

2 files changed

+17
-6
lines changed

src/specify_cli/integrations/catalog.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ def _fetch_single_catalog(
257257

258258
try:
259259
with urllib.request.urlopen(entry.url, timeout=10) as resp:
260+
# Validate final URL after redirects
261+
final_url = resp.geturl()
262+
if final_url != entry.url:
263+
self._validate_catalog_url(final_url)
260264
catalog_data = json.loads(resp.read())
261265

262266
if not isinstance(catalog_data, dict):
@@ -551,7 +555,7 @@ def _validate(self) -> None:
551555
raise IntegrationDescriptorError(
552556
"Command entry 'file' must be a non-empty string"
553557
)
554-
if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts:
558+
if os.path.isabs(cmd_file) or ".." in Path(cmd_file).parts or Path(cmd_file).drive or Path(cmd_file).anchor:
555559
raise IntegrationDescriptorError(
556560
f"Command entry 'file' must be a relative path without '..': {cmd_file}"
557561
)
@@ -560,7 +564,7 @@ def _validate(self) -> None:
560564
raise IntegrationDescriptorError(
561565
"Script entry must be a non-empty string"
562566
)
563-
if os.path.isabs(script_entry) or ".." in Path(script_entry).parts:
567+
if os.path.isabs(script_entry) or ".." in Path(script_entry).parts or Path(script_entry).drive or Path(script_entry).anchor:
564568
raise IntegrationDescriptorError(
565569
f"Script entry must be a relative path without '..': {script_entry}"
566570
)

tests/integrations/test_integration_catalog.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,20 +130,24 @@ def _patch_urlopen(self, monkeypatch, catalog_data):
130130
"""Patch urllib.request.urlopen to return *catalog_data*."""
131131

132132
class FakeResponse:
133-
def __init__(self, data):
133+
def __init__(self, data, url=""):
134134
self._data = json.dumps(data).encode()
135+
self._url = url
135136

136137
def read(self):
137138
return self._data
138139

140+
def geturl(self):
141+
return self._url
142+
139143
def __enter__(self):
140144
return self
141145

142146
def __exit__(self, *a):
143147
pass
144148

145149
def fake_urlopen(url, timeout=10):
146-
return FakeResponse(catalog_data)
150+
return FakeResponse(catalog_data, url)
147151

148152
import urllib.request
149153
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
@@ -431,16 +435,19 @@ def test_list_catalog_flag(self, tmp_path, monkeypatch):
431435
import urllib.request
432436

433437
class FakeResponse:
434-
def __init__(self, data):
438+
def __init__(self, data, url=""):
435439
self._data = json.dumps(data).encode()
440+
self._url = url
436441
def read(self):
437442
return self._data
443+
def geturl(self):
444+
return self._url
438445
def __enter__(self):
439446
return self
440447
def __exit__(self, *a):
441448
pass
442449

443-
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog))
450+
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
444451

445452
old = os.getcwd()
446453
try:

0 commit comments

Comments
 (0)