baodan/api/insurance/utils/security.py

134 lines
4.5 KiB
Python
Raw Normal View History

"""安全工具 — 文件上传校验和 URL 安全校验。"""
import ipaddress
import io
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 prepare_pdf_upload(file_storage, password: str = "") -> tuple[bool, str, bytes | None]:
"""校验 PDF加密文件解密后返回可供解析的 PDF 字节。
密码不会写入数据库或日志空密码可自动处理仅限制复制/打印的 PDF
返回 (is_valid, error_message, pdf_bytes)
"""
file_storage.seek(0)
original_bytes = file_storage.read()
file_storage.seek(0)
# 1. 检查文件大小
size = len(original_bytes)
if size == 0:
return False, "文件为空", None
if size > MAX_FILE_SIZE:
return False, f"文件大小 {size // (1024*1024)}MB 超过限制 {MAX_FILE_SIZE // (1024*1024)}MB", None
# 2. 检查 PDF 魔数
if not original_bytes[:8].startswith(PDF_MAGIC):
return False, "文件不是有效的 PDF 格式", None
# 3. 解密并检查页数
try:
import pypdf
reader = pypdf.PdfReader(io.BytesIO(original_bytes))
pdf_bytes = original_bytes
if reader.is_encrypted:
decrypt_result = reader.decrypt(password or "")
if decrypt_result == 0:
message = "PDF 密码错误,请重新输入" if password else "该 PDF 需要打开密码,请填写密码后重试"
return False, message, None
writer = pypdf.PdfWriter()
writer.append_pages_from_reader(reader)
output = io.BytesIO()
writer.write(output)
pdf_bytes = output.getvalue()
# 4. 检查页数
if len(reader.pages) > MAX_PDF_PAGES:
return False, f"PDF 页数 {len(reader.pages)} 超过限制 {MAX_PDF_PAGES}", None
except ImportError:
# pypdf 不可用时跳过加密和页数检查
pdf_bytes = original_bytes
except Exception as e:
logger.warning(f"PDF 解析检查失败: {e}")
return False, "PDF 文件内容损坏、密码错误或格式无效", None
finally:
file_storage.seek(0)
return True, "", pdf_bytes
def validate_pdf_upload(file_storage, password: str = "") -> tuple[bool, str]:
"""兼容旧调用:只返回 PDF 校验结果。"""
is_valid, error_message, _ = prepare_pdf_upload(file_storage, password)
return is_valid, error_message
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, ""