Skip to content

Commit 1f1b20c

Browse files
committed
fix: stabilize security rebase follow-ups
1 parent c6dd574 commit 1f1b20c

8 files changed

Lines changed: 110 additions & 89 deletions

File tree

src/specify_cli/authentication/http.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,10 @@ def _validate_strict_redirect(_old_url: str, new_url: str) -> None:
7272
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
7373
"""Redirect handler that guards every redirect it is installed for.
7474
75-
1. Reject redirects that are not HTTPS with a hostname, except HTTP to
75+
1. Run any caller-provided redirect validator.
76+
2. Reject redirects that are not HTTPS with a hostname, except HTTP to
7677
localhost / 127.0.0.1 / ::1 (the exact hosts allowed by
7778
``is_https_or_localhost_http``).
78-
2. Run any caller-provided redirect validator.
7979
3. Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades.
8080
"""
8181

@@ -89,9 +89,9 @@ def __init__(
8989
self._redirect_validator = redirect_validator
9090

9191
def redirect_request(self, req, fp, code, msg, headers, newurl):
92-
_validate_strict_redirect(req.full_url, newurl)
9392
if self._redirect_validator is not None:
9493
self._redirect_validator(req.full_url, newurl)
94+
_validate_strict_redirect(req.full_url, newurl)
9595

9696
original_auth = (
9797
req.get_header("Authorization")
@@ -164,6 +164,7 @@ def open_url(
164164
entries = find_entries_for_url(url, _load_config())
165165

166166
effective_redirect_validator = redirect_validator
167+
use_redirect_handler = strict_redirects or effective_redirect_validator is not None
167168

168169
def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
169170
merged = {}
@@ -195,7 +196,7 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
195196

196197
# No entry worked (or none matched) — unauthenticated fallback
197198
req = _make_req({})
198-
if effective_redirect_validator is not None:
199+
if use_redirect_handler:
199200
# No auth is attached on this path, so the handler's host list is empty:
200201
# here it runs redirect validation only, not auth stripping.
201202
opener = urllib.request.build_opener(_StripAuthOnRedirect((), effective_redirect_validator))

src/specify_cli/presets/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ def _validate(self):
238238
f"Invalid template file path '{file_path}': "
239239
"must be a relative path within the preset directory"
240240
)
241+
tmpl["file"] = normalized
241242

242243
# Validate strategy field (optional, defaults to "replace")
243244
strategy = tmpl.get("strategy", "replace")

tests/integrations/test_integration_catalog.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ def __exit__(self, *_args):
340340
pass
341341

342342
def fake_urlopen(url, timeout=10):
343-
assert url == entry.url
343+
actual_url = url.full_url if hasattr(url, "full_url") else url
344+
assert actual_url == entry.url
344345
assert timeout == 10
345346
return FakeResponse()
346347

tests/test_authentication.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -799,17 +799,18 @@ def test_redirect_outside_hosts_strips_auth(self):
799799
assert new_req.headers.get("Authorization") is None
800800
assert new_req.unredirected_hdrs.get("Authorization") is None
801801

802-
def test_https_to_http_same_host_redirect_strips_auth(self):
802+
def test_https_to_http_same_host_redirect_rejected(self):
803803
from specify_cli.authentication.http import _StripAuthOnRedirect
804804
from urllib.request import Request
805805
import io
806+
import urllib.error
807+
806808
handler = _StripAuthOnRedirect(("github.com",))
807809
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
808-
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
809-
"http://github.com/org/repo")
810-
assert new_req is not None
811-
assert new_req.headers.get("Authorization") is None
812-
assert new_req.unredirected_hdrs.get("Authorization") is None
810+
811+
with pytest.raises(urllib.error.URLError, match="unsafe redirect"):
812+
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
813+
"http://github.com/org/repo")
813814

814815
def test_redirect_validator_can_reject_before_following_redirect(self):
815816
import urllib.error

tests/test_extensions.py

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3270,7 +3270,7 @@ def test_fetch_single_catalog_rejects_malformed_payload(self, temp_dir, payload)
32703270
catalog = self._make_catalog(temp_dir)
32713271

