mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-28 07:26:52 +00:00
275 lines
9.1 KiB
Python
275 lines
9.1 KiB
Python
import socket
|
||
from unittest import TestCase
|
||
from unittest.mock import patch
|
||
|
||
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):
|
||
"""
|
||
默认 URL 校验保持历史 allowlist 行为,避免影响非代理调用方。
|
||
"""
|
||
self.assertTrue(
|
||
SecurityUtils.is_safe_url(
|
||
"http://192.168.1.50:8096/secret.png",
|
||
{"http://192.168.1.50:8096"},
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_blocks_private_literal_ip_when_enabled(self):
|
||
"""
|
||
启用 SSRF 防护时,即使内网 IP 命中 allowlist 也不能放行。
|
||
"""
|
||
self.assertFalse(
|
||
SecurityUtils.is_safe_url(
|
||
"http://192.168.1.50:8096/secret.png",
|
||
{"http://192.168.1.50:8096"},
|
||
block_private=True,
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_blocks_loopback_dns_result_when_enabled(self):
|
||
"""
|
||
主机名解析到回环地址时必须拒绝,防止通过域名绕过内网地址拦截。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
return_value=[
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("127.0.0.1", 0),
|
||
)
|
||
],
|
||
):
|
||
self.assertFalse(
|
||
SecurityUtils.is_safe_url(
|
||
"http://internal.example.com/secret.png",
|
||
{"example.com"},
|
||
block_private=True,
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_blocks_mixed_public_and_private_dns_results(self):
|
||
"""
|
||
同一域名只要存在任一非公网解析结果,就不能作为图片代理目标。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
return_value=[
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("93.184.216.34", 0),
|
||
),
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("10.0.0.8", 0),
|
||
),
|
||
],
|
||
):
|
||
self.assertFalse(
|
||
SecurityUtils.is_safe_url(
|
||
"https://assets.example.com/poster.jpg",
|
||
{"example.com"},
|
||
block_private=True,
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_allows_public_dns_result_when_enabled(self):
|
||
"""
|
||
域名解析结果全部为公网地址且命中 allowlist 时继续允许访问。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
return_value=[
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("93.184.216.34", 0),
|
||
)
|
||
],
|
||
):
|
||
self.assertTrue(
|
||
SecurityUtils.is_safe_url(
|
||
"https://assets.example.com/poster.jpg",
|
||
{"example.com"},
|
||
block_private=True,
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_rejects_dns_resolution_failure_when_enabled(self):
|
||
"""
|
||
SSRF 防护无法确认目标地址时按失败处理,避免解析异常时继续请求。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
side_effect=socket.gaierror,
|
||
):
|
||
self.assertFalse(
|
||
SecurityUtils.is_safe_url(
|
||
"https://assets.example.com/poster.jpg",
|
||
{"example.com"},
|
||
block_private=True,
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_allows_configured_private_range_after_domain_match(self):
|
||
"""
|
||
图片域名命中 allowlist 后,可通过配置允许 TUN fake-ip 等特定非公网网段。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
return_value=[
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("198.18.16.96", 0),
|
||
)
|
||
],
|
||
), patch("app.utils.security.logger.debug") as debug_log:
|
||
self.assertTrue(
|
||
SecurityUtils.is_safe_url(
|
||
"https://img1.doubanio.com/poster.webp",
|
||
{"doubanio.com"},
|
||
block_private=True,
|
||
allowed_private_ranges=["198.18.0.0/15"],
|
||
)
|
||
)
|
||
debug_message = debug_log.call_args.args[0]
|
||
self.assertIn("ips=198.18.16.96", debug_message)
|
||
self.assertIn("ranges=198.18.0.0/15", debug_message)
|
||
|
||
def test_is_safe_url_blocks_configured_private_range_without_domain_match(self):
|
||
"""
|
||
非公网网段例外必须依附域名白名单,不能单独放行任意用户 URL。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
return_value=[
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("198.18.16.96", 0),
|
||
)
|
||
],
|
||
):
|
||
self.assertFalse(
|
||
SecurityUtils.is_safe_url(
|
||
"https://attacker.example.com/poster.webp",
|
||
{"doubanio.com"},
|
||
block_private=True,
|
||
allowed_private_ranges=["198.18.0.0/15"],
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_blocks_private_result_outside_configured_range(self):
|
||
"""
|
||
仅允许显式配置的非公网网段,其它内网解析结果仍按 SSRF 风险拦截。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
return_value=[
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("10.0.0.8", 0),
|
||
)
|
||
],
|
||
):
|
||
self.assertFalse(
|
||
SecurityUtils.is_safe_url(
|
||
"https://assets.example.com/poster.jpg",
|
||
{"example.com"},
|
||
block_private=True,
|
||
allowed_private_ranges=["198.18.0.0/15"],
|
||
)
|
||
)
|
||
|
||
def test_is_safe_url_blocks_mixed_allowed_and_disallowed_private_results(self):
|
||
"""
|
||
同一域名的解析结果必须全部落在允许网段内,避免部分安全结果掩盖风险地址。
|
||
"""
|
||
with patch(
|
||
"app.utils.security.socket.getaddrinfo",
|
||
return_value=[
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("198.18.16.96", 0),
|
||
),
|
||
(
|
||
socket.AF_INET,
|
||
socket.SOCK_STREAM,
|
||
0,
|
||
"",
|
||
("10.0.0.8", 0),
|
||
),
|
||
],
|
||
):
|
||
self.assertFalse(
|
||
SecurityUtils.is_safe_url(
|
||
"https://assets.example.com/poster.jpg",
|
||
{"example.com"},
|
||
block_private=True,
|
||
allowed_private_ranges=["198.18.0.0/15"],
|
||
)
|
||
)
|