"""Bounded public HTTP reads with DNS validation and redirect checks.""" import asyncio import ipaddress import socket from urllib.parse import urlsplit import httpx async def public_address(url): parsed = urlsplit(url) if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.port not in (None, 80, 443): raise ValueError('只允许公开HTTP/HTTPS地址') answers = await asyncio.get_running_loop().getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == 'https' else 80), type=socket.SOCK_STREAM) addresses = [item[4][0] for item in answers] if not addresses or any(not ipaddress.ip_address(ip).is_global for ip in addresses): raise ValueError('不允许访问非公开地址') return parsed, addresses[0] async def fetch_public_response(url): async with httpx.AsyncClient(timeout=20, trust_env=False) as client: for _ in range(4): from alphapulse.crawler.content_policy import file_url if file_url(url): raise ValueError('订阅地址不支持 PDF 或附件') parsed, address = await public_address(url) # Pin the checked address while retaining TLS hostname verification. target = httpx.URL(url).copy_with(host=address) async with client.stream('GET', target, headers={'Host': parsed.hostname}, extensions={'sni_hostname': parsed.hostname}) as response: if response.is_redirect: location = response.headers.get('location') if not location: raise ValueError('重定向缺少地址') url = str(httpx.URL(url).join(location)); continue from scrapy.http import Headers from alphapulse.crawler.content_policy import binary_headers if binary_headers(Headers(response.headers.multi_items())): raise ValueError('订阅返回不支持的文件类型') chunks, size, prefix = [], 0, b'' async for chunk in response.aiter_bytes(): size += len(chunk) if size > 2*1024*1024: raise ValueError('响应超过2MiB限制') chunks.append(chunk) prefix = (prefix + chunk)[:8] if prefix.lstrip().startswith(b'%PDF'): raise ValueError('订阅返回 PDF 文件') body = b''.join(chunks) # Bytes are already decompressed; Scrapy must not decode them again. headers = [(k, v) for k, v in response.headers.multi_items() if k.lower() not in ('content-encoding', 'content-length', 'transfer-encoding')] return httpx.Response(response.status_code, headers=headers, content=body, request=httpx.Request('GET', url)) raise ValueError('重定向超过限制')