Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

131 lines
5.0 KiB
Python

import ipaddress
import socket
from urllib.parse import urljoin, urlparse
import httpx
from yuxi.knowledge.utils.url_validator import is_url_parsing_enabled, validate_url
from yuxi.utils import logger
# 最大允许下载大小 (例如 10MB)
MAX_DOWNLOAD_SIZE = 10 * 1024 * 1024
# 允许的 Content-Type
ALLOWED_CONTENT_TYPES = ["text/html", "application/xhtml+xml"]
async def is_private_ip(hostname: str) -> bool:
"""Check if the hostname resolves to a private IP address."""
import asyncio
try:
# Resolve hostname to IP in a separate thread to avoid blocking the event loop
ip_list = await asyncio.to_thread(socket.getaddrinfo, hostname, None)
for item in ip_list:
ip_addr = item[4][0]
ip_obj = ipaddress.ip_address(ip_addr)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local:
return True
return False
except Exception as e:
logger.warning(f"Failed to resolve hostname {hostname}: {e}")
# If resolution fails, assume it's unsafe or let the connection fail naturally,
# but to be safe we can return True to block it if strict mode is preferred.
# For now, we return False assuming standard DNS failure handling.
return False
async def fetch_url_content(url: str, max_size: int = MAX_DOWNLOAD_SIZE) -> tuple[bytes, str]:
"""
Fetch URL content with security checks (size limit, content type, private IP blocking).
Args:
url: The URL to fetch.
max_size: Maximum allowed size in bytes.
Returns:
tuple: (content_bytes, final_url)
Raises:
ValueError: If validation fails or download error occurs.
"""
if not is_url_parsing_enabled():
raise ValueError("URL parsing feature is disabled")
# Initial validation
is_valid, error_msg = validate_url(url)
if not is_valid:
raise ValueError(f"Invalid URL: {error_msg}")
# Parse URL to check IP
parsed_url = urlparse(url)
if await is_private_ip(parsed_url.hostname):
raise ValueError("Access to private IP addresses is forbidden")
current_url = url
redirect_count = 0
max_redirects = 5
# We handle redirects manually to check each target URL against whitelist/private IP
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=False) as client:
while True:
logger.info(f"Fetching URL: {current_url}")
# Request headers
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/91.0.4472.124 "
"Safari/537.36"
)
}
# Stream the response to check headers before downloading body
async with client.stream("GET", current_url, headers=headers) as response:
# Handle Redirects
if response.status_code in (301, 302, 303, 307, 308):
if redirect_count >= max_redirects:
raise ValueError("Too many redirects")
redirect_count += 1
location = response.headers.get("Location")
if not location:
raise ValueError("Redirect response missing Location header")
current_url = urljoin(current_url, location)
# Validate the new URL
is_valid, error_msg = validate_url(current_url)
if not is_valid:
raise ValueError(f"Redirected to invalid URL: {error_msg}")
parsed_new = urlparse(current_url)
if await is_private_ip(parsed_new.hostname):
raise ValueError("Redirected to private IP address")
continue # Start new request
response.raise_for_status()
# Check Content-Type
content_type = response.headers.get("Content-Type", "").lower()
if not any(allowed in content_type for allowed in ALLOWED_CONTENT_TYPES):
raise ValueError(f"Unsupported Content-Type: {content_type}. Only HTML is supported.")
# Download content with size limit
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > max_size:
raise ValueError(f"Content size exceeds limit of {max_size} bytes")
return bytes(content), current_url
except httpx.HTTPError as e:
logger.error(f"HTTP error fetching {url}: {e}")
raise ValueError(f"Failed to fetch URL: {e}")
except Exception as e:
logger.error(f"Error fetching {url}: {e}")
raise ValueError(f"Error fetching URL: {str(e)}")