A03: Manual redirect control with per-hop validation

Replace urlopen automatic redirect following with custom HTTPRedirectHandler
that raises on 3xx redirects. Each redirect hop is validated (scheme, host,
port) before the request is made using _validate_url_before_io().

Key changes:
- _StrictRedirectHandler intercepts 301/302/303/307/308 responses
- _extract_redirect_url() extracts Location header from redirect responses
- fetch_html() manually follows redirects with hop count limit (MAX_REDIRECT_HOPS=5)
- Relative redirect URLs resolved with urljoin() before validation
- All redirect targets validated against ALLOWED_HOSTS, ALLOWED_PORTS, HTTPS-only

Tests:
- test_fetch_html_redirect_to_disallowed_host_rejected (mocked redirect)
- test_fetch_html_redirect_chain_limit (exceeds MAX_REDIRECT_HOPS)
- test_extract_redirect_url_from_headers (Location/location headers)
- test_urljoin_resolves_relative_redirects (relative URL resolution)
This commit is contained in:
ik
2026-09-10 06:26:15 +07:00
parent 4ac50db1db
commit d0d208ebd7
2 changed files with 204 additions and 25 deletions
+132
View File
@@ -141,3 +141,135 @@ def test_check_and_reserve_atomic_under_concurrent_access(tmp_path: Path) -> Non
assert ok_count == 1, f"Expected exactly 1 ok, got {ok_count}: {results}"
denied_count = sum(1 for _, r in results if "cooldown" in r)
assert denied_count == 2, f"Expected 2 denied, got {denied_count}: {results}"
# A03: Manual redirect control tests
def test_validate_url_before_io_rejects_http(tmp_path: Path) -> None:
"""A03: HTTP scheme rejected even for allowed hosts."""
from rf4_research.community_cli import _validate_url_before_io
with pytest.raises(ValueError, match="Only HTTPS"):
_validate_url_before_io("http://rf4-stat.ru/fishing/")
def test_validate_url_before_io_rejects_bad_ports(tmp_path: Path) -> None:
"""A03: Non-standard ports rejected."""
from rf4_research.community_cli import _validate_url_before_io
with pytest.raises(ValueError, match="not in allowed ports"):
_validate_url_before_io("https://rf4-stat.ru:8080/path")
with pytest.raises(ValueError, match="not in allowed ports"):
_validate_url_before_io("https://rf4-stat.ru:4443/path")
def test_validate_url_before_io_rejects_disallowed_hosts(tmp_path: Path) -> None:
"""A03: Disallowed hosts rejected before network I/O."""
from rf4_research.community_cli import _validate_url_before_io
with pytest.raises(ValueError, match="not in allowlist"):
_validate_url_before_io("https://evil.com/phishing")
with pytest.raises(ValueError, match="not in allowlist"):
_validate_url_before_io("https://169.254.169.254/metadata")
def test_validate_url_before_io_allows_valid_urls() -> None:
"""A03: Valid allowed hosts pass validation."""
from rf4_research.community_cli import _validate_url_before_io
hostname, scheme = _validate_url_before_io("https://rf4-stat.ru/fishing/")
assert hostname == "rf4-stat.ru"
assert scheme == "https"
hostname, scheme = _validate_url_before_io("https://download.rf4db.com/ru/catches")
assert hostname == "download.rf4db.com"
assert scheme == "https"
def test_validate_url_before_io_normalizes_www() -> None:
"""A03: www. prefix is stripped from hostname."""
from rf4_research.community_cli import _validate_url_before_io
hostname, _ = _validate_url_before_io("https://www.rf4-stat.ru/fishing/")
assert hostname == "rf4-stat.ru"
def test_fetch_html_redirect_to_disallowed_host_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
"""A03: Redirect to disallowed host raises before making request."""
from rf4_research.community_cli import fetch_html
import urllib.error
# Mock build_opener to return an opener that raises 302 redirect
from unittest.mock import Mock
from rf4_research.community_cli import _StrictRedirectHandler
opener_instance = Mock()
def mock_open(request, timeout=None):
# Simulate a redirect response
exc = urllib.error.HTTPError(
url=str(request.full_url),
code=302,
msg="Found",
hdrs=Mock(),
fp=None
)
exc.headers = {"Location": "https://evil.com/phishing"}
raise exc
opener_instance.open = mock_open
def mock_build_opener(*args, **kwargs):
return opener_instance
monkeypatch.setattr("rf4_research.community_cli.build_opener", mock_build_opener)
# Should raise during redirect validation (evil.com not in allowlist)
with pytest.raises(ValueError, match="not in allowlist"):
fetch_html("https://rf4-stat.ru/redirect-to-evil", _redirects=0)
def test_fetch_html_redirect_chain_limit() -> None:
"""A03: Redirect chain exceeding MAX_REDIRECT_HOPS raises ValueError."""
from rf4_research.community_cli import fetch_html, MAX_REDIRECT_HOPS
with pytest.raises(ValueError, match=f"Redirect chain exceeds {MAX_REDIRECT_HOPS} hops"):
fetch_html("https://rf4-stat.ru/", _redirects=MAX_REDIRECT_HOPS + 1)
def test_fetch_html_allows_valid_https() -> None:
"""A03: Valid HTTPS URLs pass scheme validation."""
from rf4_research.community_cli import _validate_url_before_io
hostname, scheme = _validate_url_before_io("https://rf4-stat.ru/fishing/")
assert scheme == "https"
assert hostname == "rf4-stat.ru"
def test_extract_redirect_url_from_headers() -> None:
"""A03: _extract_redirect_url handles both Location and location headers."""
from rf4_research.community_cli import _extract_redirect_url
# Standard capitalization
headers = {"Location": "https://rf4-stat.ru/new-path"}
assert _extract_redirect_url(headers) == "https://rf4-stat.ru/new-path"
# Lowercase (some servers use this)
headers = {"location": "https://rf4-stat.ru/other-path"}
assert _extract_redirect_url(headers) == "https://rf4-stat.ru/other-path"
# Missing header
headers = {}
assert _extract_redirect_url(headers) is None
def test_urljoin_resolves_relative_redirects() -> None:
"""A03: Relative redirect URLs are resolved against the base URL."""
from urllib.parse import urljoin
# Relative path
assert urljoin("https://rf4-stat.ru/old", "/new") == "https://rf4-stat.ru/new"
# Relative without leading slash
assert urljoin("https://rf4-stat.ru/old/path", "new") == "https://rf4-stat.ru/old/new"
# Absolute URL
assert urljoin("https://rf4-stat.ru/old", "https://rf4-stat.ru/absolute") == "https://rf4-stat.ru/absolute"