Spaces:
Paused
Paused
| """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 | |
| ) | |