"""SSRF guard — pure validator tests (no network: DNS answers are injected).""" from __future__ import annotations import pytest from aiatlas.sdk.fetch import MAX_REDIRECTS, BlockedDestination, validate_destination @pytest.mark.parametrize("url", [ "ftp://example.com/x", "file:///etc/passwd", "gopher://example.com", "javascript:alert(1)", "data:text/html,hi", "http://localhost/", "http://LOCALHOST:8080/", "https://foo.local/", "http://db.internal/", "http://intranet/", "http://metadata.google.internal/computeMetadata/v1/", "http://127.0.0.1/", "http://127.1.2.3/", "http://10.0.0.5/", "http://172.16.4.4/", "http://172.31.255.1/", "http://192.168.2.10/", "http://169.254.169.254/latest/meta-data/", "http://169.254.170.2/", "http://100.64.1.1/", "http://100.127.255.255/", "http://0.0.0.0/", "http://[::1]/", "http://[fe80::1]/", "http://[fc00::1]/", "http://[fd12:3456::1]/", "http://[::]/", "http://[::ffff:127.0.0.1]/", "http://[::ffff:10.0.0.1]/", "http://user:pass@example.com/", ]) def test_blocked_literals(url: str) -> None: with pytest.raises(BlockedDestination): validate_destination(url, resolved_ips=[]) @pytest.mark.parametrize("url", ["https://huggingface.co/Qwen/Qwen3-8B", "http://arxiv.org/abs/2505.09388", "https://8.8.8.8/", "https://[2606:4700::6810:84e5]/"]) def test_public_allowed(url: str) -> None: validate_destination(url, resolved_ips=["104.16.0.1"]) def test_dns_rebinding_to_private_is_blocked() -> None: with pytest.raises(BlockedDestination): validate_destination("https://evil.example.com/", resolved_ips=["10.0.0.9"]) with pytest.raises(BlockedDestination): validate_destination("https://evil.example.com/", resolved_ips=["104.16.0.1", "169.254.169.254"]) # any private answer blocks with pytest.raises(BlockedDestination): validate_destination("https://evil.example.com/", resolved_ips=["fd00::1"]) def test_unresolvable_host_is_not_ssrf() -> None: # resolution failures surface later as transport errors, not as blocked destinations validate_destination("https://does-not-exist.example.com/", resolved_ips=[]) def test_redirect_cap_constant() -> None: assert MAX_REDIRECTS == 5 async def test_fetcher_get_blocks_before_connecting() -> None: from aiatlas.sdk.fetch import Fetcher, FetchError async with Fetcher(robots=False) as f: with pytest.raises(FetchError) as exc: await f.get("http://169.254.169.254/latest/meta-data/") assert "blocked destination" in str(exc.value) with pytest.raises(FetchError): await f.get("file:///etc/hosts") async def test_fetcher_blocks_redirect_to_private(monkeypatch: pytest.MonkeyPatch) -> None: """A public host redirecting to an internal address is refused on the redirect hop (uses respx-free httpx MockTransport).""" import httpx from aiatlas.sdk import fetch as fetch_mod from aiatlas.sdk.fetch import Fetcher, FetchError async def fake_validate(url: str) -> None: # public → ok, private literal → raise, exactly like the real validator without DNS fetch_mod.validate_destination(url, resolved_ips=["104.16.0.1"]) monkeypatch.setattr(fetch_mod, "validate_destination_async", fake_validate) def handler(request: httpx.Request) -> httpx.Response: if request.url.host == "public.example.com": return httpx.Response(302, headers={"location": "http://169.254.169.254/latest/"}) return httpx.Response(200, content=b"x" * 100) async with Fetcher(robots=False) as f: f._client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False) with pytest.raises(FetchError) as exc: await f.get("https://public.example.com/start", min_bytes=1) assert "blocked destination" in str(exc.value) async def test_fetcher_caps_redirects(monkeypatch: pytest.MonkeyPatch) -> None: import httpx from aiatlas.sdk import fetch as fetch_mod from aiatlas.sdk.fetch import Fetcher, FetchError async def fake_validate(url: str) -> None: fetch_mod.validate_destination(url, resolved_ips=["104.16.0.1"]) monkeypatch.setattr(fetch_mod, "validate_destination_async", fake_validate) n = {"hops": 0} def handler(request: httpx.Request) -> httpx.Response: n["hops"] += 1 return httpx.Response(301, headers={"location": f"https://public.example.com/{n['hops']}"}) async with Fetcher(robots=False) as f: f._client = httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False) with pytest.raises(FetchError) as exc: await f.get("https://public.example.com/start", min_bytes=1) assert "too many redirects" in str(exc.value) assert n["hops"] == MAX_REDIRECTS + 1