[Security] Gate per-request multimodal processor kwargs (#58830)

Signed-off-by: Juan Pérez de Algaba <jperezde@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>

Juan Pérez de Algaba committed Sep 26, 2026 at 13:10 UTC 31f2e70cd3e403aaa471fb7fe3d33a7207aaf3cc
17 files changed +203 -4
docs/usage/security.md
+13
@@ -289,6 +289,19 @@ To mitigate this, vLLM enforces a configurable upper bound on the `n` parameter
289 - **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.
290 - **Monitoring:** Monitor per-request resource consumption to detect anomalous patterns that may indicate abuse.
291
292 +### Per-request multimodal arguments
293 +
294 +API server endpoints reject non-empty per-request `mm_processor_kwargs` and
295 +`media_io_kwargs` by default. These arguments can change image, video, or audio
296 +loading, sizing, sampling, and preprocessing behavior, causing excessive CPU,
297 +GPU, or memory use when controlled by an untrusted client. Server-level
298 +`--mm-processor-kwargs` and `--media-io-kwargs` remain available for deployment
299 +configuration.
300 +
301 +Only deployments whose API clients are trusted should start the server with
302 +`--trust-request-mm-kwargs` to restore per-request overrides. Do not enable
303 +this option on an endpoint exposed to untrusted clients.
304 +
305 ## Tool Server and MCP Security
306
307 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.
rust/src/cmd/src/cli/unsupported.rs
+11
@@ -409,6 +409,17 @@ pub struct ServerUnsupportedArgs {
409 )]
410 pub trust_request_chat_template: Option<Unsupported>,
411
412 + /// Whether to trust per-request multimodal kwargs (`mm_processor_kwargs`
413 + /// and `media_io_kwargs`). If False, the server rejects non-empty values
414 + /// because they can change multimodal preprocessing resource usage.
415 + #[arg(
416 + long,
417 + visible_alias = "no-trust-request-mm-kwargs",
418 + default_missing_value = "true",
419 + num_args = 0..=1
420 + )]
421 + pub trust_request_mm_kwargs: Option<Unsupported>,
422 +
423 /// The role name to return if `request.add_generation_prompt=true`.
424 #[arg(long)]
425 pub response_role: Option<Unsupported>,
tests/engine/test_arg_utils.py
+12
@@ -480,6 +480,18 @@ def test_compilation_config():
480 )
481
482
483 +def test_trust_request_mm_kwargs_cli():
484 + from vllm.entrypoints.launchers.cli_args import FrontendArgs
485 +
486 + parser = FrontendArgs.add_cli_args(FlexibleArgumentParser())
487 +
488 + args = parser.parse_args([])
489 + assert not args.trust_request_mm_kwargs
490 +
491 + args = parser.parse_args(["--trust-request-mm-kwargs"])
492 + assert args.trust_request_mm_kwargs
493 +
494 +
495 def test_attention_config():
496 from vllm.v1.attention.backends.registry import AttentionBackendEnum
497
tests/entrypoints/multimodal/openai/chat_completion/test_audio_in_video.py
+2 -4
@@ -26,13 +26,11 @@ def server():
26 "--enforce-eager",
27 "--limit-mm-per-prompt",
28 json.dumps({"audio": 3, "video": 3}),
29 + "--trust-request-mm-kwargs",
30 *ROCM_EXTRA_ARGS,
31 ]
32
32 - with RemoteOpenAIServer(
33 - MODEL_NAME,
34 - args,
35 - ) as remote_server:
33 + with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
34 yield remote_server
35
36
tests/entrypoints/multimodal/openai/chat_completion/test_video.py
+1
@@ -37,6 +37,7 @@ def server():
37 json.dumps({"video": MAXIMUM_VIDEOS}),
38 "--media-io-kwargs",
39 json.dumps({"video": {"num_frames": 32}}),
40 + "--trust-request-mm-kwargs",
41 ]
42
43 # ROCm: Increase timeouts to handle potential network delays and slower
tests/entrypoints/openai/chat_completion/test_chat_error.py
+75
@@ -475,6 +475,81 @@ def test_json_schema_response_format_missing_schema():
475 )
476
477
478 +@pytest.mark.asyncio
479 +async def test_online_renderer_rejects_mm_processor_kwargs_by_default():
480 + model_config = MockModelConfig()
481 + model_config.multimodal_config = MultiModalConfig()
482 + online_renderer = OnlineRenderer(
483 + model_config=model_config,
484 + renderer=MagicMock(),
485 + request_logger=None,
486 + chat_template=None,
487 + chat_template_content_format="auto",
488 + )
489 +
490 + request = ChatCompletionRequest(
491 + model=MODEL_NAME,
492 + messages=[{"role": "user", "content": "hello"}],
493 + mm_processor_kwargs={
494 + "patch_size": 1,
495 + "vision_min_num_patches": 3_000_000_000,
496 + },
497 + )
498 +
499 + with pytest.raises(
500 + VLLMValidationError,
501 + match="Per-request mm_processor_kwargs are disabled",
502 + ):
503 + await online_renderer.preprocess_chat(
504 + request,
505 + request.messages,
506 + default_template=None,
507 + default_template_content_format="auto",
508 + default_template_kwargs=None,
509 + )
510 +
511 +
512 +@pytest.mark.asyncio
513 +async def test_online_renderer_rejects_media_io_kwargs_by_default():
514 + model_config = MockModelConfig()
515 + model_config.multimodal_config = MultiModalConfig()
516 + online_renderer = OnlineRenderer(
517 + model_config=model_config,
518 + renderer=MagicMock(),
519 + request_logger=None,
520 + chat_template=None,
521 + chat_template_content_format="auto",
522 + )
523 +
524 + request = ChatCompletionRequest(
525 + model=MODEL_NAME,
526 + messages=[{"role": "user", "content": "hello"}],
527 + media_io_kwargs={"video": {"num_frames": 1_000_000}},
528 + )
529 +
530 + with pytest.raises(
531 + VLLMValidationError,
532 + match="Per-request media_io_kwargs are disabled",
533 + ):
534 + await online_renderer.preprocess_chat(
535 + request,
536 + request.messages,
537 + default_template=None,
538 + default_template_content_format="auto",
539 + default_template_kwargs=None,
540 + )
541 +
542 +
543 +def test_trust_request_mm_kwargs_opt_in_allows_overrides():
544 + from vllm.entrypoints.generate.base.protocol import validate_request_mm_kwargs
545 +
546 + validate_request_mm_kwargs(
547 + mm_processor_kwargs={"use_audio_in_video": True},
548 + media_io_kwargs={"video": {"num_frames": 4}},
549 + trust_request_mm_kwargs=True,
550 + )
551 +
552 +
553 @pytest.mark.parametrize("format_value", [None, {}])
554 def test_structural_tag_response_format_invalid(format_value):
555 """Malformed structural tags should be rejected during request validation."""
tests/entrypoints/openai/test_render_parity.py
+1
@@ -233,6 +233,7 @@ def online_renderer(model_config: MockModelConfig, request) -> OnlineRenderer:
233 enable_auto_tools=True,
234 tool_parser="openai",
235 exclude_tools_when_tool_choice_none=exclude_tools_when_tool_choice_none,
236 + trust_request_mm_kwargs=True,
237 )
238
239
tests/entrypoints/pooling/embed/test_online_vision.py
+1
@@ -46,6 +46,7 @@ def server():
46 json.dumps({"image": MAXIMUM_IMAGES}),
47 "--chat-template",
48 str(vlm2vec_jinja_path),
49 + "--trust-request-mm-kwargs",
50 ]
51
52 with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
tests/entrypoints/pooling/scoring/test_late_interaction_online.py
+30
@@ -26,6 +26,36 @@ TEXTS_2 = [
26 ]
27
28
29 +@pytest.mark.asyncio
30 +async def test_flash_late_interaction_rejects_mm_processor_kwargs_by_default():
31 + from unittest.mock import MagicMock
32 +
33 + from vllm.config.multimodal import MultiModalConfig
34 + from vllm.entrypoints.pooling.base.serving import PoolingBaseServing
35 + from vllm.entrypoints.pooling.scoring.protocol import ScoreQueriesDocumentsRequest
36 + from vllm.entrypoints.pooling.scoring.serving import ServingScores
37 + from vllm.exceptions import VLLMValidationError
38 +
39 + serving = MagicMock()
40 + serving.model_config = MagicMock(multimodal_config=MultiModalConfig())
41 + serving.trust_request_mm_kwargs = False
42 + serving.io_processor = MagicMock()
43 + serving._init_ctx = PoolingBaseServing._init_ctx.__get__(serving)
44 + request = ScoreQueriesDocumentsRequest(
45 + model=MODEL_NAME,
46 + queries=TEXTS_1[0],
47 + documents=TEXTS_2[0],
48 + mm_processor_kwargs={"vision_min_num_patches": 3_000_000_000},
49 + )
50 +
51 + with pytest.raises(
52 + VLLMValidationError,
53 + match="Per-request mm_processor_kwargs are disabled",
54 + ):
55 + await ServingScores.flash_late_interaction(serving, request)
56 + serving._preprocessing.assert_not_called()
57 +
58 +
59 @pytest.fixture(scope="module", params=[True, False])
60 def server(request):
61 args = [
vllm/entrypoints/chat_utils.py
+1
@@ -1450,6 +1450,7 @@ class ChatTemplateConfig:
1450 chat_template: str | None = None
1451 chat_template_content_format: ChatTemplateContentFormatOption = "auto"
1452 trust_request_chat_template: bool = False
1453 + trust_request_mm_kwargs: bool = False
1454
1455
1456 def validate_chat_template(chat_template: Path | str | None):
vllm/entrypoints/generate/base/protocol.py
+28
@@ -49,6 +49,34 @@ def validate_cache_salt(cache_salt: object) -> None:
49 )
50
51
52 +def validate_request_mm_kwargs(
53 + *,
54 + mm_processor_kwargs: dict[str, Any] | None,
55 + media_io_kwargs: dict[str, dict[str, Any]] | None,
56 + trust_request_mm_kwargs: bool,
57 +) -> None:
58 + """Reject untrusted per-request multimodal kwarg overrides."""
59 + if trust_request_mm_kwargs:
60 + return
61 +
62 + if mm_processor_kwargs:
63 + raise VLLMValidationError(
64 + "Per-request mm_processor_kwargs are disabled by default because "
65 + "they can change multimodal preprocessing resource usage. Start "
66 + "the server with --trust-request-mm-kwargs only when clients "
67 + "are trusted.",
68 + parameter="mm_processor_kwargs",
69 + )
70 + if media_io_kwargs:
71 + raise VLLMValidationError(
72 + "Per-request media_io_kwargs are disabled by default because "
73 + "they can change multimodal media loading resource usage. Start "
74 + "the server with --trust-request-mm-kwargs only when clients "
75 + "are trusted.",
76 + parameter="media_io_kwargs",
77 + )
78 +
79 +
80 class SpeculativeDecodingMetrics(OpenAIBaseModel):
81 """Per-request speculative-decoding acceptance metrics.
82
vllm/entrypoints/launchers/api_server/app_state.py
+1
@@ -92,6 +92,7 @@ async def init_app_state(
92 chat_template=resolved_chat_template,
93 chat_template_content_format=args.chat_template_content_format,
94 trust_request_chat_template=args.trust_request_chat_template,
95 + trust_request_mm_kwargs=args.trust_request_mm_kwargs,
96 enable_auto_tools=args.enable_auto_tool_choice,
97 exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
98 tool_parser=args.tool_call_parser,
vllm/entrypoints/launchers/cli_args.py
+5
@@ -93,6 +93,11 @@ class BaseFrontendArgs:
93 """Whether to trust the chat template provided in the request. If False,
94 the server will always use the chat template specified by `--chat-template`
95 or the ones from tokenizer."""
96 + trust_request_mm_kwargs: bool = False
97 + """Whether to trust per-request multimodal kwargs (`mm_processor_kwargs`
98 + and `media_io_kwargs`). If False, the server rejects non-empty values
99 + because they can change multimodal preprocessing resource usage. Only
100 + enable this when API clients are trusted."""
101 default_chat_template_kwargs: dict[str, Any] | None = None
102 """Default keyword arguments to pass to the chat template renderer.
103 These will be merged with request-level chat_template_kwargs,
vllm/entrypoints/launchers/render/app_state.py
+1
@@ -57,6 +57,7 @@ async def init_render_app_state(
57 chat_template=resolved_chat_template,
58 chat_template_content_format=args.chat_template_content_format,
59 trust_request_chat_template=args.trust_request_chat_template,
60 + trust_request_mm_kwargs=args.trust_request_mm_kwargs,
61 enable_auto_tools=args.enable_auto_tool_choice,
62 exclude_tools_when_tool_choice_none=args.exclude_tools_when_tool_choice_none,
63 tool_parser=args.tool_call_parser,
vllm/entrypoints/pooling/base/serving.py
+7
@@ -16,6 +16,7 @@ from vllm import PoolingRequestOutput, envs
16 from vllm.config import VllmConfig
17 from vllm.engine.protocol import EngineClient
18 from vllm.entrypoints.chat_utils import ChatTemplateConfig
19 +from vllm.entrypoints.generate.base.protocol import validate_request_mm_kwargs
20 from vllm.entrypoints.openai.models.serving import OpenAIServingModels
21 from vllm.entrypoints.serve.engine.serving import BaseServing
22 from vllm.entrypoints.serve.engine.typing import AnyRequest
@@ -60,6 +61,7 @@ class PoolingBaseServing(ABC, BaseServing):
61 self.return_tokens_as_token_ids = return_tokens_as_token_ids
62 self.log_error_stack = log_error_stack
63 self.chat_template_config = chat_template_config
64 + self.trust_request_mm_kwargs = chat_template_config.trust_request_mm_kwargs
65
66 # Shared thread pool executor for preprocessing and postprocessing.
67 self._executor: Executor = self.renderer._executor
@@ -108,6 +110,11 @@ class PoolingBaseServing(ABC, BaseServing):
110 request: AnyPoolingRequest,
111 raw_request: Request | None = None,
112 ):
113 + validate_request_mm_kwargs(
114 + mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None),
115 + media_io_kwargs=getattr(request, "media_io_kwargs", None),
116 + trust_request_mm_kwargs=self.trust_request_mm_kwargs,
117 + )
118 base_request_id = self._base_request_id(
119 raw_request, getattr(request, "request_id", None)
120 )
vllm/entrypoints/pooling/factories.py
+1
@@ -166,6 +166,7 @@ def init_pooling_state(
166 chat_template=resolved_chat_template,
167 chat_template_content_format=args.chat_template_content_format,
168 trust_request_chat_template=args.trust_request_chat_template,
169 + trust_request_mm_kwargs=args.trust_request_mm_kwargs,
170 )
171
172 state.serving_pooling = (
vllm/renderers/online_renderer.py
+13
@@ -17,6 +17,7 @@ from vllm.entrypoints.chat_utils import (
17 ChatTemplateContentFormatOption,
18 ConversationMessage,
19 )
20 +from vllm.entrypoints.generate.base.protocol import validate_request_mm_kwargs
21 from vllm.entrypoints.openai.chat_completion.protocol import (
22 ChatCompletionNamedToolChoiceParam,
23 ChatCompletionRequest,
@@ -123,6 +124,7 @@ class OnlineRenderer:
124 chat_template: str | None,
125 chat_template_content_format: ChatTemplateContentFormatOption,
126 trust_request_chat_template: bool = False,
127 + trust_request_mm_kwargs: bool = False,
128 enable_auto_tools: bool = False,
129 exclude_tools_when_tool_choice_none: bool = False,
130 tool_parser: str | None = None,
@@ -155,6 +157,7 @@ class OnlineRenderer:
157 default_chat_template_kwargs or {}
158 )
159 self.trust_request_chat_template = trust_request_chat_template
160 + self.trust_request_mm_kwargs = trust_request_mm_kwargs
161
162 self.log_error_stack = log_error_stack
163 self.supports_browsing = False
@@ -674,6 +677,11 @@ class OnlineRenderer:
677 """Copied from GenerateBaseServing._preprocess_cmpl."""
678 renderer = self.renderer
679 model_config = self.model_config
680 + validate_request_mm_kwargs(
681 + mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None),
682 + media_io_kwargs=getattr(request, "media_io_kwargs", None),
683 + trust_request_mm_kwargs=self.trust_request_mm_kwargs,
684 + )
685
686 parsed_prompts = [
687 (
@@ -710,6 +718,11 @@ class OnlineRenderer:
718 ) -> tuple[list[ConversationMessage], list[EngineInput]]:
719 """Copied from GenerateBaseServing._preprocess_chat."""
720 renderer = self.renderer
721 + validate_request_mm_kwargs(
722 + mm_processor_kwargs=getattr(request, "mm_processor_kwargs", None),
723 + media_io_kwargs=getattr(request, "media_io_kwargs", None),
724 + trust_request_mm_kwargs=self.trust_request_mm_kwargs,
725 + )
726 mm_config = self.model_config.multimodal_config
727
728 default_template_kwargs = merge_kwargs(