fix: sign media server image proxy URLs

This commit is contained in:
jxxghp
2026-05-25 12:41:55 +08:00
parent d713ea54c1
commit 63b9994b0e
6 changed files with 327 additions and 160 deletions

View 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()

View File

@@ -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):
"""

View File

@@ -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(),
)
)