"""安全工具 — 文件上传校验和 URL 安全校验。""" import ipaddress import logging import os import socket logger = logging.getLogger(__name__) # PDF 文件头魔数 PDF_MAGIC = b"%PDF" # 文件大小限制(默认 30MB) MAX_FILE_SIZE = int(os.environ.get("MAX_UPLOAD_SIZE_MB", "30")) * 1024 * 1024 # PDF 页数限制(默认 200 页) MAX_PDF_PAGES = int(os.environ.get("MAX_PDF_PAGES", "200")) # 私网/保留 IP 前缀 _PRIVATE_NETWORKS = [ ipaddress.ip_network("127.0.0.0/8"), # loopback ipaddress.ip_network("10.0.0.0/8"), # 私网 A ipaddress.ip_network("172.16.0.0/12"), # 私网 B ipaddress.ip_network("192.168.0.0/16"), # 私网 C ipaddress.ip_network("169.254.0.0/16"), # 链路本地 ipaddress.ip_network("0.0.0.0/8"), # 当前网络 ipaddress.ip_network("::1/128"), # IPv6 loopback ipaddress.ip_network("fc00::/7"), # IPv6 私网 ipaddress.ip_network("fe80::/10"), # IPv6 链路本地 ] def validate_pdf_upload(file_storage) -> tuple[bool, str]: """校验上传的 PDF 文件。 返回 (is_valid, error_message)。 """ # 1. 检查文件大小 file_storage.seek(0, os.SEEK_END) size = file_storage.tell() file_storage.seek(0) if size == 0: return False, "文件为空" if size > MAX_FILE_SIZE: return False, f"文件大小 {size // (1024*1024)}MB 超过限制 {MAX_FILE_SIZE // (1024*1024)}MB" # 2. 检查 PDF 魔数 header = file_storage.read(8) file_storage.seek(0) if not header.startswith(PDF_MAGIC): return False, "文件不是有效的 PDF 格式" # 3. 检查 PDF 是否加密 try: import pypdf reader = pypdf.PdfReader(file_storage) if reader.is_encrypted: file_storage.seek(0) return False, "不支持加密的 PDF 文件,请先解密后重新上传" # 4. 检查页数 if len(reader.pages) > MAX_PDF_PAGES: file_storage.seek(0) return False, f"PDF 页数 {len(reader.pages)} 超过限制 {MAX_PDF_PAGES} 页" file_storage.seek(0) except ImportError: # pypdf 不可用时跳过加密和页数检查 file_storage.seek(0) except Exception as e: logger.warning(f"PDF 解析检查失败: {e}") file_storage.seek(0) return False, "PDF 文件内容损坏或格式无效" return True, "" def is_safe_base_url(url: str) -> tuple[bool, str]: """检查 Base URL 是否安全(拒绝私网、环回、元数据地址)。 返回 (is_safe, error_message)。 """ if not url: return True, "" # 只允许 http/https if not url.startswith(("http://", "https://")): return False, "仅支持 http/https 协议" # 解析主机名 from urllib.parse import urlparse try: parsed = urlparse(url) except Exception: return False, "URL 格式无效" hostname = parsed.hostname if not hostname: return False, "URL 缺少主机名" # 拒绝明确的 localhost if hostname in ("localhost", "0.0.0.0"): return False, f"不允许访问 {hostname}" # DNS 解析后检查 IP try: for info in socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM): ip_str = info[4][0] ip = ipaddress.ip_address(ip_str) for network in _PRIVATE_NETWORKS: if ip in network: return False, f"不允许访问私网/保留地址 ({ip_str})" except socket.gaierror: pass # DNS 解析失败不阻止,让后续调用报错 except Exception as e: logger.warning(f"URL 安全检查异常: {e}") return True, ""