0841b1a103
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
131 lines
4.2 KiB
Python
131 lines
4.2 KiB
Python
"""文件上传安全校验。
|
||
|
||
提供 validate_upload() 对上传文件做:
|
||
1. 文件名清洗(去除路径、危险字符、控制长度)
|
||
2. 扩展名白名单校验
|
||
3. MIME 与扩展名一致性校验
|
||
4. 文件魔数(magic bytes)校验,防止伪装
|
||
5. 文件大小校验
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass
|
||
from typing import Optional
|
||
|
||
from fastapi import HTTPException, UploadFile
|
||
|
||
|
||
# 允许的扩展名 → 期望的 MIME 前缀(; 前部分)
|
||
ALLOWED_TYPES: dict[str, str] = {
|
||
"txt": "text/",
|
||
"md": "text/",
|
||
"csv": "text/",
|
||
"json": "application/json",
|
||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
"pdf": "application/pdf",
|
||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
"xls": "application/vnd.ms-excel",
|
||
"png": "image/png",
|
||
"jpg": "image/jpeg",
|
||
"jpeg": "image/jpeg",
|
||
"gif": "image/gif",
|
||
"webp": "image/webp",
|
||
"bmp": "image/bmp",
|
||
}
|
||
|
||
# 魔数签名(前若干字节)
|
||
MAGIC_SIGNATURES: dict[str, list[bytes]] = {
|
||
"pdf": [b"%PDF-"],
|
||
"png": [b"\x89PNG\r\n\x1a\n"],
|
||
"jpg": [b"\xff\xd8\xff"],
|
||
"jpeg": [b"\xff\xd8\xff"],
|
||
"gif": [b"GIF87a", b"GIF89a"],
|
||
"bmp": [b"BM"],
|
||
"webp": [b"RIFF"],
|
||
# Office 文件均为 ZIP 容器(PK\x03\x04)
|
||
"docx": [b"PK\x03\x04"],
|
||
"pptx": [b"PK\x03\x04"],
|
||
"xlsx": [b"PK\x03\x04"],
|
||
"xls": [b"\xd0\xcf\x11\xe0"], # OLE 复合文档
|
||
}
|
||
|
||
MAX_FILENAME_LEN = 200
|
||
|
||
|
||
@dataclass
|
||
class UploadCheck:
|
||
filename: str
|
||
suffix: str
|
||
media_type: str
|
||
size: int
|
||
|
||
|
||
_FILENAME_BAD = re.compile("[\x00-\x1f<>:\"/\\|?*]")
|
||
|
||
|
||
def sanitize_filename(name: str) -> str:
|
||
"""清洗文件名:去路径、去控制/危险字符、限长。"""
|
||
# 仅保留文件名部分
|
||
name = re.split(r"[\\/]", name)[-1].strip()
|
||
name = _FILENAME_BAD.sub("_", name)
|
||
name = name.strip(". ")
|
||
if not name:
|
||
name = "upload"
|
||
if len(name) > MAX_FILENAME_LEN:
|
||
stem, dot, ext = name.rpartition(".")
|
||
if dot:
|
||
name = stem[: MAX_FILENAME_LEN - len(ext) - 1] + "." + ext
|
||
else:
|
||
name = name[:MAX_FILENAME_LEN]
|
||
return name
|
||
|
||
|
||
def _check_magic(suffix: str, head: bytes) -> bool:
|
||
sigs = MAGIC_SIGNATURES.get(suffix)
|
||
if not sigs:
|
||
return True # 无签名要求(文本类)默认放行
|
||
return any(head.startswith(sig) for sig in sigs)
|
||
|
||
|
||
def validate_upload(
|
||
file: UploadFile,
|
||
*,
|
||
content: bytes,
|
||
max_size: Optional[int] = None,
|
||
) -> UploadCheck:
|
||
"""校验上传文件,失败抛出 HTTPException。
|
||
|
||
参数 content 为已读取的字节内容(用于魔数校验)。
|
||
"""
|
||
if max_size is not None and len(content) > max_size:
|
||
raise HTTPException(status_code=413, detail=f"文件不能超过 {max_size // (1024 * 1024)}MB")
|
||
if not content:
|
||
raise HTTPException(status_code=400, detail="上传文件为空")
|
||
|
||
raw_name = file.filename or "material"
|
||
filename = sanitize_filename(raw_name)
|
||
suffix = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||
media_type = (file.content_type or "").split(";")[0].strip().lower()
|
||
|
||
if suffix not in ALLOWED_TYPES:
|
||
raise HTTPException(
|
||
status_code=415,
|
||
detail="暂不支持该文件类型,请上传 txt、md、csv、json、docx、pptx、pdf、xlsx 或图片",
|
||
)
|
||
|
||
# MIME 一致性:图片/office/pdf 必须匹配;文本类型放宽
|
||
expected_mime = ALLOWED_TYPES[suffix]
|
||
if expected_mime != "text/" and media_type and not media_type.startswith(expected_mime.split("/")[0]):
|
||
# 宽松:只校验主类型一致(image/* application/* 等)
|
||
if media_type.split("/")[0] != expected_mime.split("/")[0]:
|
||
raise HTTPException(status_code=415, detail="文件类型与扩展名不一致")
|
||
|
||
# 魔数校验
|
||
head = content[:16]
|
||
if not _check_magic(suffix, head):
|
||
raise HTTPException(status_code=415, detail="文件内容与声明类型不符")
|
||
|
||
return UploadCheck(filename=filename, suffix=suffix, media_type=media_type or expected_mime, size=len(content))
|