32723272
mock_response = MagicMock()
3273-
mock_response.read.return_value = json.dumps(payload).encode()
3273+
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
32743274
mock_response.__enter__ = lambda s: s
32753275
mock_response.__exit__ = MagicMock(return_value=False)
32763276

@@ -3337,10 +3337,12 @@ def test_fetch_single_catalog_rejects_malformed_cached_payload(
33373337
"schema_version": "1.0",
33383338
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
33393339
}
3340-
mock_response = MagicMock()
3341-
mock_response.read.return_value = json.dumps(valid).encode()
3342-
mock_response.__enter__ = lambda s: s
3343-
mock_response.__exit__ = MagicMock(return_value=False)
3340+
def make_response():
3341+
mock_response = MagicMock()
3342+
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
3343+
mock_response.__enter__ = lambda s: s
3344+
mock_response.__exit__ = MagicMock(return_value=False)
3345+
return mock_response
33443346

33453347
entry = CatalogEntry(
33463348
url=ExtensionCatalog.DEFAULT_CATALOG_URL,
@@ -3349,7 +3351,7 @@ def test_fetch_single_catalog_rejects_malformed_cached_payload(
33493351
install_allowed=True,
33503352
)
33513353

3352-
with patch.object(catalog, "_open_url", return_value=mock_response):
3354+
with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()):
33533355
result = catalog._fetch_single_catalog(entry, force_refresh=False)
33543356

33553357
# The poisoned cache was discarded and the network payload returned.
@@ -3385,7 +3387,7 @@ def test_fetch_catalog_rejects_malformed_payload(self, temp_dir, payload):
33853387

33863388
catalog = self._make_catalog(temp_dir)
33873389
mock_response = MagicMock()
3388-
mock_response.read.return_value = json.dumps(payload).encode()
3390+
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
33893391
mock_response.__enter__ = lambda s: s
33903392
mock_response.__exit__ = MagicMock(return_value=False)
33913393

@@ -3424,12 +3426,14 @@ def test_fetch_catalog_recovers_from_unreadable_cache(self, temp_dir):
34243426
"schema_version": "1.0",
34253427
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
34263428
}
3427-
mock_response = MagicMock()
3428-
mock_response.read.return_value = json.dumps(valid).encode()
3429-
mock_response.__enter__ = lambda s: s
3430-
mock_response.__exit__ = MagicMock(return_value=False)
3429+
def make_response():
3430+
mock_response = MagicMock()
3431+
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
3432+
mock_response.__enter__ = lambda s: s
3433+
mock_response.__exit__ = MagicMock(return_value=False)
3434+
return mock_response
34313435

3432-
with patch.object(catalog, "_open_url", return_value=mock_response):
3436+
with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()):
34333437
result = catalog.fetch_catalog(force_refresh=False)
34343438

34353439
# Recovered via network rather than crashing on the unreadable cache.
@@ -3463,7 +3467,7 @@ def test_fetch_catalog_recovers_from_unreadable_metadata(self, temp_dir):
34633467
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
34643468
}
34653469
mock_response = MagicMock()
3466-
mock_response.read.return_value = json.dumps(valid).encode()
3470+
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
34673471
mock_response.__enter__ = lambda s: s
34683472
mock_response.__exit__ = MagicMock(return_value=False)
34693473

@@ -3537,7 +3541,7 @@ def test_fetch_catalog_writes_cache_as_utf8(self, temp_dir, monkeypatch):
35373541
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
35383542
}
35393543
mock_response = MagicMock()
3540-
mock_response.read.return_value = json.dumps(payload).encode("utf-8")
3544+
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode("utf-8")).read
35413545
mock_response.__enter__ = lambda s: s
35423546
mock_response.__exit__ = MagicMock(return_value=False)
35433547

