diff --git a/lightllm/server/multimodal_params.py b/lightllm/server/multimodal_params.py index fc136ab0ce..b42681d5ab 100644 --- a/lightllm/server/multimodal_params.py +++ b/lightllm/server/multimodal_params.py @@ -76,6 +76,9 @@ async def preload(self, request: Request): # without the noisy 'Failed to read audio' error logs. raise e except Exception as e: + if self._type == "url": + logger.exception("Failed to read audio resource") + raise ValueError("Failed to read audio resource") from None raise ValueError(f"Failed to read audio type={self._type}, data[:100]={self._data[:100]}: {e}!") def read(self): @@ -203,6 +206,9 @@ async def preload(self, request: Request): # without the noisy 'Failed to read image' error logs. raise e except Exception as e: + if self._type == "url": + logger.exception("Failed to read image resource") + raise ValueError("Failed to read image resource") from None raise ValueError(f"Failed to read image type={self._type}, data[:100]={self._data[:100]}: {e}!") def read(self): diff --git a/lightllm/utils/multimodal_utils.py b/lightllm/utils/multimodal_utils.py index 1dc69e44a3..636deaa5af 100644 --- a/lightllm/utils/multimodal_utils.py +++ b/lightllm/utils/multimodal_utils.py @@ -1,6 +1,12 @@ +import asyncio +import base64 import inspect +import ipaddress +import os +import socket import time -import base64 +from urllib.parse import urlsplit + import httpx from PIL import Image from io import BytesIO @@ -47,6 +53,85 @@ def put(self, url: str, proxy: Optional[str], content: bytes) -> None: URL_RESOURCE_POOL = UrlResourcePool(maxsize=get_lightllm_url_pool_maxsize()) +def _validate_resolved_host(hostname: str, port: Optional[int]) -> str: + """Resolve a host, reject non-public results, and return a pinned address.""" + try: + addresses = list( + dict.fromkeys(result[4][0] for result in socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)) + ) + except (OSError, UnicodeError) as exc: + raise ValueError("resource URL host could not be resolved") from exc + + if not addresses: + raise ValueError("resource URL host must resolve to a public address") + try: + has_non_public_address = any(not ipaddress.ip_address(address).is_global for address in addresses) + except ValueError as exc: + raise ValueError("resource URL host must resolve to a public address") from exc + if has_non_public_address: + raise ValueError("resource URL host must resolve to a public address") + return next(iter(addresses)) + + +def _normalize_resource_hostname(hostname: str) -> str: + try: + return str(ipaddress.ip_address(hostname)) + except ValueError: + return hostname.rstrip(".").encode("idna").decode("ascii").lower() + + +def _resource_host_is_allowed(hostname: str) -> bool: + configured_hosts = os.getenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST") + if configured_hosts is None: + return True + + try: + allowed_hosts = { + _normalize_resource_hostname(allowed_host.strip()) + for allowed_host in configured_hosts.split(",") + if allowed_host.strip() + } + return _normalize_resource_hostname(hostname) in allowed_hosts + except UnicodeError as exc: + raise ValueError("configured multimodal URL allowlist is invalid") from exc + + +async def validate_resource_url(url: str, timeout: Optional[float] = 5.0) -> Tuple[str, str, Optional[int], str]: + """Validate a URL and return its scheme, host, port, and pinned public IP. + + LIGHTLLM_MULTIMODAL_URL_ALLOWLIST can contain comma-separated hostnames. + """ + try: + parsed = urlsplit(url) + hostname = parsed.hostname + port = parsed.port + except (TypeError, ValueError) as exc: + raise ValueError("resource URL is invalid") from exc + + if parsed.scheme not in {"http", "https"} or not hostname: + raise ValueError("resource URL must use http or https") + if parsed.username is not None or parsed.password is not None: + raise ValueError("resource URL must not include credentials") + if not _resource_host_is_allowed(hostname): + raise ValueError("resource URL host is not in the configured allowlist") + + try: + address = ipaddress.ip_address(hostname) + except ValueError: + try: + resolution = asyncio.to_thread(_validate_resolved_host, hostname, port) + resolved_address = ( + await asyncio.wait_for(resolution, timeout=timeout) if timeout is not None else await resolution + ) + except asyncio.TimeoutError as exc: + raise ValueError("resource URL host could not be resolved") from exc + else: + if not address.is_global: + raise ValueError("resource URL host must resolve to a public address") + resolved_address = str(address) + return parsed.scheme, hostname, port, resolved_address + + def _httpx_async_client_proxy_kwargs(proxy) -> dict: """ httpx 0.28+ 使用 AsyncClient(proxy=...);更早版本使用 proxies=... @@ -76,10 +161,12 @@ def image2base64(img_str: str): def _get_xhttp_client(proxy=None): kvargs = _httpx_async_client_proxy_kwargs(proxy) kvargs["limits"] = httpx.Limits(max_connections=10000, max_keepalive_connections=20) + kvargs["follow_redirects"] = False return httpx.AsyncClient(**kvargs) async def fetch_resource(url, request: Request, timeout, proxy=None): + scheme, hostname, port, address = await validate_resource_url(url, timeout=timeout) logger.info(f"Begin to download resource from url: {url}") cached = URL_RESOURCE_POOL.get(url, proxy) if cached is not None: @@ -87,17 +174,36 @@ async def fetch_resource(url, request: Request, timeout, proxy=None): start_time = time.time() client = _get_xhttp_client(proxy) - async with client.stream("GET", url, timeout=timeout) as response: + parsed_url = urlsplit(url) + pinned_host = f"[{address}]" if ":" in address else address + if port is not None: + pinned_host = f"{pinned_host}:{port}" + pinned_url = parsed_url._replace(netloc=pinned_host).geturl() + original_host = hostname.encode("idna").decode("ascii") + host_header = f"[{original_host}]" if ":" in original_host else original_host + default_port = 443 if scheme == "https" else 80 + if port is not None and port != default_port: + host_header = f"{host_header}:{port}" + http_request = client.build_request( + "GET", + pinned_url, + headers={"Host": host_header}, + timeout=timeout, + extensions={"sni_hostname": original_host}, + ) + response = await client.send(http_request, stream=True) + try: response.raise_for_status() ans_bytes = [] async for chunk in response.aiter_bytes(chunk_size=1024 * 1024): if request is not None and await request.is_disconnected(): - await response.aclose() raise ClientDisconnected(reason=f"client disconnected during download of {url}") ans_bytes.append(chunk) # 接收的数据不能大于128M if len(ans_bytes) > 128: raise Exception(f"url {url} recv data is too big") + finally: + await response.aclose() content = b"".join(ans_bytes) end_time = time.time() diff --git a/unit_tests/utils/test_multimodal_utils.py b/unit_tests/utils/test_multimodal_utils.py new file mode 100644 index 0000000000..4d21c30d43 --- /dev/null +++ b/unit_tests/utils/test_multimodal_utils.py @@ -0,0 +1,202 @@ +import asyncio +import importlib +import socket +import sys +import types +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from lightllm.utils import multimodal_utils + + +def _load_multimodal_params(monkeypatch): + server_path = Path(multimodal_utils.__file__).resolve().parents[1] / "server" + server_package = types.ModuleType("lightllm.server") + server_package.__path__ = [str(server_path)] + embed_cache_package = types.ModuleType("lightllm.server.embed_cache") + embed_cache_package.__path__ = [str(server_path / "embed_cache")] + monkeypatch.setitem(sys.modules, "lightllm.server", server_package) + monkeypatch.setitem(sys.modules, "lightllm.server.embed_cache", embed_cache_package) + return importlib.import_module("lightllm.server.multimodal_params") + + +def _run_validation(url: str) -> None: + asyncio.run(multimodal_utils.validate_resource_url(url)) + + +def _resolved_address(address: str): + return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (address, 80))] + + +@pytest.mark.parametrize( + "url", + [ + "http://127.0.0.1/image.png", + "http://10.0.0.1/image.png", + "http://169.254.169.254/latest/meta-data", + "http://[::1]/image.png", + "http://[fe80::1]/image.png", + "http://[fc00::1]/image.png", + "file:///etc/passwd", + "ftp://8.8.8.8/image.png", + ], +) +def test_validate_resource_url_rejects_unsafe_urls(url): + with pytest.raises(ValueError): + _run_validation(url) + + +def test_validate_resource_url_rejects_private_dns_result(monkeypatch): + monkeypatch.delenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST", raising=False) + monkeypatch.setattr(multimodal_utils.socket, "getaddrinfo", lambda *args, **kwargs: _resolved_address("10.0.0.8")) + + with pytest.raises(ValueError, match="public address") as error: + _run_validation("https://internal.example/image.png") + + assert "internal.example" not in str(error.value) + + +def test_validate_resource_url_allows_public_dns_result(monkeypatch): + monkeypatch.delenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST", raising=False) + monkeypatch.setattr(multimodal_utils.socket, "getaddrinfo", lambda *args, **kwargs: _resolved_address("8.8.8.8")) + + result = asyncio.run(multimodal_utils.validate_resource_url("https://public.example/image.png")) + + assert result == ("https", "public.example", None, "8.8.8.8") + + +def test_validate_resource_url_enforces_configured_host_allowlist(monkeypatch): + monkeypatch.setenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST", "images.example,media.example") + monkeypatch.setattr( + multimodal_utils.socket, + "getaddrinfo", + lambda *_args, **_kwargs: pytest.fail("disallowed host was resolved"), + ) + + with pytest.raises(ValueError, match="configured allowlist"): + _run_validation("https://other.example/image.png") + + +def test_validate_resource_url_allows_configured_hostname(monkeypatch): + monkeypatch.setenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST", "PUBLIC.EXAMPLE.,media.example") + monkeypatch.setattr(multimodal_utils.socket, "getaddrinfo", lambda *args, **kwargs: _resolved_address("8.8.8.8")) + + result = asyncio.run(multimodal_utils.validate_resource_url("https://public.example/image.png")) + + assert result == ("https", "public.example", None, "8.8.8.8") + + +def test_configured_allowlist_does_not_allow_private_dns_result(monkeypatch): + monkeypatch.setenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST", "internal.example") + monkeypatch.setattr(multimodal_utils.socket, "getaddrinfo", lambda *args, **kwargs: _resolved_address("10.0.0.8")) + + with pytest.raises(ValueError, match="public address"): + _run_validation("https://internal.example/image.png") + + +def test_validate_resource_url_rejects_mixed_dns_results(monkeypatch): + monkeypatch.delenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST", raising=False) + monkeypatch.setattr( + multimodal_utils.socket, + "getaddrinfo", + lambda *args, **kwargs: _resolved_address("8.8.8.8") + _resolved_address("192.168.1.1"), + ) + + with pytest.raises(ValueError, match="public address"): + _run_validation("https://mixed.example/image.png") + + +def test_fetch_resource_validates_before_opening_http_client(monkeypatch): + monkeypatch.setattr( + multimodal_utils, + "_get_xhttp_client", + lambda *_args, **_kwargs: pytest.fail("unsafe URL reached the HTTP client"), + ) + + with pytest.raises(ValueError): + asyncio.run(multimodal_utils.fetch_resource("http://127.0.0.1/image.png", None, timeout=1)) + + +def test_http_client_does_not_follow_redirects(monkeypatch): + monkeypatch.setattr(multimodal_utils.httpx, "AsyncClient", lambda **kwargs: kwargs) + multimodal_utils._get_xhttp_client.cache_clear() + try: + client_options = multimodal_utils._get_xhttp_client() + finally: + multimodal_utils._get_xhttp_client.cache_clear() + + assert client_options["follow_redirects"] is False + + +def test_fetch_resource_pins_dns_result_and_preserves_host(monkeypatch): + monkeypatch.delenv("LIGHTLLM_MULTIMODAL_URL_ALLOWLIST", raising=False) + monkeypatch.setattr(multimodal_utils.socket, "getaddrinfo", lambda *args, **kwargs: _resolved_address("8.8.8.8")) + monkeypatch.setattr(multimodal_utils.URL_RESOURCE_POOL, "get", lambda *_args: None) + monkeypatch.setattr(multimodal_utils.URL_RESOURCE_POOL, "put", lambda *_args: None) + + class FakeResponse: + def raise_for_status(self): + pass + + async def aiter_bytes(self, chunk_size): + yield b"image bytes" + + async def aclose(self): + pass + + class FakeClient: + def build_request(self, method, url, **kwargs): + self.request = type("Request", (), {"method": method, "url": url, **kwargs})() + return self.request + + async def send(self, request, stream): + self.sent_request = request + return FakeResponse() + + client = FakeClient() + monkeypatch.setattr(multimodal_utils, "_get_xhttp_client", lambda *_args: client) + + content = asyncio.run( + multimodal_utils.fetch_resource("https://public.example/image.png?size=small", None, timeout=1) + ) + + assert content == b"image bytes" + assert client.sent_request.url == "https://8.8.8.8/image.png?size=small" + assert client.sent_request.headers["Host"] == "public.example" + assert client.sent_request.extensions["sni_hostname"] == "public.example" + + +@pytest.mark.parametrize( + ("item_type", "expected_message"), + [ + ("image", "Failed to read image resource"), + ("audio", "Failed to read audio resource"), + ], +) +def test_remote_resource_errors_do_not_expose_fetch_details(monkeypatch, item_type, expected_message): + multimodal_params = _load_multimodal_params(monkeypatch) + + async def fail_fetch(*args, **kwargs): + raise RuntimeError("Client error '404 Not Found' for http://127.0.0.1/private.png") + + monkeypatch.setattr( + multimodal_params, + "fetch_resource", + fail_fetch, + ) + monkeypatch.setattr( + multimodal_params, + "get_env_start_args", + lambda: SimpleNamespace(max_image_pixels=1_000_000, disable_image_resize=False), + ) + + item_class = multimodal_params.ImageItem if item_type == "image" else multimodal_params.AudioItem + item = item_class(type="url", data="http://127.0.0.1/private.png") + with pytest.raises(ValueError) as error: + asyncio.run(item.preload(None)) + + assert str(error.value) == expected_message + assert "127.0.0.1" not in str(error.value) + assert "404" not in str(error.value)