Skip to content

Commit 63b9994

Browse files
committed
fix: sign media server image proxy URLs
1 parent d713ea5 commit 63b9994

6 files changed

Lines changed: 326 additions & 159 deletions

File tree

app/api/endpoints/system.py

Lines changed: 5 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import asyncio
22
import json
3-
import posixpath
4-
import re
53
from collections import deque
64
from datetime import datetime
75
from typing import Any, Optional, Union, Annotated
8-
from urllib.parse import parse_qs, unquote, urljoin, urlparse
6+
from urllib.parse import urljoin, urlparse
97

108
import aiofiles
119
import pillow_avif # noqa 用于自动注册AVIF支持
@@ -32,7 +30,6 @@
3230
get_current_active_user_async,
3331
)
3432
from app.helper.image import ImageHelper
35-
from app.helper.mediaserver import MediaServerHelper
3633
from app.helper.message import MessageHelper
3734
from app.helper.progress import ProgressHelper
3835
from app.helper.rule import RuleHelper
@@ -52,21 +49,6 @@
5249
router = APIRouter()
5350

5451
_NETTEST_REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
55-
_MEDIA_SERVER_IMAGE_PATH_PATTERNS = (
56-
re.compile(
57-
r"^/(?:emby/)?Items/[^/]+/Images/"
58-
r"(?:Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter)"
59-
r"(?:/[^/]+)?$",
60-
re.IGNORECASE,
61-
),
62-
re.compile(
63-
r"^/library/metadata/[^/]+/"
64-
r"(?:thumb|art|banner|poster|clearlogo|clearart|background)"
65-
r"(?:/[^/]+)?$",
66-
re.IGNORECASE,
67-
),
68-
re.compile(r"^/api/v1/sys/img/.+", re.IGNORECASE),
69-
)
7052

7153

7254
def _match_nettest_prefix(url: str, prefix: str) -> bool:
@@ -359,103 +341,13 @@ async def _close_nettest_response(response: Any) -> None:
359341
logger.debug(f"关闭网络测试响应失败: {err}")
360342

361343

