diff --git a/README.md b/README.md index 5620ffb..b7b4eed 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,10 @@ internal signing secret, and shared output directory across restarts. The secret needed to decrypt pending query credentials; terminal checkpoints discard credentials. Multiple workers must share these paths and support the recovery file lock. +Recovery queries reuse the original API endpoint and system proxy routing. When a +configured HTTP(S) proxy uses Fake-IP DNS, media address validation checks the +hostname through AliDNS over HTTPS; private addresses remain blocked. + ## API Access Authenticated API calls can include either: diff --git a/tests/test_api_recovery.py b/tests/test_api_recovery.py index 5f663b9..2f25179 100644 --- a/tests/test_api_recovery.py +++ b/tests/test_api_recovery.py @@ -183,7 +183,8 @@ class ApiRecoveryTests(unittest.TestCase): response.__enter__.return_value = response response.status_code = 200 response.json.return_value = {"status": "running"} - with patch.object(recovery, "_validate_public_http_url"), patch.object(recovery.requests, "request", return_value=response) as request: + # A local proxy may resolve public API hosts to benchmark-range Fake-IP addresses. + with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("198.18.0.83", 443))]), patch.object(recovery.requests, "request", return_value=response) as request: self.manager._query(row) args, kwargs = request.call_args self.assertEqual(("GET", "https://api.example/api/v3/contents/generations/tasks/task-1"), args) diff --git a/tests/test_remote_download.py b/tests/test_remote_download.py new file mode 100644 index 0000000..724496f --- /dev/null +++ b/tests/test_remote_download.py @@ -0,0 +1,50 @@ +import io +import json +import unittest +from unittest.mock import patch + +from test_cloud_archive import cloud + + +class RemoteAddressTests(unittest.TestCase): + def validate(self, address, *, host="cdn.example", proxy=True, public_answer="8.8.8.8"): + response = io.BytesIO(json.dumps({"Status": 0, "Answer": [ + {"type": 1, "data": public_answer}, + ]}).encode()) + with patch.object(cloud.socket, "getaddrinfo", return_value=[(2, 1, 6, "", (address, 443))]), \ + patch.object(cloud.urllib.request, "getproxies", return_value={"https": "http://127.0.0.1:7897"} if proxy else {}), \ + patch.object(cloud.urllib.request, "proxy_bypass", return_value=False), \ + patch.object(cloud.urllib.request, "urlopen", return_value=response) as lookup: + cloud._validate_public_http_url("https://" + host + "/video.mp4?signature=private") + return lookup.call_args_list + + def test_proxy_fake_ip_validates_public_dns_without_sending_signed_url(self): + calls = self.validate("198.18.0.86") + self.assertEqual(1, len(calls)) + url = calls[0].args[0] + self.assertIn("name=cdn.example", url) + self.assertNotIn("signature", url) + + def test_public_address_needs_no_extra_lookup(self): + self.assertEqual([], self.validate("8.8.8.8")) + + def test_private_loopback_and_metadata_addresses_are_rejected(self): + for address in ("127.0.0.1", "10.0.0.1", "169.254.169.254", "::1"): + with self.subTest(address=address), self.assertRaises(ValueError): + self.validate(address) + + def test_fake_ip_without_proxy_is_rejected(self): + with self.assertRaises(ValueError): + self.validate("198.18.0.86", proxy=False) + + def test_literal_fake_ip_is_rejected_even_with_proxy(self): + with self.assertRaises(ValueError): + self.validate("198.18.0.86", host="198.18.0.86") + + def test_private_public_dns_answer_is_rejected(self): + with self.assertRaises(ValueError): + self.validate("198.18.0.86", public_answer="10.0.0.1") + + +if __name__ == "__main__": + unittest.main() diff --git a/utils/api_recovery.py b/utils/api_recovery.py index 2051393..4d28e20 100644 --- a/utils/api_recovery.py +++ b/utils/api_recovery.py @@ -17,7 +17,7 @@ from cryptography.fernet import Fernet from filelock import FileLock, Timeout from .api_audit import suspend_api_audit -from .cloud_archive import download_remote, _validate_public_http_url +from .cloud_archive import download_remote from .recovery_providers import submission, result @@ -105,7 +105,8 @@ class ApiRecoveryManager: logger.error("API result recovery iteration failed; retrying") def _query(self, row): - _validate_public_http_url(row["query_url"]) + # This endpoint comes from the authenticated submission, not returned media. + # Use the original client's proxy/DNS routing; Fake-IP is not the API's address. authorization = self.cipher.decrypt(row["credentials"]).decode() with requests.request( row["query_method"], row["query_url"], diff --git a/utils/cloud_archive.py b/utils/cloud_archive.py index c9c9946..4261618 100644 --- a/utils/cloud_archive.py +++ b/utils/cloud_archive.py @@ -894,8 +894,26 @@ def _validate_public_http_url(url: str) -> None: addresses = socket.getaddrinfo(parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM) except socket.gaierror as error: raise ValueError(f"Unable to resolve artifact host: {error}") from error - for address in addresses: - ip = ipaddress.ip_address(address[4][0]) + ips = [ipaddress.ip_address(address[4][0]) for address in addresses] + fake_network = ipaddress.ip_network("198.18.0.0/15") + if any(ip in fake_network for ip in ips): + # Proxy Fake-IP is a routing token. Validate the hostname's public DNS + # answer instead; never allow literal Fake-IP URLs or private answers. + try: + ipaddress.ip_address(parsed.hostname) + literal = True + except ValueError: + literal = False + proxied = urllib.request.getproxies().get(parsed.scheme) and not urllib.request.proxy_bypass(parsed.hostname) + if not literal and proxied and all(ip in fake_network or ip.is_global for ip in ips): + query = urllib.parse.urlencode({"name": parsed.hostname, "type": "A"}) + with urllib.request.urlopen("https://dns.alidns.com/resolve?" + query, timeout=10) as response: + answer = json.loads(response.read(65536)) + ips = [ipaddress.ip_address(record["data"]) for record in answer.get("Answer", []) + if record.get("type") in (1, 28)] if answer.get("Status") == 0 else [] + if not ips: + raise ValueError("Public DNS lookup returned no artifact address") + for ip in ips: if not ip.is_global: raise ValueError("Remote artifact URL resolves to a non-public address")