From ea0bde374237c37d497cbd930f9205dcb51f04fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?API=E6=96=87=E6=A1=A3=E7=BB=B4=E6=8A=A4Agent?= Date: Sun, 28 Jun 2026 15:58:07 +0800 Subject: [PATCH] fix: add security validations to duplication upload API P0-1: Add MIME type whitelist validation (video files only) - Accept: mp4, mpeg, mov, avi, webm, mkv, 3gp - Reject non-video files with 415 Unsupported Media Type P0-2: Add file size limit validation - Use OSS_DIRECT_UPLOAD_MAX_MB config (default 800MB) - Check Content-Length header before reading file - Verify actual file size after reading - Return 413 Request Entity Too Large if exceeded Reference: apps/api/app/api/routes/upload.py implementation --- ...ables.py => 012_add_duplication_tables.py} | 8 +-- apps/api/app/api/routes/duplication.py | 54 ++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) rename alembic/versions/{011_add_duplication_tables.py => 012_add_duplication_tables.py} (97%) diff --git a/alembic/versions/011_add_duplication_tables.py b/alembic/versions/012_add_duplication_tables.py similarity index 97% rename from alembic/versions/011_add_duplication_tables.py rename to alembic/versions/012_add_duplication_tables.py index cea50acbf..8940b0e7c 100644 --- a/alembic/versions/011_add_duplication_tables.py +++ b/alembic/versions/012_add_duplication_tables.py @@ -1,7 +1,7 @@ """Phase 2 - 查重功能:duplication_records + duplication_segments -Revision ID: 011 -Revises: 010 +Revision ID: 012 +Revises: 011 Create Date: 2026-06-28 This migration creates two new tables: @@ -12,8 +12,8 @@ from alembic import op import sqlalchemy as sa # revision identifiers -revision = "011" -down_revision = "010" +revision = "012" +down_revision = "011" branch_labels = None depends_on = None diff --git a/apps/api/app/api/routes/duplication.py b/apps/api/app/api/routes/duplication.py index 2c5c4fcec..c9d8b832c 100644 --- a/apps/api/app/api/routes/duplication.py +++ b/apps/api/app/api/routes/duplication.py @@ -30,6 +30,32 @@ logger = logging.getLogger(__name__) router = APIRouter() +# 查重功能只接受视频文件 +ALLOWED_VIDEO_MIME_TYPES = frozenset({ + "video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo", + "video/webm", "video/x-matroska", "video/3gpp", +}) + + +def _validate_video_mime_type(content_type: str | None) -> str: + """验证视频文件的 MIME 类型,如果无效则抛出异常。""" + if not content_type: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Content-Type header is required", + ) + + # 处理带参数的类型,如 "video/mp4; charset=utf-8" + base_type = content_type.split(";")[0].strip().lower() + + if base_type not in ALLOWED_VIDEO_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=f"只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp", + ) + + return base_type + def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse: return DuplicationRecordResponse( @@ -86,6 +112,21 @@ async def upload_for_duplication( detail="文件名不能为空", ) + # P0-1: 验证 MIME 类型(只接受视频文件) + validated_content_type = _validate_video_mime_type(file.content_type) + + # P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB) + from app.config import get_settings + settings = get_settings() + max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024 + + # 先检查 Content-Length header(如果可用) + if file.size is not None and file.size > max_size_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)", + ) + # 读取文件内容并上传到 OSS file_id = uuid4().hex[:8] safe_filename = file.filename.replace("/", "_").replace("\\", "_") @@ -94,6 +135,15 @@ async def upload_for_duplication( try: content = await file.read() file_size = len(content) + + # 再次检查实际文件大小 + if file_size > max_size_bytes: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail=f"文件超过上传限制 ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)", + ) + except HTTPException: + raise except Exception as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -104,7 +154,7 @@ async def upload_for_duplication( storage_service.upload_file( content, storage_key, - content_type=file.content_type or "video/mp4", + content_type=validated_content_type, ) except Exception as exc: raise HTTPException( @@ -176,7 +226,7 @@ def delete_duplication_record( duplication_repository: Any = Depends(get_duplication_repository), ) -> None: """删除查重记录。""" - # 先检查记录是否存在且属于当前用户 + # 检查记录是否存在且属于当前用户 detail_uc = GetDuplicationDetailUseCase(duplication_repository) record = detail_uc.execute(record_id) if record is None or record.user_id != authenticated_user.user.id: