fix(image): allow configured site domains in image proxy

This commit is contained in:
jxxghp
2026-09-30 00:53:37 +08:00
parent c0464c6b0b
commit 8c46c9bee8
4 changed files with 100 additions and 7 deletions

View File

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

View File

@@ -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)
### 端点

View File

@@ -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",

View File

@@ -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",
}