mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-09 01:31:05 +08:00
fix: sign media server image proxy URLs
This commit is contained in:
82
tests/test_mediaserver_image_signing.py
Normal file
82
tests/test_mediaserver_image_signing.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
for _module_name in (
|
||||
"app.chain.mediaserver",
|
||||
"app.db.models",
|
||||
"app.db.user_oper",
|
||||
"app.helper.message",
|
||||
"app.utils.crypto",
|
||||
):
|
||||
if _module_name in sys.modules and not hasattr(
|
||||
sys.modules[_module_name], "__file__"
|
||||
):
|
||||
del sys.modules[_module_name]
|
||||
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.schemas import MediaServerLibrary, MediaServerPlayItem
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
|
||||
class MediaServerImageSigningTest(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _build_chain(result):
|
||||
"""
|
||||
构造只带 run_module 的 MediaServerChain,避免单测初始化真实模块管理器。
|
||||
"""
|
||||
chain = MediaServerChain.__new__(MediaServerChain)
|
||||
chain.run_module = Mock(return_value=result)
|
||||
return chain
|
||||
|
||||
def test_librarys_signs_image_fields(self):
|
||||
"""
|
||||
媒体库接口返回前需要给 image 和 image_list 加签。
|
||||
"""
|
||||
image = "http://192.168.1.50:8096/Items/lib/Images/Primary"
|
||||
image_list = [
|
||||
"http://192.168.1.50:32400/library/metadata/1/thumb/1",
|
||||
]
|
||||
chain = self._build_chain(
|
||||
[
|
||||
MediaServerLibrary(
|
||||
id="lib",
|
||||
image=image,
|
||||
image_list=image_list,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
result = chain.librarys(server="jellyfin")
|
||||
|
||||
self.assertEqual(SecurityUtils.verify_signed_url(result[0].image), image)
|
||||
self.assertEqual(
|
||||
SecurityUtils.verify_signed_url(result[0].image_list[0]),
|
||||
image_list[0],
|
||||
)
|
||||
|
||||
def test_latest_signs_play_item_images(self):
|
||||
"""
|
||||
最近入库接口返回前需要给条目图片加签。
|
||||
"""
|
||||
image = "http://192.168.1.50:8096/Items/item/Images/Backdrop"
|
||||
chain = self._build_chain([MediaServerPlayItem(id="item", image=image)])
|
||||
|
||||
result = chain.latest(server="jellyfin")
|
||||
|
||||
self.assertEqual(SecurityUtils.verify_signed_url(result[0].image), image)
|
||||
|
||||
def test_latest_wallpapers_signs_urls(self):
|
||||
"""
|
||||
媒体服务器壁纸 URL 返回前也需要加签。
|
||||
"""
|
||||
wallpaper = "http://192.168.1.50:8096/Items/item/Images/Backdrop"
|
||||
chain = self._build_chain([wallpaper])
|
||||
|
||||
result = chain.get_latest_wallpapers()
|
||||
|
||||
self.assertEqual(SecurityUtils.verify_signed_url(result[0]), wallpaper)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,44 @@ from app.utils.security import SecurityUtils
|
||||
|
||||
|
||||
class SecurityUtilsTest(TestCase):
|
||||
def test_signed_url_roundtrip_returns_clean_url(self):
|
||||
"""
|
||||
URL 签名验证成功后返回不含签名片段的真实请求地址。
|
||||
"""
|
||||
url = "http://192.168.1.50:8096/Items/abc/Images/Primary?api_key=demo"
|
||||
|
||||
signed_url = SecurityUtils.sign_url(url)
|
||||
|
||||
self.assertIn("#mp_exp=", signed_url)
|
||||
self.assertEqual(SecurityUtils.verify_signed_url(signed_url), url)
|
||||
self.assertEqual(SecurityUtils.strip_url_signature(signed_url), url)
|
||||
|
||||
def test_signed_url_rejects_tampered_url(self):
|
||||
"""
|
||||
签名绑定完整 URL,签名后修改路径必须校验失败。
|
||||
"""
|
||||
signed_url = SecurityUtils.sign_url(
|
||||
"http://192.168.1.50:8096/Items/abc/Images/Primary"
|
||||
)
|
||||
tampered_url = signed_url.replace(
|
||||
"/Items/abc/Images/Primary",
|
||||
"/System/Info/Public",
|
||||
)
|
||||
|
||||
self.assertIsNone(SecurityUtils.verify_signed_url(tampered_url))
|
||||
|
||||
def test_signed_url_rejects_expired_signature(self):
|
||||
"""
|
||||
已过期签名不能继续放行私网图片代理请求。
|
||||
"""
|
||||
with patch("app.utils.security.time.time", return_value=1000):
|
||||
signed_url = SecurityUtils.sign_url(
|
||||
"http://192.168.1.50:8096/Items/abc/Images/Primary",
|
||||
expires_in=10,
|
||||
)
|
||||
|
||||
with patch("app.utils.security.time.time", return_value=1011):
|
||||
self.assertIsNone(SecurityUtils.verify_signed_url(signed_url))
|
||||
|
||||
def test_is_safe_url_keeps_default_allowlist_behavior(self):
|
||||
"""
|
||||
|
||||
@@ -37,7 +37,12 @@ _stub_module("app.chain.media", MediaChain=_Dummy)
|
||||
_stub_module("app.chain.mediaserver", MediaServerChain=_Dummy)
|
||||
_stub_module("app.chain.search", SearchChain=_Dummy)
|
||||
_stub_module("app.chain.system", SystemChain=_Dummy)
|
||||
_stub_module("app.core.event", eventmanager=_Dummy(), Event=_Dummy)
|
||||
_stub_module(
|
||||
"app.core.event",
|
||||
eventmanager=_Dummy(),
|
||||
Event=_Dummy,
|
||||
EventManager=_Dummy,
|
||||
)
|
||||
_stub_module("app.core.metainfo", MetaInfo=_Dummy)
|
||||
_stub_module("app.core.module", ModuleManager=_Dummy)
|
||||
_stub_module(
|
||||
@@ -82,10 +87,12 @@ from app.api.endpoints import system as system_endpoint
|
||||
|
||||
|
||||
class NettestSecurityTest(unittest.TestCase):
|
||||
def test_fetch_image_allows_private_media_server_image_path(self):
|
||||
def test_fetch_image_allows_signed_private_url(self):
|
||||
"""
|
||||
已配置媒体服务器的内网图片接口需要继续允许代理,保证前端封面显示。
|
||||
服务端签名过的私网图片 URL 可以继续代理,保证前端封面显示。
|
||||
"""
|
||||
image_url = "http://192.168.1.50:8096/System/Info/Public"
|
||||
signed_url = system_endpoint.SecurityUtils.sign_url(image_url)
|
||||
image_helper = Mock()
|
||||
image_helper.async_fetch_image = AsyncMock(return_value=b"image-bytes")
|
||||
|
||||
@@ -96,14 +103,18 @@ class NettestSecurityTest(unittest.TestCase):
|
||||
):
|
||||
resp = asyncio.run(
|
||||
system_endpoint.fetch_image(
|
||||
url="http://192.168.1.50:8096/Items/abc/Images/Primary",
|
||||
allowed_domains={"http://192.168.1.50:8096"},
|
||||
media_server_domains={"http://192.168.1.50:8096"},
|
||||
url=signed_url,
|
||||
allowed_domains=set(),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
image_helper.async_fetch_image.assert_awaited_once()
|
||||
image_helper.async_fetch_image.assert_awaited_once_with(
|
||||
url=image_url,
|
||||
proxy=None,
|
||||
use_cache=False,
|
||||
cookies=None,
|
||||
)
|
||||
|
||||
def test_fetch_image_blocks_private_allowed_url_before_request(self):
|
||||
"""
|
||||
@@ -123,39 +134,23 @@ class NettestSecurityTest(unittest.TestCase):
|
||||
|
||||
self.assertIsNone(resp)
|
||||
|
||||
def test_fetch_image_blocks_private_media_server_non_image_path(self):
|
||||
def test_fetch_image_blocks_tampered_signed_private_url(self):
|
||||
"""
|
||||
媒体服务器 host 只放行图片接口,不放行同 host 下的任意 API。
|
||||
私网签名绑定完整 URL,改动路径后不能继续代理。
|
||||
"""
|
||||
signed_url = system_endpoint.SecurityUtils.sign_url(
|
||||
"http://192.168.1.50:8096/Items/abc/Images/Primary"
|
||||
).replace("/Items/abc/Images/Primary", "/System/Info/Public")
|
||||
|
||||
class FailIfCalled:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("fetch_image should block non-image media server paths")
|
||||
raise AssertionError("fetch_image should block tampered signed URLs")
|
||||
|
||||
with patch.object(system_endpoint, "ImageHelper", FailIfCalled):
|
||||
resp = asyncio.run(
|
||||
system_endpoint.fetch_image(
|
||||
url="http://192.168.1.50:8096/System/Info/Public",
|
||||
allowed_domains={"http://192.168.1.50:8096"},
|
||||
media_server_domains={"http://192.168.1.50:8096"},
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIsNone(resp)
|
||||
|
||||
def test_fetch_image_blocks_traversal_in_media_server_image_path(self):
|
||||
"""
|
||||
编码后的路径穿越不能借媒体图片前缀绕过私网 SSRF 防护。
|
||||
"""
|
||||
class FailIfCalled:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("fetch_image should block traversal image paths")
|
||||
|
||||
with patch.object(system_endpoint, "ImageHelper", FailIfCalled):
|
||||
resp = asyncio.run(
|
||||
system_endpoint.fetch_image(
|
||||
url="http://192.168.1.50:5666/api/v1/sys/img/%2e%2e/manager/user/list",
|
||||
allowed_domains={"http://192.168.1.50:5666"},
|
||||
media_server_domains={"http://192.168.1.50:5666"},
|
||||
url=signed_url,
|
||||
allowed_domains=set(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user