veilrender-public / src /veilrender /url_validator.py
Peng Ding
security: fix SSRF, add URL validation, fail2ban, and body size limit
573cdfc
Raw
History Blame Contribute Delete
3.51 kB
"""URL validation to prevent SSRF, local file reads, and protocol abuse.
Blocks:
- Non-HTTP(S) schemes: file://, data:, javascript:, ftp://, etc.
- Private/reserved IP ranges: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 169.254.0.0/16, ::1, fd00::/8, etc.
- DNS resolution is checked before navigation to prevent DNS rebinding.
"""
from __future__ import annotations
import ipaddress
import logging
import socket
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_ALLOWED_SCHEMES = {"http", "https"}
class URLValidationError(Exception):
"""Raised when a URL fails validation."""
def validate_url(url: str) -> str:
"""Validate and sanitize a URL before passing it to Playwright.
Args:
url: The user-supplied URL to validate.
Returns:
The validated URL (unchanged if valid).
Raises:
URLValidationError: If the URL is unsafe.
"""
if not url or not url.strip():
raise URLValidationError("Empty URL")
parsed = urlparse(url)
# Scheme check
scheme = parsed.scheme.lower()
if scheme not in _ALLOWED_SCHEMES:
raise URLValidationError(f"Scheme '{scheme}' not allowed (only http/https)")
# Hostname check
hostname = parsed.hostname
if not hostname:
raise URLValidationError("Missing hostname")
# Resolve DNS and check against private IP ranges
_check_resolved_ips(hostname)
return url
def _check_resolved_ips(hostname: str) -> None:
"""Resolve hostname and reject private/reserved IP addresses.
Resolves DNS before Playwright navigates to prevent DNS rebinding
attacks where a hostname initially resolves to a public IP but
later resolves to a private one.
Args:
hostname: The hostname to check.
Raises:
URLValidationError: If the hostname resolves to a private IP.
"""
# Check if hostname is a raw IP literal
try:
addr = ipaddress.ip_address(hostname)
if _is_blocked_ip(addr):
raise URLValidationError(f"IP address {hostname} is in a blocked range")
return
except ValueError:
pass # Not an IP literal, proceed to DNS resolution
# Resolve hostname
try:
results = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
except socket.gaierror as exc:
raise URLValidationError(
f"DNS resolution failed for '{hostname}': {exc}"
) from exc
if not results:
raise URLValidationError(f"No DNS results for '{hostname}'")
for family, _type, _proto, _canonname, sockaddr in results:
ip_str = sockaddr[0]
try:
addr = ipaddress.ip_address(ip_str)
if _is_blocked_ip(addr):
raise URLValidationError(
f"'{hostname}' resolves to blocked IP {ip_str}"
)
except ValueError:
continue
def _is_blocked_ip(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""Check if an IP address is in a blocked range.
Blocks:
- Loopback (127.0.0.0/8, ::1)
- Private networks (10/8, 172.16/12, 192.168/16)
- Link-local (169.254/16, fe80::/10)
- Unique local (fd00::/8)
- Multicast, reserved, unspecified
"""
return (
addr.is_loopback
or addr.is_private
or addr.is_reserved
or addr.is_multicast
or addr.is_link_local
or addr.is_unspecified
)