Files
MoviePilot/tests/test_security_utils.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

883 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
验证 URL 签名、地址安全判断及 DNS 缓存和并发解析。
"""
import socket
from unittest.mock import patch
import pytest
from app.application.security.url import (
SecurityUtils,
UrlSafetyDiagnosis,
UrlSafetyReason,
_dns_inflight_locks,
_dns_negative_cache,
_dns_positive_cache,
)
@pytest.fixture
def _isolate_dns_state():
"""
在空缓存下运行 DNS 校验,并在用例结束时恢复原有缓存和锁映射。
"""
caches = (_dns_positive_cache, _dns_negative_cache, _dns_inflight_locks)
originals = [dict(cache) for cache in caches]
for cache in caches:
cache.clear()
try:
yield
finally:
for cache, original in zip(caches, originals):
cache.clear()
cache.update(original)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_signed_url_roundtrip_returns_clean_url() -> None:
"""
URL 签名验证成功后返回不含签名片段的真实请求地址。
"""
url = "http://192.168.1.50:8096/Items/abc/Images/Primary?api_key=demo"
signed_url = SecurityUtils.sign_url(url)
assert "#mp_sig=" in signed_url
assert "mp_purpose=image-proxy" in signed_url
assert SecurityUtils.verify_signed_url(signed_url) == url
assert SecurityUtils.strip_url_signature(signed_url) == url
@pytest.mark.usefixtures("_isolate_dns_state")
def test_signed_url_rejects_tampered_url() -> None:
"""
签名绑定完整 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",
)
assert SecurityUtils.verify_signed_url(tampered_url) is None
@pytest.mark.usefixtures("_isolate_dns_state")
def test_signed_url_is_deterministic_for_same_inputs() -> None:
"""
相同 URL 与 RESOURCE_SECRET_KEY 多次签名结果必须完全一致,
保证浏览器 / Service Worker 缓存能稳定命中。
"""
url = "http://192.168.1.50:8096/Items/abc/Images/Primary"
first = SecurityUtils.sign_url(url)
second = SecurityUtils.sign_url(url)
assert first == second
assert SecurityUtils.verify_signed_url(first) == url
@pytest.mark.usefixtures("_isolate_dns_state")
def test_signed_url_invalidated_after_secret_rotation() -> None:
"""
`RESOURCE_SECRET_KEY` 变更(进程重启或运维显式轮换)后旧签名必须作废,
作为签名长期有效模型的失效兜底。
"""
url = "http://192.168.1.50:8096/Items/abc/Images/Primary"
with patch(
"app.runtime.config.settings.RESOURCE_SECRET_KEY",
"old-secret-value-aaaaaaaaaaaaaaaaaaaaaaaa",
):
signed_url = SecurityUtils.sign_url(url)
assert SecurityUtils.verify_signed_url(signed_url) == url
with patch(
"app.runtime.config.settings.RESOURCE_SECRET_KEY",
"new-secret-value-bbbbbbbbbbbbbbbbbbbbbbbb",
):
assert SecurityUtils.verify_signed_url(signed_url) is None
@pytest.mark.usefixtures("_isolate_dns_state")
def test_signed_url_rejects_other_purpose() -> None:
"""
签名绑定 `purpose`,挪用到其它签名用途必须被拒绝。
"""
url = "http://192.168.1.50:8096/Items/abc/Images/Primary"
signed_url = SecurityUtils.sign_url(url)
assert SecurityUtils.verify_signed_url(signed_url, purpose="other-purpose") is None
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_keeps_default_allowlist_behavior() -> None:
"""
默认 URL 校验保持历史 allowlist 行为,避免影响非代理调用方。
"""
assert SecurityUtils.is_safe_url("http://192.168.1.50:8096/secret.png", {"http://192.168.1.50:8096"})
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_blocks_private_literal_ip_when_enabled() -> None:
"""
启用 SSRF 防护时,即使内网 IP 命中 allowlist 也不能放行。
"""
assert not SecurityUtils.is_safe_url(
"http://192.168.1.50:8096/secret.png", {"http://192.168.1.50:8096"}, block_private=True
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_blocks_loopback_dns_result_when_enabled() -> None:
"""
主机名解析到回环地址时必须拒绝,防止通过域名绕过内网地址拦截。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("127.0.0.1", 0),
)
],
):
assert not SecurityUtils.is_safe_url(
"http://internal.example.com/secret.png", {"example.com"}, block_private=True
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_blocks_mixed_public_and_private_dns_results() -> None:
"""
同一域名只要存在任一非公网解析结果,就不能作为图片代理目标。
"""
with patch(
"app.adapters.network.resolver.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),
),
],
):
assert not SecurityUtils.is_safe_url(
"https://assets.example.com/poster.jpg", {"example.com"}, block_private=True
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_allows_public_dns_result_when_enabled() -> None:
"""
域名解析结果全部为公网地址且命中 allowlist 时继续允许访问。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("93.184.216.34", 0),
)
],
):
assert SecurityUtils.is_safe_url("https://assets.example.com/poster.jpg", {"example.com"}, block_private=True)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_rejects_dns_resolution_failure_when_enabled() -> None:
"""
SSRF 防护无法确认目标地址时按失败处理,避免解析异常时继续请求。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
side_effect=socket.gaierror,
):
assert not SecurityUtils.is_safe_url(
"https://assets.example.com/poster.jpg", {"example.com"}, block_private=True
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_allows_configured_private_range_after_domain_match() -> None:
"""
图片域名命中 allowlist 后,可通过配置允许 TUN fake-ip 等特定非公网网段。
"""
with (
patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("198.18.16.96", 0),
)
],
),
patch("app.application.security.url.logger.debug") as debug_log,
):
assert 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]
assert "ips=198.18.16.96" in debug_message
assert "ranges=198.18.0.0/15" in debug_message
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_blocks_configured_private_range_without_domain_match() -> None:
"""
非公网网段例外必须依附域名白名单,不能单独放行任意用户 URL。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("198.18.16.96", 0),
)
],
):
assert not SecurityUtils.is_safe_url(
"https://attacker.example.com/poster.webp",
{"doubanio.com"},
block_private=True,
allowed_private_ranges=["198.18.0.0/15"],
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_blocks_private_result_outside_configured_range() -> None:
"""
仅允许显式配置的非公网网段,其它内网解析结果仍按 SSRF 风险拦截。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("10.0.0.8", 0),
)
],
):
assert not SecurityUtils.is_safe_url(
"https://assets.example.com/poster.jpg",
{"example.com"},
block_private=True,
allowed_private_ranges=["198.18.0.0/15"],
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_blocks_mixed_allowed_and_disallowed_private_results() -> None:
"""
同一域名的解析结果必须全部落在允许网段内,避免部分安全结果掩盖风险地址。
"""
with patch(
"app.adapters.network.resolver.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),
),
],
):
assert not SecurityUtils.is_safe_url(
"https://assets.example.com/poster.jpg",
{"example.com"},
block_private=True,
allowed_private_ranges=["198.18.0.0/15"],
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_async_uses_event_loop_resolver() -> None:
"""
异步版本通过事件循环的非阻塞 getaddrinfo 完成 SSRF 校验,
且语义与同步版本保持一致:解析到非公网地址时仍然拒绝。
"""
import asyncio
async def fake_getaddrinfo(host, *_args, **_kwargs):
"""
返回预设 DNS 地址,隔离真实域名解析并记录调用。
"""
assert host == "internal.example.com"
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("127.0.0.1", 0),
)
]
async def run() -> bool:
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
loop = asyncio.get_running_loop()
with patch.object(loop, "getaddrinfo", side_effect=fake_getaddrinfo):
return await SecurityUtils.is_safe_url_async(
"http://internal.example.com/secret.png",
{"example.com"},
block_private=True,
)
assert not asyncio.run(run())
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_async_hits_dns_cache() -> None:
"""
异步与同步版本共享 DNS TTL 缓存:同步预热后,异步版本不应再发起 DNS 查询。
"""
import asyncio
# 先用同步路径预热缓存
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("93.184.216.34", 0),
)
],
):
assert SecurityUtils.is_safe_url("https://assets.example.com/poster.jpg", {"example.com"}, block_private=True)
async def run() -> bool:
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
loop = asyncio.get_running_loop()
with patch.object(
loop,
"getaddrinfo",
side_effect=AssertionError("缓存命中后不应再次发起 DNS 查询"),
):
return await SecurityUtils.is_safe_url_async(
"https://assets.example.com/poster.jpg",
{"example.com"},
block_private=True,
)
assert asyncio.run(run())
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_async_allows_public_dns_result() -> None:
"""
异步版本对全公网解析结果且命中 allowlist 时放行。
"""
import asyncio
async def fake_getaddrinfo(host, *_args, **_kwargs):
"""
返回预设 DNS 地址,隔离真实域名解析并记录调用。
"""
assert host == "assets.example.com"
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("93.184.216.34", 0),
)
]
async def run() -> bool:
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
loop = asyncio.get_running_loop()
with patch.object(loop, "getaddrinfo", side_effect=fake_getaddrinfo):
return await SecurityUtils.is_safe_url_async(
"https://assets.example.com/poster.jpg",
{"example.com"},
block_private=True,
)
assert asyncio.run(run())
@pytest.mark.usefixtures("_isolate_dns_state")
def test_dns_resolution_failure_populates_negative_cache() -> None:
"""
DNS 解析失败应回填负向缓存,避免短期内对同一目标反复触发 `getaddrinfo`。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
side_effect=socket.gaierror,
) as mock_resolve:
assert not SecurityUtils.is_safe_url(
"https://assets.example.com/poster.jpg", {"example.com"}, block_private=True
)
assert mock_resolve.call_count == 1
assert "assets.example.com" in _dns_negative_cache
assert not SecurityUtils.is_safe_url(
"https://assets.example.com/another.jpg", {"example.com"}, block_private=True
)
assert mock_resolve.call_count == 1, "命中负向缓存后不应再次调用 getaddrinfo"
@pytest.mark.usefixtures("_isolate_dns_state")
def test_literal_ip_skips_dns_cache() -> None:
"""
URL 中的字面量 IP 走快路径,不应进入 DNS 缓存或触发 `getaddrinfo`。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
side_effect=AssertionError("字面量 IP 不应触发 getaddrinfo"),
):
assert not SecurityUtils.is_safe_url(
"http://10.0.0.5:8080/secret.png", {"http://10.0.0.5:8080"}, block_private=True
)
assert "10.0.0.5" not in _dns_positive_cache
assert "10.0.0.5" not in _dns_negative_cache
@pytest.mark.usefixtures("_isolate_dns_state")
def test_literal_ipv6_in_brackets_is_recognized() -> None:
"""
`urlparse` 已为 IPv6 字面量脱壳,`_literal_ip` 兼容直接传入带方括号的形式。
"""
assert str(SecurityUtils._literal_ip("[::1]")) == "::1"
assert str(SecurityUtils._literal_ip("::1")) == "::1"
assert SecurityUtils._literal_ip("not-an-ip") is None
@pytest.mark.usefixtures("_isolate_dns_state")
def test_is_safe_url_async_dedupes_concurrent_inflight_queries() -> None:
"""
同 hostname 的并发未命中请求应通过 in-flight 锁去重,只触发一次 DNS 查询。
"""
import asyncio
call_count = 0
async def run() -> None:
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
nonlocal call_count
loop = asyncio.get_running_loop()
release = asyncio.Event()
async def slow_getaddrinfo(host, *_args, **_kwargs):
"""
等待测试信号后返回 DNS 地址,用于观察并发合并和取消行为。
"""
nonlocal call_count
call_count += 1
await release.wait()
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("93.184.216.34", 0),
)
]
with patch.object(loop, "getaddrinfo", side_effect=slow_getaddrinfo):
tasks = [
asyncio.create_task(
SecurityUtils.is_safe_url_async(
"https://assets.example.com/poster.jpg",
{"example.com"},
block_private=True,
)
)
for _ in range(5)
]
# 让所有任务都进入 in-flight 等待状态
await asyncio.sleep(0)
await asyncio.sleep(0)
release.set()
results = await asyncio.gather(*tasks)
assert all(results)
assert call_count == 1, "并发未命中应去重为单次 DNS 查询"
asyncio.run(run())
@pytest.mark.usefixtures("_isolate_dns_state")
def test_sync_cache_access_is_thread_safe() -> None:
"""
同步路径下并发线程访问 DNS 缓存不应触发异常或拿到不一致结果。
TTLCache 自身非线程安全,依赖模块级 `_dns_cache_lock` 串行化读写。
"""
import threading
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("93.184.216.34", 0),
)
],
):
results: list = []
errors: list = []
def worker() -> None:
"""
在线程中执行本次地址解析并记录结果。
"""
try:
for _ in range(50):
results.append(
SecurityUtils.is_safe_url(
"https://assets.example.com/poster.jpg",
{"example.com"},
block_private=True,
)
)
# 捕获解析线程传播的所有异常,验证取消路径不向调用方泄漏失败。
except Exception as exc: # noqa: BLE001
errors.append(exc)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == []
assert all(results)
assert len(results) == 8 * 50
@pytest.mark.usefixtures("_isolate_dns_state")
def test_async_dns_resolution_failure_releases_inflight_lock() -> None:
"""
DNS 解析失败后 in-flight 锁字典中必须被清理,避免每个解析失败的 hostname
都在 `_dns_inflight_locks` 里残留一把 `asyncio.Lock`。
"""
import asyncio
async def fail_getaddrinfo(*_args, **_kwargs):
"""
模拟 DNS 解析失败,验证异常路径释放资源。
"""
raise socket.gaierror()
async def run() -> None:
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
loop = asyncio.get_running_loop()
with patch.object(loop, "getaddrinfo", side_effect=fail_getaddrinfo):
result = await SecurityUtils._hostname_addresses_async("bad-host.example")
assert result is None
asyncio.run(run())
assert not any((key[1] == "bad-host.example" for key in _dns_inflight_locks)), (
"解析失败路径必须释放 in-flight 锁字典条目"
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_async_dns_resolution_success_releases_inflight_lock() -> None:
"""
正常解析完成后 in-flight 锁字典也必须被清理,避免 hostname 累积。
"""
import asyncio
async def fake_getaddrinfo(*_args, **_kwargs):
"""
返回预设 DNS 地址,隔离真实域名解析并记录调用。
"""
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("93.184.216.34", 0),
)
]
async def run() -> None:
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
loop = asyncio.get_running_loop()
with patch.object(loop, "getaddrinfo", side_effect=fake_getaddrinfo):
result = await SecurityUtils._hostname_addresses_async("ok-host.example")
assert result is not None
asyncio.run(run())
assert not any((key[1] == "ok-host.example" for key in _dns_inflight_locks)), (
"正常解析路径必须释放 in-flight 锁字典条目"
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_async_dns_concurrent_waiters_release_inflight_lock() -> None:
"""
并发未命中场景下,所有等待者完成后 in-flight 锁字典也必须被清理,
覆盖"等到锁但缓存已被前一个协程回填"的二次返回路径。
"""
import asyncio
async def run() -> None:
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
loop = asyncio.get_running_loop()
release = asyncio.Event()
async def slow_getaddrinfo(*_args, **_kwargs):
"""
等待测试信号后返回 DNS 地址,用于观察并发合并和取消行为。
"""
await release.wait()
return [
(
socket.AF_INET,
socket.SOCK_STREAM,
0,
"",
("93.184.216.34", 0),
)
]
with patch.object(loop, "getaddrinfo", side_effect=slow_getaddrinfo):
tasks = [
asyncio.create_task(SecurityUtils._hostname_addresses_async("multi-host.example")) for _ in range(5)
]
await asyncio.sleep(0)
await asyncio.sleep(0)
release.set()
await asyncio.gather(*tasks)
asyncio.run(run())
assert not any((key[1] == "multi-host.example" for key in _dns_inflight_locks)), (
"并发等待者全部退出后必须释放 in-flight 锁字典条目"
)
@pytest.mark.usefixtures("_isolate_dns_state")
def test_domain_not_allowed_returns_reason_and_no_host() -> None:
"""
协议或 allowlist 校验未通过时,诊断返回 DOMAIN_NOT_ALLOWED,
且不暴露 host/ips 字段。
"""
diag = SecurityUtils.evaluate_url_safety(
"https://attacker.example.com/x.jpg",
{"image.tmdb.org"},
)
assert isinstance(diag, UrlSafetyDiagnosis)
assert not diag.allowed
assert diag.reason is UrlSafetyReason.DOMAIN_NOT_ALLOWED
assert diag.host is None
assert diag.ips == []
assert diag.matched_private_ranges == []
@pytest.mark.usefixtures("_isolate_dns_state")
def test_allowed_without_block_private_skips_dns() -> None:
"""
未启用 block_private 时直接放行,不发起 DNS 解析,ips 保持为空。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
side_effect=AssertionError("不应触发 DNS 解析"),
):
diag = SecurityUtils.evaluate_url_safety(
"https://image.tmdb.org/t/p/w500/x.jpg",
{"image.tmdb.org"},
)
assert diag.allowed
assert diag.reason is UrlSafetyReason.ALLOWED
assert diag.host == "image.tmdb.org"
assert diag.ips == []
@pytest.mark.usefixtures("_isolate_dns_state")
def test_dns_resolution_failed_carries_host_without_ips() -> None:
"""
`block_private=True` 下 DNS 抛错时返回 DNS_RESOLUTION_FAILED,
附带 host 便于排查但不携带 ips。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
side_effect=socket.gaierror,
):
diag = SecurityUtils.evaluate_url_safety(
"https://image.tmdb.org/t/p/w500/x.jpg",
{"image.tmdb.org"},
block_private=True,
)
assert not diag.allowed
assert diag.reason is UrlSafetyReason.DNS_RESOLUTION_FAILED
assert diag.host == "image.tmdb.org"
assert diag.ips == []
@pytest.mark.usefixtures("_isolate_dns_state")
def test_non_global_dns_result_lists_resolved_ips() -> None:
"""
命中 allowlist 但 DNS 解析到非公网且未配置允许网段时,诊断标记
NON_GLOBAL_DNS_RESULT 并把解析到的 IP 列出来,供日志附带 fake-ip 提示。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("198.18.16.96", 0)),
],
):
diag = SecurityUtils.evaluate_url_safety(
"https://image.tmdb.org/t/p/w500/x.jpg",
{"image.tmdb.org"},
block_private=True,
)
assert not diag.allowed
assert diag.reason is UrlSafetyReason.NON_GLOBAL_DNS_RESULT
assert diag.host == "image.tmdb.org"
assert diag.ips == ["198.18.16.96"]
assert diag.matched_private_ranges == []
@pytest.mark.usefixtures("_isolate_dns_state")
def test_mixed_private_and_public_with_ranges_reports_mixed_reason() -> None:
"""
配置了 allowed_private_ranges 但解析结果存在公网或不在允许网段内的私网
地址时,诊断必须标记 MIXED_OR_DISALLOWED_PRIVATE_RESULT,避免与"未配置
允许网段"场景混淆。
"""
with patch(
"app.adapters.network.resolver.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)),
],
):
diag = SecurityUtils.evaluate_url_safety(
"https://image.tmdb.org/t/p/w500/x.jpg",
{"image.tmdb.org"},
block_private=True,
allowed_private_ranges=["198.18.0.0/15"],
)
assert not diag.allowed
assert diag.reason is UrlSafetyReason.MIXED_OR_DISALLOWED_PRIVATE_RESULT
assert diag.ips == ["198.18.16.96", "10.0.0.8"]
@pytest.mark.usefixtures("_isolate_dns_state")
def test_allowed_via_configured_private_range_reports_matched_networks() -> None:
"""
通过 allowed_private_ranges 放行时返回 ALLOWED,同时把命中的 IP 与
网段填入诊断对象,便于排查日志确认放行依据。
"""
with patch(
"app.adapters.network.resolver.socket.getaddrinfo",
return_value=[
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("198.18.16.96", 0)),
],
):
diag = SecurityUtils.evaluate_url_safety(
"https://image.tmdb.org/t/p/w500/x.jpg",
{"image.tmdb.org"},
block_private=True,
allowed_private_ranges=["198.18.0.0/15"],
)
assert diag.allowed
assert diag.reason is UrlSafetyReason.ALLOWED
assert diag.ips == ["198.18.16.96"]
assert diag.matched_private_ranges == ["198.18.0.0/15"]
@pytest.mark.usefixtures("_isolate_dns_state")
def test_async_evaluation_returns_same_diagnosis() -> None:
"""
异步版本走事件循环线程池但应保持与同步版本一致的诊断结果。
"""
import asyncio
async def fake_getaddrinfo(host, *_args, **_kwargs):
"""
返回预设 DNS 地址,隔离真实域名解析并记录调用。
"""
return [
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("198.18.16.96", 0)),
]
async def run():
"""
在独立事件循环中执行本用例的异步地址校验,并验证缓存或锁状态。
"""
with patch.object(
asyncio.get_running_loop(),
"getaddrinfo",
side_effect=fake_getaddrinfo,
create=True,
):
return await SecurityUtils.evaluate_url_safety_async(
"https://image.tmdb.org/x.jpg",
{"image.tmdb.org"},
block_private=True,
)
diag = asyncio.run(run())
assert not diag.allowed
assert diag.reason is UrlSafetyReason.NON_GLOBAL_DNS_RESULT
assert diag.ips == ["198.18.16.96"]