From 31f2e70cd3e403aaa471fb7fe3d33a7207aaf3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20P=C3=A9rez=20de=20Algaba?= <124347725+jperezdealgaba@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:10:35 +0200 Subject: [PATCH] [Security] Gate per-request multimodal processor kwargs (#58830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Juan Pérez de Algaba Co-authored-by: Cursor --- docs/usage/security.md | 13 ++++ rust/src/cmd/src/cli/unsupported.rs | 11 +++ tests/engine/test_arg_utils.py | 12 +++ .../chat_completion/test_audio_in_video.py | 6 +- .../openai/chat_completion/test_video.py | 1 + .../openai/chat_completion/test_chat_error.py | 75 +++++++++++++++++++ .../entrypoints/openai/test_render_parity.py | 1 + .../pooling/embed/test_online_vision.py | 1 + .../scoring/test_late_interaction_online.py | 30 ++++++++ vllm/entrypoints/chat_utils.py | 1 + vllm/entrypoints/generate/base/protocol.py | 28 +++++++ .../launchers/api_server/app_state.py | 1 + vllm/entrypoints/launchers/cli_args.py | 5 ++ .../entrypoints/launchers/render/app_state.py | 1 + vllm/entrypoints/pooling/base/serving.py | 7 ++ vllm/entrypoints/pooling/factories.py | 1 + vllm/renderers/online_renderer.py | 13 ++++ 17 files changed, 203 insertions(+), 4 deletions(-) diff --git a/docs/usage/security.md b/docs/usage/security.md index 5d4d429c6f..a6c9867755 100644 --- a/docs/usage/security.md +++ b/docs/usage/security.md @@ -289,6 +289,19 @@ To mitigate this, vLLM enforces a configurable upper bound on the `n` parameter - **Reverse proxy layer:** In addition to vLLM's built-in limit, consider enforcing request body validation and rate limiting at your reverse proxy to further constrain abusive payloads. - **Monitoring:** Monitor per-request resource consumption to detect anomalous patterns that may indicate abuse. +### Per-request multimodal arguments + +API server endpoints reject non-empty per-request `mm_processor_kwargs` and +`media_io_kwargs` by default. These arguments can change image, video, or audio +loading, sizing, sampling, and preprocessing behavior, causing excessive CPU, +GPU, or memory use when controlled by an untrusted client. Server-level +`--mm-processor-kwargs` and `--media-io-kwargs` remain available for deployment +configuration. + +Only deployments whose API clients are trusted should start the server with +`--trust-request-mm-kwargs` to restore per-request overrides. Do not enable +this option on an endpoint exposed to untrusted clients. + ## Tool Server and MCP Security vLLM supports connecting to external tool servers via the `--tool-server` argument. This enables models to call tools through the Responses API (`/v1/responses`). Tool server support works with all models — it is not limited to specific model architectures. diff --git a/rust/src/cmd/src/cli/unsupported.rs b/rust/src/cmd/src/cli/unsupported.rs index 5088063914..f65f766e84 100644 --- a/rust/src/cmd/src/cli/unsupported.rs +++ b/rust/src/cmd/src/cli/unsupported.rs @@ -409,6 +409,17 @@ pub struct ServerUnsupportedArgs { )] pub trust_request_chat_template: Option, + /// Whether to trust per-request multimodal kwargs (`mm_processor_kwargs` + /// and `media_io_kwargs`). If False, the server rejects non-empty values + /// because they can change multimodal preprocessing resource usage. + #[arg( + long, + visible_alias = "no-trust-request-mm-kwargs", + default_missing_value = "true", + num_args = 0..=1 + )] + pub trust_request_mm_kwargs: Option, + /// The role name to return if `request.add_generation_prompt=true`. #[arg(long)] pub response_role: Option, diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py index 480c4f1b8a..924d2bafec 100644 --- a/tests/engine/test_arg_utils.py +++ b/tests/engine/test_arg_utils.py @@ -480,6 +480,18 @@ def test_compilation_config(): ) +def test_trust_request_mm_kwargs_cli(): + from vllm.entrypoints.launchers.cli_args import FrontendArgs + + parser = FrontendArgs.add_cli_args(FlexibleArgumentParser()) + + args = parser.parse_args([]) + assert not args.trust_request_mm_kwargs + + args = parser.parse_args(["--trust-request-mm-kwargs"]) + assert args.trust_request_mm_kwargs + + def test_attention_config(): from vllm.v1.attention.backends.registry import AttentionBackendEnum diff --git a/tests/entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py b/tests/entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py index 5031cbea1a..7b0f3c89c9 100644 --- a/tests/entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py +++ b/tests/entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py @@ -26,13 +26,11 @@ def server(): "--enforce-eager", "--limit-mm-per-prompt", json.dumps({"audio": 3, "video": 3}), + "--trust-request-mm-kwargs", *ROCM_EXTRA_ARGS, ] - with RemoteOpenAIServer( - MODEL_NAME, - args, - ) as remote_server: + with RemoteOpenAIServer(MODEL_NAME, args) as remote_server: yield remote_server diff --git a/tests/entrypoints/multimodal/openai/chat_completion/test_video.py b/tests/entrypoints/multimodal/openai/chat_completion/test_video.py index 682eb97386..a6ca410e07 100644 --- a/tests/entrypoints/multimodal/openai/chat_completion/test_video.py +++ b/tests/entrypoints/multimodal/openai/chat_completion/test_video.py @@ -37,6 +37,7 @@ def server(): json.dumps({"video": MAXIMUM_VIDEOS}), "--media-io-kwargs", json.dumps({"video": {"num_frames": 32}}), + "--trust-request-mm-kwargs", ] # ROCm: Increase timeouts to handle potential network delays and slower diff --git a/tests/entrypoints/openai/chat_completion/test_chat_error.py b/tests/entrypoints/openai/chat_completion/test_chat_error.py index a1665a71de..eafcdffaa0 100644 --- a/tests/entrypoints/openai/chat_completion/test_chat_error.py +++ b/tests/entrypoints/openai/chat_completion/test_chat_error.py @@ -475,6 +475,81 @@ def test_json_schema_response_format_missing_schema(): ) +@pytest.mark.asyncio +async def test_online_renderer_rejects_mm_processor_kwargs_by_default(): + model_config = MockModelConfig() + model_config.multimodal_config = MultiModalConfig() + online_renderer = OnlineRenderer( + model_config=model_config, + renderer=MagicMock(), + request_logger=None, + chat_template=None, + chat_template_content_format="auto", + ) + + request = ChatCompletionRequest( + model=MODEL_NAME, + messages=[{"role": "user", "content": "hello"}], + mm_processor_kwargs={ + "patch_size": 1, + "vision_min_num_patches": 3_000_000_000, + }, + ) + + with pytest.raises( + VLLMValidationError, + match="Per-request mm_processor_kwargs are disabled", + ): + await online_renderer.preprocess_chat( + request, + request.messages, + default_template=None, + default_template_content_format="auto", + default_template_kwargs=None, + ) + + +@pytest.mark.asyncio +async def test_online_renderer_rejects_media_io_kwargs_by_default(): + model_config = MockModelConfig() + model_config.multimodal_config = MultiModalConfig() + online_renderer = OnlineRenderer( + model_config=model_config, + renderer=MagicMock(), + request_logger=None, + chat_template=None, + chat_template_content_format="auto", + ) + + request = ChatCompletionRequest( + model=MODEL_NAME, + messages=[{"role": "user", "content": "hello"}], + media_io_kwargs={"video": {"num_frames": 1_000_000}}, + ) + + with pytest.raises( + VLLMValidationError, + match="Per-request media_io_kwargs are disabled", + ): + await online_renderer.preprocess_chat( + request, + request.messages, + default_template=None, + default_template_content_format="auto", + default_template_kwargs=None, + ) + + +def test_trust_request_mm_kwargs_opt_in_allows_overrides(): + from vllm.entrypoints.generate.base.protocol import validate_request_mm_kwargs + + validate_request_mm_kwargs( + mm_processor_kwargs={"use_audio_in_video": True}, + media_io_kwargs={"video": {"num_frames": 4}}, + trust_request_mm_kwargs=True, + ) + + @pytest.mark.parametrize("format_value", [None, {}]) def test_structural_tag_response_format_invalid(format_value): """Malformed structural tags should be rejected during request validation.""" diff --git a/tests/entrypoints/openai/test_render_parity.py b/tests/entrypoints/openai/test_render_parity.py index 4fc44c5a71..2bf4345091 100644 --- a/tests/entrypoints/openai/test_render_parity.py +++ b/tests/entrypoints/openai/test_render_parity.py @@ -233,6 +233,7 @@ def online_renderer(model_config: MockModelConfig, request) -> OnlineRenderer: enable_auto_tools=True, tool_parser="openai", exclude_tools_when_tool_choice_none=exclude_tools_when_tool_choice_none, + trust_request_mm_kwargs=True, ) diff --git a/tests/entrypoints/pooling/embed/test_online_vision.py b/tests/entrypoints/pooling/embed/test_online_vision.py index 2b4bf57a13..8ca7961d42 100644 --- a/tests/entrypoints/pooling/embed/test_online_vision.py +++ b/tests/entrypoints/pooling/embed/test_online_vision.py @@ -46,6 +46,7 @@ def server(): json.dumps({"image": MAXIMUM_IMAGES}), "--chat-template", str(vlm2vec_jinja_path), + "--trust-request-mm-kwargs", ] with RemoteOpenAIServer(MODEL_NAME, args) as remote_server: diff --git a/tests/entrypoints/pooling/scoring/test_late_interaction_online.py b/tests/entrypoints/pooling/scoring/test_late_interaction_online.py index 7e4501fe85..51ef85c0d7 100644 --- a/tests/entrypoints/pooling/scoring/test_late_interaction_online.py +++ b/tests/entrypoints/pooling/scoring/test_late_interaction_online.py @@ -26,6 +26,36 @@ TEXTS_2 = [ ] +@pytest.mark.asyncio +async def test_flash_late_interaction_rejects_mm_processor_kwargs_by_default(): + from unittest.mock import MagicMock + + from vllm.config.multimodal import MultiModalConfig + from vllm.entrypoints.pooling.base.serving import PoolingBaseServing + from vllm.entrypoints.pooling.scoring.protocol import ScoreQueriesDocumentsRequest + from vllm.entrypoints.pooling.scoring.serving import ServingScores + from vllm.exceptions import VLLMValidationError + + serving = MagicMock() + serving.model_config = MagicMock(multimodal_config=MultiModalConfig()) + serving.trust_request_mm_kwargs = False + serving.io_processor = MagicMock() + serving._init_ctx = PoolingBaseServing._init_ctx.__get__(serving) + request = ScoreQueriesDocumentsRequest( + model=MODEL_NAME, + queries=TEXTS_1[0], + documents=TEXTS_2[0], + mm_processor_kwargs={"vision_min_num_patches": 3_000_000_000}, + ) + + with pytest.raises( + VLLMValidationError, + match="Per-request mm_processor_kwargs are disabled", + ): + await ServingScores.flash_late_interaction(serving, request) + serving._preprocessing.assert_not_called() + + @pytest.fixture(scope="module", params=[True, False]) def server(request): args = [ diff --git a/vllm/entrypoints/chat_utils.py b/vllm/entrypoints/chat_utils.py index e3cc744ba7..f739495a8a 100644 --- a/vllm/entrypoints/chat_utils.py +++ b/vllm/entrypoints/chat_utils.py @@ -1450,6 +1450,7 @@ class ChatTemplateConfig: chat_template: str | None = None chat_template_content_format: ChatTemplateContentFormatOption = "auto" trust_request_chat_template: bool = False + trust_request_mm_kwargs: bool = False def validate_chat_template(chat_template: Path | str | None): diff --git a/vllm/entrypoints/generate/base/protocol.py b/vllm/entrypoints/generate/base/protocol.py index 91d2e30a47..c183498143 100644 --- a/vllm/entrypoints/generate/base/protocol.py +++ b/vllm/entrypoints/generate/base/protocol.py @@ -49,6 +49,34 @@ def validate_cache_salt(cache_salt: object) -> None: ) +def validate_request_mm_kwargs( + *, + mm_processor_kwargs: dict[str, Any] | None, + media_io_kwargs: dict[str, dict[str, Any]] | None, + trust_request_mm_kwargs: bool, +) -> None: + """Reject untrusted per-request multimodal kwarg overrides.""" + if trust_request_mm_kwargs: + return + + if mm_processor_kwargs: + raise VLLMValidationError( + "Per-request mm_processor_kwargs are disabled by default because " + "they can change multimodal preprocessing resource usage. Start " + "the server with --trust-request-mm-kwargs only when clients " + "are trusted.", + parameter="mm_processor_kwargs", + ) + if media_io_kwargs: + raise VLLMValidationError( + "Per-request media_io_kwargs are disabled by default because " + "they can change multimodal media loading resource usage. Start " + "the server with --trust-request-mm-kwargs only when clients " + "are trusted.", + parameter="media_io_kwargs", + ) + + class SpeculativeDecodingMetrics(OpenAIBaseModel): """Per-request speculative-decoding acceptance metrics. diff --git a/vllm/entrypoints/launchers/api_server/app_state.py b/vllm/entrypoints/launchers/api_server/app_state.py index d33be65949..4f30beb1a8 100644 --- a/vllm/entrypoints/launchers/api_server/app_state.py +++ b/vllm/entrypoints/launchers/api_server/app_state.py @@ -92,6 +92,7 @@ async def init_app_state( chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, trust_request_chat_template=args.trust_request_chat_template, + trust_request_mm_kwargs=args.trust_request_mm_kwargs, enable_auto_tools=args.enable_auto_tool_choice, exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none, tool_parser=args.tool_call_parser, diff --git a/vllm/entrypoints/launchers/cli_args.py b/vllm/entrypoints/launchers/cli_args.py index 4e4ceed96d..ce6692565c 100644 --- a/vllm/entrypoints/launchers/cli_args.py +++ b/vllm/entrypoints/launchers/cli_args.py @@ -93,6 +93,11 @@ class BaseFrontendArgs: """Whether to trust the chat template provided in the request. If False, the server will always use the chat template specified by `--chat-template` or the ones from tokenizer.""" + trust_request_mm_kwargs: bool = False + """Whether to trust per-request multimodal kwargs (`mm_processor_kwargs` + and `media_io_kwargs`). If False, the server rejects non-empty values + because they can change multimodal preprocessing resource usage. Only + enable this when API clients are trusted.""" default_chat_template_kwargs: dict[str, Any] | None = None """Default keyword arguments to pass to the chat template renderer. These will be merged with request-level chat_template_kwargs, diff --git a/vllm/entrypoints/launchers/render/app_state.py b/vllm/entrypoints/launchers/render/app_state.py index 1d242977be..bbd9a61275 100644 --- a/vllm/entrypoints/launchers/render/app_state.py +++ b/vllm/entrypoints/launchers/render/app_state.py @@ -57,6 +57,7 @@ async def init_render_app_state( chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, trust_request_chat_template=args.trust_request_chat_template, + trust_request_mm_kwargs=args.trust_request_mm_kwargs, enable_auto_tools=args.enable_auto_tool_choice, exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none, tool_parser=args.tool_call_parser, diff --git a/vllm/entrypoints/pooling/base/serving.py b/vllm/entrypoints/pooling/base/serving.py index e94d2ce2e3..c0c0c15e9c 100644 --- a/vllm/entrypoints/pooling/base/serving.py +++ b/vllm/entrypoints/pooling/base/serving.py @@ -16,6 +16,7 @@ from vllm import PoolingRequestOutput, envs from vllm.config import VllmConfig from vllm.engine.protocol import EngineClient from vllm.entrypoints.chat_utils import ChatTemplateConfig +from vllm.entrypoints.generate.base.protocol import validate_request_mm_kwargs from vllm.entrypoints.openai.models.serving import OpenAIServingModels from vllm.entrypoints.serve.engine.serving import BaseServing from vllm.entrypoints.serve.engine.typing import AnyRequest @@ -60,6 +61,7 @@ class PoolingBaseServing(ABC, BaseServing): self.return_tokens_as_token_ids = return_tokens_as_token_ids self.log_error_stack = log_error_stack self.chat_template_config = chat_template_config + self.trust_request_mm_kwargs = chat_template_config.trust_request_mm_kwargs # Shared thread pool executor for preprocessing and postprocessing. self._executor: Executor = self.renderer._executor @@ -108,6 +110,11 @@ class PoolingBaseServing(ABC, BaseServing): request: AnyPoolingRequest, raw_request: Request | None = None, ): + validate_request_mm_kwargs( + mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None), + media_io_kwargs=getattr(request, "media_io_kwargs", None), + trust_request_mm_kwargs=self.trust_request_mm_kwargs, + ) base_request_id = self._base_request_id( raw_request, getattr(request, "request_id", None) ) diff --git a/vllm/entrypoints/pooling/factories.py b/vllm/entrypoints/pooling/factories.py index c7615054dd..e6d01be5a9 100644 --- a/vllm/entrypoints/pooling/factories.py +++ b/vllm/entrypoints/pooling/factories.py @@ -166,6 +166,7 @@ def init_pooling_state( chat_template=resolved_chat_template, chat_template_content_format=args.chat_template_content_format, trust_request_chat_template=args.trust_request_chat_template, + trust_request_mm_kwargs=args.trust_request_mm_kwargs, ) state.serving_pooling = ( diff --git a/vllm/renderers/online_renderer.py b/vllm/renderers/online_renderer.py index e5169d70ba..139b783aaa 100644 --- a/vllm/renderers/online_renderer.py +++ b/vllm/renderers/online_renderer.py @@ -17,6 +17,7 @@ from vllm.entrypoints.chat_utils import ( ChatTemplateContentFormatOption, ConversationMessage, ) +from vllm.entrypoints.generate.base.protocol import validate_request_mm_kwargs from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionNamedToolChoiceParam, ChatCompletionRequest, @@ -123,6 +124,7 @@ class OnlineRenderer: chat_template: str | None, chat_template_content_format: ChatTemplateContentFormatOption, trust_request_chat_template: bool = False, + trust_request_mm_kwargs: bool = False, enable_auto_tools: bool = False, exclude_tools_when_tool_choice_none: bool = False, tool_parser: str | None = None, @@ -155,6 +157,7 @@ class OnlineRenderer: default_chat_template_kwargs or {} ) self.trust_request_chat_template = trust_request_chat_template + self.trust_request_mm_kwargs = trust_request_mm_kwargs self.log_error_stack = log_error_stack self.supports_browsing = False @@ -674,6 +677,11 @@ class OnlineRenderer: """Copied from GenerateBaseServing._preprocess_cmpl.""" renderer = self.renderer model_config = self.model_config + validate_request_mm_kwargs( + mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None), + media_io_kwargs=getattr(request, "media_io_kwargs", None), + trust_request_mm_kwargs=self.trust_request_mm_kwargs, + ) parsed_prompts = [ ( @@ -710,6 +718,11 @@ class OnlineRenderer: ) -> tuple[list[ConversationMessage], list[EngineInput]]: """Copied from GenerateBaseServing._preprocess_chat.""" renderer = self.renderer + validate_request_mm_kwargs( + mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None), + media_io_kwargs=getattr(request, "media_io_kwargs", None), + trust_request_mm_kwargs=self.trust_request_mm_kwargs, + ) mm_config = self.model_config.multimodal_config default_template_kwargs = merge_kwargs(