diff --git a/app/api/endpoints/system.py b/app/api/endpoints/system.py index e782eac23..0eed1cac5 100644 --- a/app/api/endpoints/system.py +++ b/app/api/endpoints/system.py @@ -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, diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 40364cd8c..12f83d595 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -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) ### 端点 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index ad49ac407..c36258c40 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/test_system_image_proxy.py b/tests/test_system_image_proxy.py index 5835ae146..9f2d93f7d 100644 --- a/tests/test_system_image_proxy.py +++ b/tests/test_system_image_proxy.py @@ -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", }