"""Tests for `_validate_url_target` — SSRF prevention. Any URL the app fetches must resolve to a public IP; private/internal/ loopback addresses must be rejected so attacker-controlled URLs cannot reach internal services via our HTTP client. """ import socket from unittest.mock import patch import pytest from db import _validate_url_target def _mock_getaddrinfo(address): """Return a function suitable as a socket.getaddrinfo replacement.""" def f(host, port, *args, **kwargs): family = socket.AF_INET6 if ":" in address else socket.AF_INET return [(family, socket.SOCK_STREAM, 0, "", (address, port or 80))] return f @pytest.mark.parametrize("blocked_ip", [ "127.0.0.1", "127.1.2.3", "10.0.0.1", "10.255.255.255", "172.16.0.1", "172.31.255.255", "192.168.0.1", "192.168.255.255", "169.254.169.254", "0.0.0.0", "::1", "fc00::1", "fe80::1", ]) def test_blocks_private_and_loopback(monkeypatch, blocked_ip): monkeypatch.setattr(socket, "getaddrinfo", _mock_getaddrinfo(blocked_ip)) with pytest.raises(ValueError, match="blocked"): _validate_url_target("https://evil.example.com/internal") def test_allows_public_ipv4(monkeypatch): monkeypatch.setattr(socket, "getaddrinfo", _mock_getaddrinfo("8.8.8.8")) _validate_url_target("https://dns.example.com/") # does not raise def test_allows_public_ipv6(monkeypatch): monkeypatch.setattr(socket, "getaddrinfo", _mock_getaddrinfo("2001:4860:4860::8888")) _validate_url_target("https://v6.example.com/") # does not raise def test_rejects_unresolvable_hostname(monkeypatch): def boom(*args, **kwargs): raise socket.gaierror("no such host") monkeypatch.setattr(socket, "getaddrinfo", boom) with pytest.raises(ValueError, match="Cannot resolve"): _validate_url_target("https://does-not-exist.example.com/") def test_rejects_missing_hostname(): with pytest.raises(ValueError, match="No hostname"): _validate_url_target("http:///path-only")