mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-02 21:34:32 +08:00
fix(image): allow configured site domains in image proxy
This commit is contained in:
@@ -44,6 +44,7 @@ from app.application.network import get_configured_network_test_service
|
||||
from app.application.rules import RuleHelper
|
||||
from app.application.scheduling import get_scheduler
|
||||
from app.application.security.url import SecurityUtils
|
||||
from app.application.site.query import get_configured_site_query_service
|
||||
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
|
||||
from app.application.system import LogFileData, LogNotFoundError
|
||||
from app.chain.media import MediaChain
|
||||
@@ -107,10 +108,23 @@ _PUBLIC_SYSTEM_CONFIG_KEYS = {
|
||||
_PUBLIC_SETTINGS_KEYS = {"PLUGIN_MARKET"}
|
||||
|
||||
|
||||
def _get_image_proxy_allowed_domains() -> set[str]:
|
||||
"""返回图片代理允许的域名,并纳入已启用的 Bangumi 图片代理主机。"""
|
||||
async def _get_image_proxy_allowed_domains() -> set[str]:
|
||||
"""合并静态白名单、已配置站点和 Bangumi 图片主机,仍保留 DNS 安全校验。"""
|
||||
runtime_settings = get_runtime_settings()
|
||||
allowed_domains = set(runtime_settings.get("SECURITY_IMAGE_DOMAINS", []))
|
||||
# 停用站点仍需展示图标;每次读取配置,新增、改址和删除立即生效。
|
||||
for site in await get_configured_site_query_service().list():
|
||||
for address in (site.domain, site.url):
|
||||
if not address:
|
||||
continue
|
||||
candidate = address if "://" in address else f"https://{address}"
|
||||
try:
|
||||
parsed = urlsplit(candidate)
|
||||
except ValueError:
|
||||
continue
|
||||
if parsed.scheme in {"http", "https"} and parsed.netloc and not parsed.username:
|
||||
# 保留协议,避免白名单解析器把 host:port 的主机误认成 scheme。
|
||||
allowed_domains.add(f"{parsed.scheme}://{parsed.netloc.lower()}")
|
||||
if not runtime_settings.get("BANGUMI_PROXY_ENABLE"):
|
||||
return allowed_domains
|
||||
|
||||
@@ -211,7 +225,7 @@ async def fetch_image(
|
||||
return None
|
||||
|
||||
if allowed_domains is None:
|
||||
allowed_domains = _get_image_proxy_allowed_domains()
|
||||
allowed_domains = await _get_image_proxy_allowed_domains()
|
||||
|
||||
fetch_url = SecurityUtils.strip_url_signature(url)
|
||||
# 验证URL安全性
|
||||
@@ -283,7 +297,7 @@ async def proxy_img(
|
||||
"""
|
||||
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
|
||||
"""
|
||||
allowed_domains = _get_image_proxy_allowed_domains()
|
||||
allowed_domains = await _get_image_proxy_allowed_domains()
|
||||
cookies = MediaServerChain().get_image_cookies(server=None, image_url=imgurl) if use_cookies else None
|
||||
return await fetch_image(
|
||||
url=imgurl,
|
||||
|
||||
@@ -21,6 +21,11 @@ MCP 使用系统配置中的 `API_TOKEN` 作为认证密钥,文档中的 API K
|
||||
|
||||
`POST /api/v1/history/transfer/{history_id}/discard-corrupt` 属于需要管理权限的整理恢复 REST 接口,不向 Agent gateway 暴露。成功响应的 `data.history_id` 为保留的整理历史 ID;任务已清理时同样返回该结构,历史不存在时返回业务失败。
|
||||
|
||||
图片 REST 接口 `/api/v1/system/img/{proxy}` 和 `/api/v1/system/cache/image` 自动将已配置
|
||||
站点的主域名及访问地址加入图片域名白名单(含停用站点,便于展示图标),无需手动配置
|
||||
`SECURITY_IMAGE_DOMAINS`。站点新增、改址或删除后按最新配置生效;仍执行 DNS/私网校验,
|
||||
Fake-IP 等非公网解析地址需要通过 `IMAGE_PROXY_ALLOWED_PRIVATE_RANGES` 明确允许。
|
||||
|
||||
## 2. 标准 MCP 协议 (JSON-RPC 2.0)
|
||||
|
||||
### 端点
|
||||
|
||||
@@ -1097,8 +1097,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8928,
|
||||
"edge_sha256": "64fc5d25c243f10ff6c6d4f5e93ae685e7c5202d4f6805dfde85c14fcb3c74f7",
|
||||
"edge_count": 8929,
|
||||
"edge_sha256": "568144c98e697e2e7f27f672c1a65afb3a947631957df79786bf700a4abdeeae",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -3058,6 +3058,7 @@
|
||||
"app.api.endpoints.system -> app.application.security",
|
||||
"app.api.endpoints.system -> app.application.security.url",
|
||||
"app.api.endpoints.system -> app.application.site",
|
||||
"app.api.endpoints.system -> app.application.site.query",
|
||||
"app.api.endpoints.system -> app.application.system",
|
||||
"app.api.endpoints.system -> app.chain",
|
||||
"app.api.endpoints.system -> app.chain.media",
|
||||
|
||||
@@ -18,6 +18,78 @@ from app.application.image import (
|
||||
configure_image_ports,
|
||||
reset_image_ports,
|
||||
)
|
||||
from app.schemas.site import Site
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configured_image_sites(monkeypatch: pytest.MonkeyPatch) -> AsyncMock:
|
||||
"""隔离图片白名单的站点查询,避免其他用例的数据库状态影响结果。"""
|
||||
query = AsyncMock()
|
||||
query.list.return_value = []
|
||||
monkeypatch.setattr(system_endpoint, "get_configured_site_query_service", lambda: query)
|
||||
return query
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["proxy", "cache"])
|
||||
@pytest.mark.parametrize(
|
||||
("url", "address", "ranges", "expected"),
|
||||
[
|
||||
("https://www.qingwapt.com/favicon.svg", "8.8.8.8", [], True),
|
||||
("https://www.qingwapt.com/favicon.svg", "198.18.0.1", ["198.18.0.0/15"], True),
|
||||
("https://www.qingwapt.com/favicon.svg", "198.18.0.1", [], False),
|
||||
("https://www.qingwapt.com/favicon.svg", "127.0.0.1", ["198.18.0.0/15"], False),
|
||||
("https://www.qingwapt.com.evil.example/favicon.svg", "8.8.8.8", [], False),
|
||||
("https://evilqingwapt.com/favicon.svg", "8.8.8.8", [], False),
|
||||
("https://qingwapt.com@evil.example/favicon.svg", "8.8.8.8", [], False),
|
||||
("https://mirror.example:8443/favicon.svg", "8.8.8.8", [], True),
|
||||
("https://mirror.example:9443/favicon.svg", "8.8.8.8", [], False),
|
||||
],
|
||||
)
|
||||
def test_site_image_endpoints_preserve_dns_safety(
|
||||
configured_image_sites: AsyncMock,
|
||||
endpoint: str,
|
||||
url: str,
|
||||
address: str,
|
||||
ranges: list[str],
|
||||
expected: bool,
|
||||
) -> None:
|
||||
"""站点图片自动放行,两个接口均保留私网、相似域名及端口边界。"""
|
||||
configured_image_sites.list.return_value = [
|
||||
Site(domain="qingwapt.com", url="https://mirror.example:8443/", is_active=False),
|
||||
]
|
||||
image_helper = Mock()
|
||||
image_helper.async_fetch_image_with_mime_type = AsyncMock(return_value=(b"image", "image/png"))
|
||||
with patch.object(system_endpoint, "get_runtime_settings", return_value={
|
||||
"IMAGE_PROXY_ALLOWED_PRIVATE_RANGES": ranges,
|
||||
}), patch.object(system_endpoint, "_get_image_proxy_trusted_hosts", return_value=set()), patch.object(
|
||||
system_endpoint.SecurityUtils, "_hostname_addresses_async",
|
||||
new=AsyncMock(return_value=[ipaddress.ip_address(address)]),
|
||||
), patch.object(system_endpoint, "ImageHelper", return_value=image_helper), patch(
|
||||
"app.application.security.url._emit_image_proxy_block_warning", new=AsyncMock(),
|
||||
):
|
||||
if endpoint == "proxy":
|
||||
response = asyncio.run(system_endpoint.proxy_img(imgurl=url))
|
||||
else:
|
||||
response = asyncio.run(system_endpoint.cache_img(url=url))
|
||||
assert (response is not None) is expected
|
||||
assert image_helper.async_fetch_image_with_mime_type.await_count == int(expected)
|
||||
|
||||
|
||||
def test_site_image_allowlist_tracks_configuration(configured_image_sites: AsyncMock) -> None:
|
||||
"""站点改址、删除不残留白名单,非法地址不能打断其他图片请求。"""
|
||||
configured_image_sites.list.side_effect = [
|
||||
[Site(domain="qingwapt.com", url="https://old.example/")],
|
||||
[Site(domain="qingwapt.com", url="https://new.example/")],
|
||||
[Site(url=url) for url in (None, "https://[", "file://host/path", "https://user@host/")],
|
||||
]
|
||||
with patch.object(system_endpoint, "get_runtime_settings", return_value={}):
|
||||
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == {
|
||||
"https://qingwapt.com", "https://old.example",
|
||||
}
|
||||
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == {
|
||||
"https://qingwapt.com", "https://new.example",
|
||||
}
|
||||
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == set()
|
||||
|
||||
|
||||
class _FakeImageTransport:
|
||||
@@ -82,6 +154,7 @@ def restore_image_ports() -> Iterator[None]:
|
||||
|
||||
|
||||
def _image_bytes(image_format: str, trailing: bytes = b"") -> bytes:
|
||||
"""生成离线图片载荷,用于验证内容识别和响应头。"""
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (2, 2), color=(32, 96, 160)).save(buffer, format=image_format)
|
||||
return buffer.getvalue() + trailing
|
||||
@@ -98,7 +171,7 @@ def test_bangumi_image_proxy_domain_is_added_to_allowlist() -> None:
|
||||
"SECURITY_IMAGE_DOMAINS": ["lain.bgm.tv"],
|
||||
},
|
||||
):
|
||||
assert system_endpoint._get_image_proxy_allowed_domains() == {
|
||||
assert asyncio.run(system_endpoint._get_image_proxy_allowed_domains()) == {
|
||||
"lain.bgm.tv",
|
||||
"image-proxy.example",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user