362-
def _normalize_proxy_image_path(path: str) -> str:
363-
"""
364-
归一化代理图片路径,用于识别媒体服务器图片接口。
365-
366-
URL path 可能包含编码后的特殊字符,这里先解码再规范化路径,避免
367-
`%2e%2e` 或重复斜杠绕过后续的媒体图片路径判断。
368-
"""
369-
decoded_path = unquote(path or "/")
370-
normalized_path = posixpath.normpath(decoded_path)
371-
if not normalized_path.startswith("/"):
372-
normalized_path = f"/{normalized_path}"
373-
return normalized_path
374-
375-
376-
def _is_known_media_server_image_path(path: str) -> bool:
377-
"""
378-
判断路径是否属于已知媒体服务器图片读取接口。
379-
380-
这里仅覆盖 MoviePilot 自身会返回给前端的封面、背景图和图片流接口,
381-
不允许媒体服务器同 host 下的任意 API 路径继续通过图片代理访问。
382-
"""
383-
normalized_path = _normalize_proxy_image_path(path)
384-
return any(
385-
pattern.match(normalized_path)
386-
for pattern in _MEDIA_SERVER_IMAGE_PATH_PATTERNS
387-
)
388-
389-
390-
def _is_plex_transcode_image_url(url: str) -> bool:
391-
"""
392-
校验 Plex 图片转码接口只转码 Plex 自身 metadata 图片路径。
393-
394-
Plex 的 posterUrl/artUrl 可能使用 `/photo/:/transcode` 包装真实图片路径,
395-
因此需要额外检查 query 里的 `url` 仍然是 metadata 图片路径,而不是
396-
任意可被 Plex 代取的地址。
397-
"""
398-
parsed_url = urlparse(url)
399-
if _normalize_proxy_image_path(parsed_url.path) != "/photo/:/transcode":
400-
return False
401-
source_path = parse_qs(parsed_url.query).get("url", [None])[0]
402-
if not source_path:
403-
return False
404-
source_url = urlparse(source_path)
405-
if source_url.scheme or source_url.netloc:
406-
return False
407-
return _is_known_media_server_image_path(source_path)
408-
409-
410-
def _is_ugreen_image_stream_url(url: str) -> bool:
411-
"""
412-
校验绿联本机图片流接口只代理官方 scraper 图片。
413-
414-
绿联本地图片需要带加密鉴权头,目前模块只会把 scraper.ugnas.com 的签名图
415-
转成 getImaStream,本检查避免用户把该接口改造成任意远程 URL 中转。
416-
"""
417-
parsed_url = urlparse(url)
418-
if _normalize_proxy_image_path(parsed_url.path) != "/ugreen/v2/video/getImaStream":
419-
return False
420-
source_url = parse_qs(parsed_url.query).get("name", [None])[0]
421-
if not source_url:
422-
return False
423-
parsed_source = urlparse(source_url)
424-
return (
425-
parsed_source.scheme in {"http", "https"}
426-
and parsed_source.netloc.lower() == "scraper.ugnas.com"
427-
)
428-
429-
430-
def _is_allowed_media_server_image_url(
431-
url: str,
432-
media_server_domains: set[str],
433-
) -> bool:
434-
"""
435-
判断内网媒体服务器 URL 是否可作为图片代理目标。
436-
437-
私有地址默认仍然禁止;只有 URL host 精确命中已配置媒体服务器,并且路径是
438-
已知图片接口时才允许访问,用于兼容前端媒体库和最近入库图片展示。
439-
"""
440-
if not media_server_domains:
441-
return False
442-
if not SecurityUtils.is_safe_url(url, media_server_domains, strict=True):
443-
return False
444-
return (
445-
_is_known_media_server_image_path(urlparse(url).path)
446-
or _is_plex_transcode_image_url(url)
447-
or _is_ugreen_image_stream_url(url)
448-
)
449-
450-
451344
async def fetch_image(
452345
url: str,
453346
proxy: Optional[bool] = None,
454347
use_cache: bool = False,
455348
if_none_match: Optional[str] = None,
456349
cookies: Optional[str | dict] = None,
457350
allowed_domains: Optional[set[str]] = None,
458-
media_server_domains: Optional[set[str]] = None,
459351
) -> Optional[Response]:
460352
"""
461353
处理图片缓存逻辑,支持HTTP缓存和磁盘缓存
@@ -466,17 +358,16 @@ async def fetch_image(
466358
if allowed_domains is None:
467359
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)
468360

361+
fetch_url = SecurityUtils.strip_url_signature(url)
469362
# 验证URL安全性
470363
if not SecurityUtils.is_safe_url(
471364
url, allowed_domains, block_private=True
472-
) and not _is_allowed_media_server_image_url(
473-
url, media_server_domains or set()
474-
):
365+
) and not (fetch_url := SecurityUtils.verify_signed_url(url)):
475366
logger.warn(f"Blocked unsafe image URL: {url}")
476367
return None
477368

478369
content = await ImageHelper().async_fetch_image(
479-
url=url,
370+
url=fetch_url,
480371
proxy=proxy,
481372
use_cache=use_cache,
482373
cookies=cookies,
@@ -491,7 +382,7 @@ async def fetch_image(
491382
# 返回缓存图片
492383
return Response(
493384
content=content,
494-
media_type=UrlUtils.get_mime_type(url, "image/jpeg"),
385+
media_type=UrlUtils.get_mime_type(fetch_url, "image/jpeg"),
495386
headers=headers,
496387
)
497388
return None
@@ -509,13 +400,6 @@ async def proxy_img(
509400
"""
510401
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
511402
"""
512-
# 媒体服务器添加图片代理支持
513-
hosts = [
514-
config.config.get("host")
515-
for config in MediaServerHelper().get_configs().values()
516-
if config and config.config and config.config.get("host")
517-
]
518-
media_server_domains = set(hosts)
519403
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)
520404
cookies = (
521405
MediaServerChain().get_image_cookies(server=None, image_url=imgurl)
@@ -529,7 +413,6 @@ async def proxy_img(
529413
cookies=cookies,
530414
if_none_match=if_none_match,
531415
allowed_domains=allowed_domains,
532-
media_server_domains=media_server_domains,
533416
)
534417

535418

app/chain/mediaserver.py

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.helper.service import ServiceConfigHelper
99
from app.log import logger
1010
from app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem
11+
from app.utils.security import SecurityUtils
1112

1213
lock = threading.Lock()
1314

@@ -17,12 +18,54 @@ class MediaServerChain(ChainBase):
1718
媒体服务器处理链
1819
"""
1920

