fix: 封面选帧接口安全加固 — 权限校验防绕过 + video_url SSRF 白名单
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2m31s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m55s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 4m4s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m56s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 4m31s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m3s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled

AI Code Review 指出的两个阻塞问题:

1. 权限校验绕过(步骤0a):原逻辑仅在关联任务存在且
   created_by_user_id 非空时才校验归属;若 GeneratedVideo 的
   generation_task_id 为空或关联任务被删除,校验被静默跳过。
   修复:
   - 优先校验 GeneratedVideo.user_id 直接归属
   - 关联任务存在时校验 task.created_by_user_id
   - video 无 owner 且关联任务也查不到(归属无法确认)→ 403,
     不再静默放行

2. video_url SSRF(步骤0b):原逻辑直接使用请求体传入的 URL,
   攻击者可传入内网/云元数据地址诱导服务端请求。
   修复:新增 _is_trusted_media_url() 白名单校验:
   - 仅允许 http/https 且主机为自家 OSS bucket/endpoint 域名
   - 显式拒绝 localhost/127.*/10.*/192.168.*/169.254.*/172.16-31.*
   - 校验失败静默忽略该 URL,回退后续查找链

附带改进(review 建议项):
- 函数内联 import 全部移至模块顶部(storage/re)
- 新增 2 个安全测试:SSRF 内网地址拦截、归属无法确认 403
- 现有 32 个测试适配新的 mock 命名空间,共 34 个测试全过
This commit is contained in:
xiaoxia
2026-08-29 00:21:49 +08:00
parent b6f211ebe6
commit 3b828ab184
2 changed files with 301 additions and 72 deletions
+149 -60
View File
@@ -9,7 +9,9 @@
from __future__ import annotations
import logging
import re
from typing import Any, List, Optional
from urllib.parse import urlparse
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_generated_video_repository
@@ -24,6 +26,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import (
)
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from packages.shared.storage import get_shared_storage_service
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
@@ -53,13 +56,11 @@ class GenerateCoverRequest(BaseModel):
)
generated_video_id: Optional[str] = Field(
default=None,
description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,"
"而非预览片段。",
description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,而非预览片段。",
)
video_url: Optional[str] = Field(
default=None,
description="最终视频 URL(兜底)。当 generated_video_id 不可用时,"
"直接从此 URL 对应的视频抽帧。",
description="最终视频 URL(兜底)。当 generated_video_id 不可用时,直接从此 URL 对应的视频抽帧。",
)
@@ -131,8 +132,6 @@ def _persist_cover_frame(
exc_info=True,
)
from packages.shared.storage import get_shared_storage_service
storage = get_shared_storage_service()
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
storage.upload_file(
@@ -150,7 +149,6 @@ def _persist_cover_frame(
Path(tmp_path).unlink(missing_ok=True)
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
try:
@@ -172,19 +170,67 @@ def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]:
if storage_key.startswith("http"):
url = storage_key
else:
from packages.shared.storage import get_shared_storage_service
storage_svc = get_shared_storage_service()
url = storage_svc.get_url(storage_key)
if url:
import re as _re
url = _re.sub(r"(?<!:)//", "/", url)
url = re.sub(r"(?<!:)//", "/", url)
return url
except Exception as e:
logger.warning("[封面生成] storage_key 转 URL 失败: key=%s err=%s", storage_key, e)
return None
def _is_trusted_media_url(url: str) -> bool:
"""校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。
用户可通过 video_url 传入视频地址,但服务端(MediaKit)会主动请求该 URL
因此必须限制为自家存储域名,拒绝内网地址、元数据地址等任意主机。
"""
if not url:
return False
try:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https"):
return False
host = (parsed.hostname or "").lower()
if not host:
return False
# 显式拒绝内网/保留地址
if host in {"localhost", "0.0.0.0"} or host.startswith(("127.", "10.", "192.168.", "169.254.")):
return False
# 172.16.0.0/12
try:
parts = [int(p) for p in host.split(".")]
if len(parts) == 4 and parts[0] == 172 and 16 <= parts[1] <= 31:
return False
except ValueError:
pass
# 允许:自家 OSS bucket 域名(<bucket>.<endpoint>
try:
storage_svc = get_shared_storage_service()
public_base = getattr(storage_svc, "public_url", "") or ""
if public_base:
from urllib.parse import urlparse as _urlparse
trusted_host = (_urlparse(public_base).hostname or "").lower()
if trusted_host and (host == trusted_host or host.endswith("." + trusted_host)):
return True
# endpoint 本身(如 oss-cn-hangzhou.aliyuncs.com)及其子域也放行
ep = getattr(storage_svc, "endpoint", "") or ""
ep_host = ep.split(":")[0].lower()
if ep_host.startswith(("http://", "https://")):
ep_host = _urlparse(ep_host).hostname or ""
if ep_host and (host == ep_host or host.endswith("." + ep_host)):
return True
except Exception:
logger.warning("[封面生成] 存储域名白名单初始化失败,URL 校验从严拒绝", exc_info=True)
return False
return False
except Exception:
logger.warning("[封面生成] video_url 白名单校验异常,从严拒绝: url=%s", url[:80], exc_info=True)
return False
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def generate_cover(
body: GenerateCoverRequest,
@@ -248,52 +294,78 @@ def generate_cover(
if body.generated_video_id:
logger.info(
"[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s",
plan_id, body.generated_video_id,
plan_id,
body.generated_video_id,
)
try:
from app.dependencies import get_generated_video_repository as _get_gv_repo
gv_repo = _get_gv_repo(db)
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
file_url = getattr(gv, "file_url", "") or ""
if file_url:
# 权限校验:通过关联的 generation_task 校验归属
# 权限校验(双重,任何一层确认归属不符即拒绝):
# 1) GeneratedVideo.user_id 直接归属(老数据可能为空,为空时不据此放行)
gv_owner = (getattr(gv, "user_id", "") or "").strip()
if gv_owner and gv_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
# 2) 关联 generation_task 归属校验;关联任务缺失时不可静默放行:
# 若 video 自身无 owner 信息且关联任务也查不到,拒绝访问
gv_task_id = getattr(gv, "generation_task_id", "") or ""
task0 = None
if gv_task_id:
try:
_repo0 = SQLAlchemyGenerationTaskRepository(db)
task0 = _repo0.get(gv_task_id)
if (
task0
and task0.created_by_user_id
and task0.created_by_user_id != current_user.user.id
):
raise HTTPException(status_code=403, detail="无权访问该视频")
except HTTPException:
raise
task0 = SQLAlchemyGenerationTaskRepository(db).get(gv_task_id)
except Exception:
pass
logger.warning(
"[封面生成] 步骤0a关联任务查询异常: plan_id=%s task_id=%s",
plan_id,
gv_task_id,
exc_info=True,
)
if task0 is not None:
task_owner = (getattr(task0, "created_by_user_id", "") or "").strip()
if task_owner and task_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
elif not gv_owner:
# video 无 owner 且关联任务不存在/无法确认归属 → 拒绝,防止越权
logger.warning(
"[封面生成] 步骤0a视频归属无法确认,拒绝访问: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
)
raise HTTPException(status_code=403, detail="无权访问该视频")
rendered_storage_key = file_url
logger.info(
"[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s",
plan_id, body.generated_video_id, file_url[:80],
plan_id,
body.generated_video_id,
file_url[:80],
)
except HTTPException:
raise
except Exception:
logger.warning(
"[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s",
plan_id, body.generated_video_id, exc_info=True,
plan_id,
body.generated_video_id,
exc_info=True,
)
# 0b:直接使用 video_url(兜底)
# 0b:直接使用 video_url(兜底)— 必须通过存储域名白名单校验,防止 SSRF
if not rendered_storage_key and body.video_url:
logger.info(
"[封面生成] 步骤0b: 使用请求体传入的 video_url: plan_id=%s url=%s",
plan_id, body.video_url[:80],
)
rendered_storage_key = body.video_url
if _is_trusted_media_url(body.video_url):
logger.info(
"[封面生成] 步骤0b: 使用请求体传入的 video_url(白名单通过): plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
rendered_storage_key = body.video_url
else:
logger.warning(
"[封面生成] 步骤0b: video_url 不在受信任存储域名白名单内,已忽略: plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
# 步骤 2:通过 plan.config.generation_task_id 查找
if not rendered_storage_key:
@@ -301,7 +373,8 @@ def generate_cover(
if generation_task_id:
logger.info(
"[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s",
plan_id, generation_task_id,
plan_id,
generation_task_id,
)
try:
_repo = SQLAlchemyGenerationTaskRepository(db)
@@ -311,11 +384,15 @@ def generate_cover(
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
plan_id, generation_task_id, rendered_storage_key[:80],
plan_id,
generation_task_id,
rendered_storage_key[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤2查找失败: plan_id=%s", plan_id, exc_info=True,
"[封面生成] 步骤2查找失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False
@@ -325,20 +402,21 @@ def generate_cover(
logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id)
all_tasks = _repo.list_by_source_edit_plan(plan_id)
for pt in all_tasks:
if (
getattr(pt, "status", "") == "completed"
and not getattr(pt, "is_preview", False)
):
if getattr(pt, "status", "") == "completed" and not getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s",
plan_id, pt.id, rendered_storage_key[:80],
plan_id,
pt.id,
rendered_storage_key[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤3查找最终成片失败: plan_id=%s", plan_id, exc_info=True,
"[封面生成] 步骤3查找最终成片失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务
@@ -348,20 +426,21 @@ def generate_cover(
logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id)
preview_tasks = _repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if (
getattr(pt, "status", "") == "completed"
and getattr(pt, "is_preview", False)
):
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s",
plan_id, pt.id, rendered_storage_key[:80],
plan_id,
pt.id,
rendered_storage_key[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤4查找预览任务失败: plan_id=%s", plan_id, exc_info=True,
"[封面生成] 步骤4查找预览任务失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 5:按 user + template 查找最近的已完成预览任务(兜底)
@@ -370,7 +449,8 @@ def generate_cover(
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info(
"[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s",
plan_id, template_id,
plan_id,
template_id,
)
preview_tasks = _repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
@@ -381,11 +461,14 @@ def generate_cover(
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s",
plan_id, preview_tasks[0].id,
plan_id,
preview_tasks[0].id,
)
except Exception:
logger.warning(
"[封面生成] 步骤5 user+template 查找失败: plan_id=%s", plan_id, exc_info=True,
"[封面生成] 步骤5 user+template 查找失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 将 storage_key 转换为可访问 URL;找不到视频时不立即报错,
@@ -430,9 +513,7 @@ def generate_cover(
# 步骤 A2:通过 generated_video_id 查找其关联任务的 cover_url
if not cover_url_from_task and body.generated_video_id:
try:
from app.dependencies import get_generated_video_repository as _get_gv_repo
gv_repo = _get_gv_repo(db)
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
gv_task_id = getattr(gv, "generation_task_id", "") or ""
@@ -442,12 +523,16 @@ def generate_cover(
cover_url_from_task = task_a2.cover_url
logger.info(
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
plan_id, body.generated_video_id, cover_url_from_task[:80],
plan_id,
body.generated_video_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤A2读取 cover_url 失败: plan_id=%s video_id=%s",
plan_id, body.generated_video_id, exc_info=True,
plan_id,
body.generated_video_id,
exc_info=True,
)
# 步骤 B:通过 source_edit_plan_id 查找关联任务的 cover_url
@@ -465,7 +550,9 @@ def generate_cover(
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 封面(步骤B-final): plan_id=%s task_id=%s url=%s",
plan_id, pt.id, cover_url_from_task[:80],
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
# 再找预览
@@ -479,7 +566,9 @@ def generate_cover(
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 封面(步骤B-preview): plan_id=%s task_id=%s url=%s",
plan_id, pt.id, cover_url_from_task[:80],
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
+152 -12
View File
@@ -181,7 +181,7 @@ class TestUnifiedCoverPipelineEndpoint:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
):
mock_repo = MagicMock()
@@ -449,7 +449,7 @@ class TestUnifiedCoverPipelineEndpoint:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -590,7 +590,7 @@ class TestSourceEditPlanFallback:
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -657,7 +657,7 @@ class TestSourceEditPlanFallback:
patch("app.api.routes.generation_cover.get_generated_video_repository") as mock_video_repo,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_usecase_cls,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -707,9 +707,9 @@ class TestStrayLoggerRemoved:
source = inspect.getsource(generation_cover)
# The stray call was logger.info(\n plan_id,\n generation_task_id,\n)
# with no format string — should not exist
assert (
"logger.info(\n plan_id," not in source
), "Stray logger.info(plan_id, generation_task_id) should be removed"
assert "logger.info(\n plan_id," not in source, (
"Stray logger.info(plan_id, generation_task_id) should be removed"
)
class TestUploadCoverType:
@@ -1177,7 +1177,7 @@ class TestUploadCoverType:
return_value=mock_asset_repo,
),
patch("packages.shared.mediakit_client.get_mediakit_client", return_value=mock_mk),
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
@@ -1238,6 +1238,7 @@ class TestCoverFromFinalVideo:
mock_gv = MagicMock()
mock_gv.file_url = "rendered/final/video.mp4"
mock_gv.generation_task_id = "task-final-001"
mock_gv.user_id = "user-1"
# 最终成片任务
mock_final_task = MagicMock()
@@ -1259,8 +1260,11 @@ class TestCoverFromFinalVideo:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.dependencies.get_generated_video_repository", return_value=mock_gv_repo),
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
@@ -1276,6 +1280,8 @@ class TestCoverFromFinalVideo:
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.return_value = "https://oss.example.com/rendered/final/video.mp4"
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
@@ -1328,6 +1334,7 @@ class TestCoverFromFinalVideo:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
@@ -1341,6 +1348,11 @@ class TestCoverFromFinalVideo:
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
mock_storage_svc = MagicMock()
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
@@ -1404,7 +1416,7 @@ class TestCoverFromFinalVideo:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.ListGeneratedVideosByTaskUseCase") as mock_list_videos,
patch("packages.shared.storage.get_shared_storage_service") as mock_storage_getter,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch(
"app.api.routes.generation_cover._persist_cover_frame",
@@ -1431,6 +1443,8 @@ class TestCoverFromFinalVideo:
mock_storage_svc = MagicMock()
mock_storage_svc.get_url.side_effect = lambda key: f"https://oss.example.com/{key}"
mock_storage_svc.public_url = "https://oss.example.com"
mock_storage_svc.endpoint = "oss.example.com"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
@@ -1475,6 +1489,7 @@ class TestCoverFromFinalVideo:
mock_gv = MagicMock()
mock_gv.file_url = "rendered/other/video.mp4"
mock_gv.generation_task_id = "task-other"
mock_gv.user_id = "" # 老数据无 user_id,走关联任务归属校验
mock_other_task = MagicMock()
mock_other_task.created_by_user_id = "other-user"
@@ -1493,7 +1508,10 @@ class TestCoverFromFinalVideo:
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.dependencies.get_generated_video_repository", return_value=mock_gv_repo),
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
):
mock_repo = MagicMock()
mock_repo.get.return_value = mock_other_task
@@ -1511,3 +1529,125 @@ class TestCoverFromFinalVideo:
current_user=mock_current_user,
)
assert exc_info.value.status_code == 403
def test_cover_video_url_ssrf_blocked(self):
"""video_url 指向内网/非白名单域名时被忽略,不向其发起抽帧请求。"""
from unittest.mock import MagicMock, patch
from app.api.routes.generation_cover import GenerateCoverRequest
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
# SSRF 攻击载荷:内网元数据地址
body = GenerateCoverRequest(
cover_type="ai_frame",
video_url="http://100.100.100.200/latest/meta-data/",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch("app.api.routes.generation_cover.get_shared_storage_service") as mock_storage_getter,
patch("packages.shared.mediakit_client.get_mediakit_client") as mock_mk_getter,
patch("app.api.routes.generation_cover._persist_cover_frame") as mock_persist,
patch("app.api.routes.generation_cover.normalize_plan_config") as mock_normalize,
):
mock_repo = MagicMock()
mock_repo.get.return_value = None
mock_repo.list_by_source_edit_plan.return_value = []
mock_repo.list_latest_completed_preview.return_value = []
mock_repo_cls.return_value = mock_repo
mock_storage_svc = MagicMock()
mock_storage_svc.public_url = "https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com"
mock_storage_svc.endpoint = "oss-cn-hangzhou.aliyuncs.com"
mock_storage_svc.get_url.side_effect = lambda k: f"https://xiaoxia-media.oss-cn-hangzhou.aliyuncs.com/{k}"
mock_storage_getter.return_value = mock_storage_svc
mock_mk = MagicMock()
mock_mk.is_available = True
mock_mk.extract_frames.return_value = [{"image_url": "https://mk/f.jpg"}]
mock_mk_getter.return_value = mock_mk
mock_normalize.return_value = {"cover": {"type": "ai_frame", "image_url": "https://mk/f.jpg"}}
from app.api.routes.generation_cover import generate_cover
from fastapi import HTTPException
# 内网 URL 被白名单拦截后,无任何可用视频源 → 400(而不是向内网发请求)
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-ssrf",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 400
# MediaKit 从未被要求抽帧该内网地址
if mock_mk.extract_frames.called:
called_url = mock_mk.extract_frames.call_args.kwargs.get("video_url", "")
assert "100.100.100.200" not in called_url
assert "meta-data" not in called_url
def test_cover_generated_video_ownership_unverifiable_denied(self):
"""video 无 user_id 且关联任务不存在时,归属无法确认 → 403(防权限绕过)。"""
from unittest.mock import MagicMock, patch
import pytest
from app.api.routes.generation_cover import GenerateCoverRequest
from fastapi import HTTPException
mock_plan = MagicMock()
mock_plan.config = {}
mock_plan_svc = MagicMock()
mock_plan_svc.get_plan_or_raise.return_value = mock_plan
mock_template_svc = MagicMock()
mock_gv = MagicMock()
mock_gv.file_url = "rendered/mystery/video.mp4"
mock_gv.generation_task_id = "task-gone" # 关联任务已删除
mock_gv.user_id = "" # 老数据无 owner
mock_gv_repo = MagicMock()
mock_gv_repo.get.return_value = mock_gv
mock_db = MagicMock()
mock_current_user = MagicMock()
mock_current_user.user.id = "user-1"
body = GenerateCoverRequest(
cover_type="ai_frame",
generated_video_id="gv-mystery",
)
with (
patch("app.api.routes.generation_cover.SQLAlchemyGenerationTaskRepository") as mock_repo_cls,
patch(
"app.api.routes.generation_cover.get_generated_video_repository",
return_value=mock_gv_repo,
),
):
mock_repo = MagicMock()
mock_repo.get.return_value = None # 关联任务查不到
mock_repo_cls.return_value = mock_repo
from app.api.routes.generation_cover import generate_cover
with pytest.raises(HTTPException) as exc_info:
generate_cover(
body=body,
template_id="tpl-1",
plan_id="plan-orphan",
services=(mock_template_svc, mock_plan_svc),
db=mock_db,
current_user=mock_current_user,
)
assert exc_info.value.status_code == 403