Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lightllm/server/multimodal_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
112 changes: 109 additions & 3 deletions lightllm/utils/multimodal_utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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=...
Expand Down Expand Up @@ -76,28 +161,49 @@ 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:
return cached

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()
Expand Down
202 changes: 202 additions & 0 deletions unit_tests/utils/test_multimodal_utils.py
Original file line number Diff line number Diff line change
@@ -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)