21+
@staticmethod
22+
def _sign_image_url(url: Optional[str]) -> Optional[str]:
23+
"""
24+
为返回前端的媒体服务器图片 URL 添加代理签名。
25+
"""
26+
return SecurityUtils.sign_url(url) if url else url
27+
28+
def _sign_library_images(
29+
self, libraries: Optional[List[MediaServerLibrary]]
30+
) -> List[MediaServerLibrary]:
31+
"""
32+
给媒体库列表中的封面和封面组添加代理签名。
33+
"""
34+
for library in libraries or []:
35+
if library.image:
36+
library.image = self._sign_image_url(library.image)
37+
if library.image_list:
38+
library.image_list = [
39+
self._sign_image_url(image)
40+
for image in library.image_list
41+
if image
42+
]
43+
return libraries or []
44+
45+
def _sign_play_item_images(
46+
self, items: Optional[List[MediaServerPlayItem]]
47+
) -> List[MediaServerPlayItem]:
48+
"""
49+
给媒体服务器播放条目中的图片 URL 添加代理签名。
50+
"""
51+
for item in items or []:
52+
if item.image:
53+
item.image = self._sign_image_url(item.image)
54+
return items or []
55+
2056
def librarys(self, server: str, username: Optional[str] = None,
2157
hidden: bool = False) -> List[MediaServerLibrary]:
2258
"""
2359
获取媒体服务器所有媒体库
2460
"""
25-
return self.run_module("mediaserver_librarys", server=server, username=username, hidden=hidden)
61+
return self._sign_library_images(
62+
self.run_module(
63+
"mediaserver_librarys",
64+
server=server,
65+
username=username,
66+
hidden=hidden,
67+
)
68+
)
2669

2770
def items(self, server: str, library_id: Union[str, int],
2871
start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Generator[Any, None, None]:
@@ -83,22 +126,46 @@ def playing(self, server: str, count: Optional[int] = 20,
83126
"""
84127
获取媒体服务器正在播放信息
85128
"""
86-
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
129+
return self._sign_play_item_images(
130+
self.run_module(
131+
"mediaserver_playing",
132+
count=count,
133+
server=server,
134+
username=username,
135+
)
136+
)
87137

88138
def latest(self, server: str, count: Optional[int] = 20,
89139
username: Optional[str] = None) -> List[MediaServerPlayItem]:
90140
"""
91141
获取媒体服务器最新入库条目
92142
"""
93-
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
143+
return self._sign_play_item_images(
144+
self.run_module(
145+
"mediaserver_latest",
146+
count=count,
147+
server=server,
148+
username=username,
149+
)
150+
)
94151

95152
def get_latest_wallpapers(self, server: Optional[str] = None, count: Optional[int] = 10,
96153
remote: bool = True, username: Optional[str] = None) -> List[str]:
97154
"""
98155
获取最新最新入库条目海报作为壁纸,缓存1小时
99156
"""
100-
return self.run_module("mediaserver_latest_images", server=server, count=count,
101-
remote=remote, username=username)
157+
wallpapers = self.run_module(
158+
"mediaserver_latest_images",
159+
server=server,
160+
count=count,
161+
remote=remote,
162+
username=username,
163+
)
164+
return [
165+
self._sign_image_url(wallpaper)
166+
for wallpaper in wallpapers or []
167+
if wallpaper
168+
]
102169

103170
def get_latest_wallpaper(self, server: Optional[str] = None,
104171
remote: bool = True, username: Optional[str] = None) -> Optional[str]:

0 commit comments

Comments
 (0)