@@ -3587,10 +3591,12 @@ def test_fetch_catalog_survives_unwritable_cache(self, temp_dir, monkeypatch):
35873591
"schema_version": "1.0",
35883592
"extensions": {"foo": {"name": "Foo", "version": "1.0.0"}},
35893593
}
3590-
mock_response = MagicMock()
3591-
mock_response.read.return_value = json.dumps(valid).encode()
3592-
mock_response.__enter__ = lambda s: s
3593-
mock_response.__exit__ = MagicMock(return_value=False)
3594+
def make_response():
3595+
mock_response = MagicMock()
3596+
mock_response.read.side_effect = io.BytesIO(json.dumps(valid).encode()).read
3597+
mock_response.__enter__ = lambda s: s
3598+
mock_response.__exit__ = MagicMock(return_value=False)
3599+
return mock_response
35943600

35953601
# Simulate an unwritable cache dir: every write_text under the
35963602
# cache directory raises PermissionError (an OSError subclass).
@@ -3603,7 +3609,7 @@ def failing_write_text(self, data, *args, **kwargs):
36033609

36043610
monkeypatch.setattr(_PathCls, "write_text", failing_write_text)
36053611

3606-
with patch.object(catalog, "_open_url", return_value=mock_response):
3612+
with patch.object(catalog, "_open_url", side_effect=lambda *a, **kw: make_response()):
36073613
# Legacy single-catalog path.
36083614
assert catalog.fetch_catalog(force_refresh=True) == valid
36093615

@@ -3639,7 +3645,7 @@ def test_get_merged_extensions_skips_non_mapping_entries(self, temp_dir):
36393645
},
36403646
}
36413647
mock_response = MagicMock()
3642-
mock_response.read.return_value = json.dumps(payload).encode()
3648+
mock_response.read.side_effect = io.BytesIO(json.dumps(payload).encode()).read
36433649
mock_response.__enter__ = lambda s: s
36443650
mock_response.__exit__ = MagicMock(return_value=False)
36453651

@@ -3777,7 +3783,7 @@ def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir,
37773783
zip_bytes = zip_buf.getvalue()
37783784

37793785
asset_response = MagicMock()
3780-
asset_response.read.return_value = zip_bytes
3786+
asset_response.read.side_effect = io.BytesIO(zip_bytes).read
37813787
asset_response.__enter__ = lambda s: s
37823788
asset_response.__exit__ = MagicMock(return_value=False)
37833789

tests/test_github_http.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def _make_open_url_fn(self, release_json):
9494
@contextmanager
9595
def fake_open(url, timeout=None, extra_headers=None):
9696
resp = MagicMock()
97-
resp.read.return_value = json.dumps(release_json).encode()
97+
resp.read.side_effect = io.BytesIO(json.dumps(release_json).encode()).read
9898
yield resp
9999
return fake_open
100100

@@ -148,7 +148,7 @@ def test_returns_none_on_network_error(self):
148148
@contextmanager
149149
def failing_open(url, timeout=None, extra_headers=None):
150150
raise urllib.error.URLError("network error")
151-
yield # noqa: unreachable
151+
yield # pragma: no cover
152152

153153
result = resolve_github_release_asset_api_url(
154154
"https://github.com/org/repo/releases/download/v1/pack.zip",
@@ -164,7 +164,7 @@ def test_tag_with_special_characters_is_url_encoded(self):
164164
def capturing_open(url, timeout=None, extra_headers=None):
165165
captured_urls.append(url)
166166
resp = MagicMock()
167-
resp.read.return_value = json.dumps({"assets": []}).encode()
167+
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
168168
yield resp
169169

170170
resolve_github_release_asset_api_url(
@@ -183,7 +183,7 @@ def test_tag_with_hash_is_url_encoded(self):
183183
def capturing_open(url, timeout=None, extra_headers=None):
184184
captured_urls.append(url)
185185
resp = MagicMock()
186-
resp.read.return_value = json.dumps({"assets": []}).encode()
186+
resp.read.side_effect = io.BytesIO(json.dumps({"assets": []}).encode()).read
187187
yield resp
188188

189189
resolve_github_release_asset_api_url(

0 commit comments

Comments
 (0)