Files
jiaoyu/backend/services/upload_security.py
Zhang Jing Xuan 0841b1a103 智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
2026-07-29 16:29:10 +08:00

131 lines
4.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""文件上传安全校验。
提供 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))