web-scraper / app /security.py
Claude
Claude Sonnet 5
Add rate limiting, tests, better extraction, and HF auto-deploy
ebab0e2 unverified
Raw History Blame Contribute Delete
1.78 kB
import ipaddress
import socket
from urllib.parse import urlparse
from app.errors import ScrapeError
# Hostnames worth blocking explicitly because they don't resolve to a
# "private" IP range but still expose sensitive data (cloud metadata APIs).
BLOCKED_HOSTNAMES = {"metadata.google.internal"}
def assert_url_is_safe(url: str, *, resolver=socket.getaddrinfo) -> None:
"""Best-effort SSRF guard.
Rejects non-http(s) schemes and hostnames that resolve to
private/loopback/link-local/reserved addresses. This narrows the attack
surface but is not a complete defense against DNS rebinding, since the
browser performs its own DNS resolution when it actually fetches the
page (after this check has passed).
`resolver` matches the signature of socket.getaddrinfo(host, port) and
is injectable so tests don't depend on real DNS.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ScrapeError(400, "Only http and https URLs are supported.")
hostname = parsed.hostname
if not hostname:
raise ScrapeError(400, "URL is missing a hostname.")
if hostname.lower() in BLOCKED_HOSTNAMES:
raise ScrapeError(400, "This host is not allowed.")
try:
addr_infos = resolver(hostname, None)
except OSError:
raise ScrapeError(400, "Could not resolve host.")
for _family, _type, _proto, _canonname, sockaddr in addr_infos:
ip = ipaddress.ip_address(sockaddr[0])
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
):
raise ScrapeError(400, "This host resolves to a disallowed address.")