From 309d9c3ea8083e73685562dbbc97c8b40ab6e0b1 Mon Sep 17 00:00:00 2001 From: Zhenghan Song Date: Mon, 17 Aug 2026 13:25:09 +0800 Subject: [PATCH] [Bugfix] Respect media redirect policy for image references (#6122) Signed-off-by: HaningZS Co-authored-by: Hongsheng Liu --- docs/serving/videos_api.md | 8 ++ .../openai_api/test_video_api_utils.py | 106 ++++++++++++++++++ .../openai_api/test_video_server.py | 43 ++++++- .../entrypoints/openai/video_api_utils.py | 15 ++- 4 files changed, 169 insertions(+), 3 deletions(-) diff --git a/docs/serving/videos_api.md b/docs/serving/videos_api.md index 6a94f9dab..5995e0911 100644 --- a/docs/serving/videos_api.md +++ b/docs/serving/videos_api.md @@ -167,6 +167,14 @@ transfer options such as `control_path`, `control_guidance`, `num_conditional_frames`, `show_control_condition`, and `show_input`; see the Cosmos3 recipe for complete examples. +HTTP redirects for `image_reference.image_url` follow vLLM's +`VLLM_MEDIA_URL_ALLOW_REDIRECTS` setting. Before starting the server, set it to +`1` (the default) to allow redirects or `0` to reject them. A redirect target +can differ from the original host, and vLLM-Omni does not yet fully implement +upstream vLLM's media URL allowlist protection. Deployments should therefore +treat remote media URLs as untrusted and choose this setting as part of their +URL access policy. + ### Speech-to-Video For models that support audio-driven generation (e.g., Wan2.2-S2V), pass both diff --git a/tests/entrypoints/openai_api/test_video_api_utils.py b/tests/entrypoints/openai_api/test_video_api_utils.py index 08d223c68..bb47356c6 100644 --- a/tests/entrypoints/openai_api/test_video_api_utils.py +++ b/tests/entrypoints/openai_api/test_video_api_utils.py @@ -2,16 +2,122 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Unit tests for OpenAI-compatible video API encoding helpers.""" +import base64 +from io import BytesIO + +import httpx import numpy as np import pytest import torch +from PIL import Image +from vllm import envs from vllm_omni.diffusion.postprocess import rife_interpolator from vllm_omni.entrypoints.openai import video_api_utils +from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +def _png_bytes() -> bytes: + buffer = BytesIO() + Image.new("RGB", (2, 1), color=(12, 34, 56)).save(buffer, format="PNG") + return buffer.getvalue() + + +def _install_http_transport(monkeypatch, handler, client_kwargs): + async_client = httpx.AsyncClient + + def _client_factory(*args, **kwargs): + client_kwargs.append(kwargs.copy()) + return async_client(*args, transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr(video_api_utils.httpx, "AsyncClient", _client_factory) + + +@pytest.mark.asyncio +async def test_decode_image_url_follows_redirects_when_allowed(monkeypatch): + requested_paths = [] + client_kwargs = [] + + def _handler(request): + requested_paths.append(request.url.path) + if request.url.path == "/redirect.png": + return httpx.Response(302, headers={"location": "/image.png"}) + return httpx.Response(200, content=_png_bytes(), headers={"content-type": "image/png"}) + + monkeypatch.setattr(envs, "VLLM_MEDIA_URL_ALLOW_REDIRECTS", True) + _install_http_transport(monkeypatch, _handler, client_kwargs) + + image = await video_api_utils.decode_image_url("https://example.com/redirect.png") + + assert image.size == (2, 1) + assert image.mode == "RGB" + assert requested_paths == ["/redirect.png", "/image.png"] + assert client_kwargs == [{"timeout": 60, "follow_redirects": True}] + + +@pytest.mark.asyncio +async def test_decode_image_url_rejects_redirects_when_disabled(monkeypatch): + requested_paths = [] + client_kwargs = [] + + def _handler(request): + requested_paths.append(request.url.path) + return httpx.Response(302, headers={"location": "/image.png"}) + + monkeypatch.setattr(envs, "VLLM_MEDIA_URL_ALLOW_REDIRECTS", False) + _install_http_transport(monkeypatch, _handler, client_kwargs) + + with pytest.raises(InvalidInputReferenceError, match="redirect.*VLLM_MEDIA_URL_ALLOW_REDIRECTS"): + await video_api_utils.decode_image_url("https://example.com/redirect.png") + + assert requested_paths == ["/redirect.png"] + assert client_kwargs == [{"timeout": 60, "follow_redirects": False}] + + +@pytest.mark.asyncio +async def test_decode_image_url_reports_http_status(monkeypatch): + client_kwargs = [] + + def _handler(request): + return httpx.Response(404) + + monkeypatch.setattr(envs, "VLLM_MEDIA_URL_ALLOW_REDIRECTS", True) + _install_http_transport(monkeypatch, _handler, client_kwargs) + + with pytest.raises(InvalidInputReferenceError, match="server returned HTTP 404"): + await video_api_utils.decode_image_url("https://example.com/missing.png") + + +@pytest.mark.asyncio +async def test_decode_image_url_reports_connection_failure(monkeypatch): + client_kwargs = [] + + def _handler(request): + raise httpx.ConnectError("connection refused", request=request) + + monkeypatch.setattr(envs, "VLLM_MEDIA_URL_ALLOW_REDIRECTS", True) + _install_http_transport(monkeypatch, _handler, client_kwargs) + + with pytest.raises(InvalidInputReferenceError, match="failed to download image"): + await video_api_utils.decode_image_url("https://example.com/unreachable.png") + + +@pytest.mark.asyncio +async def test_decode_image_url_keeps_data_urls_local(monkeypatch): + def _unexpected_http_client(*args, **kwargs): + pytest.fail("data URLs must not create an HTTP client") + + monkeypatch.setattr(video_api_utils.httpx, "AsyncClient", _unexpected_http_client) + encoded_image = base64.b64encode(_png_bytes()).decode() + + image = await video_api_utils.decode_image_url(f"data:image/png;base64,{encoded_image}") + + assert image.size == (2, 1) + assert image.mode == "RGB" + + def _install_fake_video_mux(monkeypatch, mux_calls): def _fake_mux_video_audio_bytes(frames, audio, fps, audio_sample_rate, video_codec_options=None): mux_calls.append( diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index b7527e233..b9b7df4df 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -16,15 +16,17 @@ from pathlib import Path from types import SimpleNamespace import av +import httpx import numpy as np import pytest from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from PIL import Image from pytest_mock import MockerFixture +from vllm import envs from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes -from vllm_omni.entrypoints.openai import api_server +from vllm_omni.entrypoints.openai import api_server, video_api_utils from vllm_omni.entrypoints.openai.api_server import router from vllm_omni.entrypoints.openai.protocol.videos import ( VideoGenerationRequest, @@ -599,6 +601,45 @@ def test_i2v_video_generation_with_image_reference_form(test_client, mocker: Moc assert input_image.size == (40, 24) +def test_i2v_video_generation_follows_allowed_image_redirect(test_client, mocker: MockerFixture, monkeypatch): + requested_paths = [] + async_client = httpx.AsyncClient + + def _handler(request): + requested_paths.append(request.url.path) + if request.url.path == "/redirect.png": + return httpx.Response(302, headers={"location": "/image.png"}) + return httpx.Response(200, content=_make_test_image_bytes((40, 24))) + + def _client_factory(*args, **kwargs): + return async_client(*args, transport=httpx.MockTransport(_handler), **kwargs) + + monkeypatch.setattr(envs, "VLLM_MEDIA_URL_ALLOW_REDIRECTS", True) + monkeypatch.setattr(video_api_utils.httpx, "AsyncClient", _client_factory) + mocker.patch( + "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", + return_value=b"fake-video", + ) + + response = test_client.post( + "/v1/videos", + data={ + "prompt": "A fox running through snow.", + "image_reference": json.dumps({"image_url": "https://example.com/redirect.png"}), + }, + ) + + assert response.status_code == 200 + video_id = response.json()["id"] + _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value) + assert requested_paths == ["/redirect.png", "/image.png"] + + engine = test_client.app.state.openai_serving_video._engine_client + input_image = engine.captured_prompt["multi_modal_data"]["image"] + assert isinstance(input_image, Image.Image) + assert input_image.size == (40, 24) + + def test_v2v_video_generation_form(test_client, mocker: MockerFixture): video_bytes = _make_test_video_bytes((32, 24), num_frames=3) diff --git a/vllm_omni/entrypoints/openai/video_api_utils.py b/vllm_omni/entrypoints/openai/video_api_utils.py index 5a4f9f826..d954bd821 100644 --- a/vllm_omni/entrypoints/openai/video_api_utils.py +++ b/vllm_omni/entrypoints/openai/video_api_utils.py @@ -17,6 +17,7 @@ import httpx import numpy as np import torch from PIL import Image, UnidentifiedImageError +from vllm import envs from vllm.multimodal.video import ( VIDEO_LOADER_REGISTRY, VideoBackend, @@ -172,11 +173,21 @@ async def decode_image_url(image_url: str) -> Image.Image: return _decode_base64_image(image_url, source="image_reference.image_url") if image_url.startswith(("http://", "https://")): - async with httpx.AsyncClient(timeout=60) as client: + allow_redirects = envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS + async with httpx.AsyncClient(timeout=60, follow_redirects=allow_redirects) as client: try: response = await client.get(image_url) response.raise_for_status() - except httpx.HTTPError as exc: + except httpx.HTTPStatusError as exc: + if exc.response.has_redirect_location and not allow_redirects: + raise InvalidInputReferenceError( + "Invalid image_reference.image_url: redirect response was rejected because " + "VLLM_MEDIA_URL_ALLOW_REDIRECTS is disabled." + ) from exc + raise InvalidInputReferenceError( + f"Invalid image_reference.image_url: server returned HTTP {exc.response.status_code}." + ) from exc + except httpx.RequestError as exc: raise InvalidInputReferenceError( "Invalid image_reference.image_url: failed to download image." ) from exc