fix(security): require signed subtitle downloads (#6055)

This commit is contained in:
InfinityPacer
2026-07-05 09:43:58 +08:00
committed by GitHub
parent 95b6adbeee
commit d977e4c48a
4 changed files with 354 additions and 12 deletions

View File

@@ -9,14 +9,41 @@ from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.helper.directory import DirectoryHelper
from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils
router = APIRouter()
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
"""
校验字幕下载签名,并用服务端站点配置覆盖请求凭据。
"""
if subtitle.site is None:
return False, "字幕站点信息为空"
clean_url = SecurityUtils.verify_signed_url(
subtitle.enclosure,
purpose=SecurityUtils.subtitle_download_purpose(subtitle.site),
)
if not clean_url:
return False, "字幕下载链接签名无效"
site = SiteOper().get(subtitle.site)
if not site:
return False, "字幕站点信息不存在"
subtitle.enclosure = clean_url
subtitle.site_cookie = site.cookie
subtitle.site_ua = site.ua
subtitle.site_proxy = bool(site.proxy)
return True, ""
@router.get("/", summary="正在下载", response_model=List[schemas.DownloaderTorrent])
def current(
name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)
@@ -127,6 +154,10 @@ def download_subtitle(
"""
subtitle_info = SubtitleInfo()
subtitle_info.from_dict(subtitle_in.model_dump())
valid, message = _prepare_subtitle_download(subtitle_info)
if not valid:
return schemas.Response(success=False, message=message)
success, message, saved_files = DownloadChain().download_subtitle(
subtitle=subtitle_info,
tmdbid=tmdbid,

View File

@@ -15,6 +15,7 @@ from app.core.security import verify_resource_token, verify_token
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -45,6 +46,49 @@ def _sse_event(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
def _serialize_signed_subtitle_result(subtitle: Any) -> dict:
"""
序列化字幕结果并签名下载链接,签名用途绑定站点 ID。
"""
data = subtitle.to_dict() if hasattr(subtitle, "to_dict") else dict(subtitle)
enclosure = data.get("enclosure")
if enclosure:
data["enclosure"] = SecurityUtils.sign_url(
enclosure,
purpose=SecurityUtils.subtitle_download_purpose(data.get("site")),
)
return data
def _serialize_signed_subtitle_results(subtitles: List[Any]) -> List[dict]:
"""
批量序列化字幕结果,确保返回给客户端的下载链接均已签名。
"""
return [_serialize_signed_subtitle_result(subtitle) for subtitle in subtitles]
def _sign_subtitle_search_event(event: dict) -> dict:
"""
签名字幕搜索流事件中的下载链接。
"""
signed_event = dict(event)
if "items" in signed_event:
signed_event["items"] = _serialize_signed_subtitle_results(
signed_event.get("items") or []
)
return signed_event
async def _iter_signed_subtitle_search_events(
event_source: AsyncIterator[dict],
) -> AsyncIterator[dict]:
"""
输出仅包含签名字幕下载链接的搜索流事件。
"""
async for event in event_source:
yield _sign_subtitle_search_event(event)
def _merge_append_event(pending_event: Optional[dict], event: dict) -> dict:
"""
合并短时间内连续到达的 append 事件,降低前端刷新频率。
@@ -168,7 +212,9 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token))
success=True,
data={
"params": params,
"results": [result.to_dict() for result in results],
"results": _serialize_signed_subtitle_results(results)
if params.get("result_type") == "subtitle"
else [result.to_dict() for result in results],
},
)
@@ -625,7 +671,11 @@ async def search_subtitle_by_title_stream(
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
)
return StreamingResponse(
_stream_search_events(request, event_source), media_type="text/event-stream"
_stream_search_events(
request,
_iter_signed_subtitle_search_events(event_source),
),
media_type="text/event-stream",
)
@@ -645,7 +695,7 @@ async def search_subtitle_by_title(
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
success=True, data=[subtitle.to_dict() for subtitle in subtitles]
success=True, data=_serialize_signed_subtitle_results(subtitles)
)
@@ -798,7 +848,11 @@ async def search_subtitle_by_id_stream(
yield event
return StreamingResponse(
_stream_search_events(request, event_source()), media_type="text/event-stream"
_stream_search_events(
request,
_iter_signed_subtitle_search_events(event_source()),
),
media_type="text/event-stream",
)
@@ -832,7 +886,7 @@ async def search_subtitle_by_id(
if not subtitles:
return schemas.Response(success=False, message="未搜索到任何字幕")
return schemas.Response(
success=True, data=[subtitle.to_dict() for subtitle in subtitles]
success=True, data=_serialize_signed_subtitle_results(subtitles)
)

View File

@@ -107,6 +107,7 @@ def _resolve_addrinfo_to_ips(
class SecurityUtils:
_SIGNED_URL_PURPOSE = "image-proxy"
_SUBTITLE_DOWNLOAD_PURPOSE_PREFIX = "subtitle-download"
@staticmethod
def is_safe_path(base_path: Path, user_path: Path,
@@ -452,16 +453,23 @@ class SecurityUtils:
@staticmethod
def strip_url_signature(url: str) -> str:
"""
移除 URL fragment 中的代理签名信息,得到真正要请求的地址。
移除 URL fragment 中的资源签名信息,得到真正要请求的地址。
图片代理签名放在 fragment 中,浏览器会把它传给 MoviePilot但 HTTP
客户端请求媒体服务器前不能把这些内部参数带过去。
签名放在 fragment 中,浏览器会把它传给 MoviePilot但 HTTP 客户端
请求外部资源前不能把这些内部参数带过去。
"""
if not url:
return url
parsed_url = urlparse(url)
return urlunparse(parsed_url._replace(fragment=""))
@staticmethod
def subtitle_download_purpose(site_id: int) -> str:
"""
构造字幕下载 URL 签名用途,签名必须绑定站点 ID避免跨站点复用。
"""
return f"{SecurityUtils._SUBTITLE_DOWNLOAD_PURPOSE_PREFIX}:{site_id}"
@staticmethod
def sign_url(
url: str,
@@ -470,9 +478,8 @@ class SecurityUtils:
"""
给服务端返回的资源 URL 添加稳定签名。
签名作为 `/system/img` 代理放行私网图片 URL 的能力凭证:图片代理默认
拒绝解析到非公网地址的 URL防 SSRF合法媒体服务器 URL 必须由后端
预先签名后才能跳过该限制。
签名作为后端资源能力凭证:外部请求边界可以用不同 `purpose` 绑定
具体业务语义,避免一个场景签出的 URL 被挪用到另一个场景。
签名为 `(url, purpose, RESOURCE_SECRET_KEY)` 的确定性 HMAC**不带
过期时间**:相同 URL 多次调用结果完全一致,让浏览器与 Service Worker
@@ -500,7 +507,7 @@ class SecurityUtils:
purpose: str = _SIGNED_URL_PURPOSE,
) -> Optional[str]:
"""
验证 URL fragment 中的代理签名,成功时返回去签名后的真实 URL。
验证 URL fragment 中的资源签名,成功时返回去签名后的真实 URL。
签名只校验 `(url, purpose, RESOURCE_SECRET_KEY)`,密钥轮换/进程重启
后旧签名自动失效。