fix(security): bound completion prompt list to prevent unbounded engine fan-out (#47845)

Signed-off-by: jperezde <jperezde@redhat.com>

Juan Pérez de Algaba committed Jul 8, 2026 at 00:48 UTC 675f4295cdfe0d870471c2b51bfeca3a68a9569e
3 files changed +177
tests/entrypoints/openai/completion/test_completion_error.py
+136
@@ -474,3 +474,139 @@ def test_negative_prompt_token_ids_flat():
474 prompt=[-1],
475 max_tokens=10,
476 )
477 +
478 +
479 +class TestCompletionPromptListLimit:
480 + """Regression tests for CVE: unbounded prompt list fan-out."""
481 +
482 + def test_scalar_prompt_allowed(self):
483 + request = CompletionRequest(
484 + model=MODEL_NAME,
485 + prompt="hello",
486 + max_tokens=1,
487 + )
488 + assert request.prompt == "hello"
489 +
490 + def test_single_token_list_allowed(self):
491 + request = CompletionRequest(
492 + model=MODEL_NAME,
493 + prompt=[1, 2, 3],
494 + max_tokens=1,
495 + )
496 + assert request.prompt == [1, 2, 3]
497 +
498 + def test_bounded_text_prompt_list_allowed(self, monkeypatch):
499 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "10")
500 + from vllm import envs
501 +
502 + if hasattr(envs.__getattr__, "cache_clear"):
503 + envs.__getattr__.cache_clear()
504 +
505 + request = CompletionRequest(
506 + model=MODEL_NAME,
507 + prompt=["a", "b", "c"],
508 + max_tokens=1,
509 + )
510 + assert request.prompt == ["a", "b", "c"]
511 +
512 + def test_bounded_token_id_prompt_list_allowed(self, monkeypatch):
513 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "10")
514 + from vllm import envs
515 +
516 + if hasattr(envs.__getattr__, "cache_clear"):
517 + envs.__getattr__.cache_clear()
518 +
519 + request = CompletionRequest(
520 + model=MODEL_NAME,
521 + prompt=[[1], [2], [3]],
522 + max_tokens=1,
523 + )
524 + assert request.prompt == [[1], [2], [3]]
525 +
526 + def test_oversized_text_prompt_list_rejected(self, monkeypatch):
527 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
528 + from vllm import envs
529 +
530 + if hasattr(envs.__getattr__, "cache_clear"):
531 + envs.__getattr__.cache_clear()
532 +
533 + with pytest.raises(
534 + Exception, match="prompt list length 10 exceeds the maximum"
535 + ):
536 + CompletionRequest(
537 + model=MODEL_NAME,
538 + prompt=["x"] * 10,
539 + max_tokens=1,
540 + )
541 +
542 + def test_oversized_token_id_prompt_list_rejected(self, monkeypatch):
543 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
544 + from vllm import envs
545 +
546 + if hasattr(envs.__getattr__, "cache_clear"):
547 + envs.__getattr__.cache_clear()
548 +
549 + with pytest.raises(
550 + Exception, match="prompt list length 10 exceeds the maximum"
551 + ):
552 + CompletionRequest(
553 + model=MODEL_NAME,
554 + prompt=[[1]] * 10,
555 + max_tokens=1,
556 + )
557 +
558 + def test_exact_limit_allowed(self, monkeypatch):
559 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
560 + from vllm import envs
561 +
562 + if hasattr(envs.__getattr__, "cache_clear"):
563 + envs.__getattr__.cache_clear()
564 +
565 + request = CompletionRequest(
566 + model=MODEL_NAME,
567 + prompt=["x"] * 5,
568 + max_tokens=1,
569 + )
570 + assert len(request.prompt) == 5
571 +
572 + def test_one_over_limit_rejected(self, monkeypatch):
573 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
574 + from vllm import envs
575 +
576 + if hasattr(envs.__getattr__, "cache_clear"):
577 + envs.__getattr__.cache_clear()
578 +
579 + with pytest.raises(Exception, match="prompt list length 6 exceeds the maximum"):
580 + CompletionRequest(
581 + model=MODEL_NAME,
582 + prompt=["x"] * 6,
583 + max_tokens=1,
584 + )
585 +
586 + def test_oversized_prompt_embeds_list_rejected(self, monkeypatch):
587 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
588 + from vllm import envs
589 +
590 + if hasattr(envs.__getattr__, "cache_clear"):
591 + envs.__getattr__.cache_clear()
592 +
593 + with pytest.raises(Exception, match="prompt_embeds list length 10 exceeds"):
594 + CompletionRequest(
595 + model=MODEL_NAME,
596 + prompt_embeds=[b"\x00"] * 10,
597 + max_tokens=1,
598 + )
599 +
600 + def test_bounded_prompt_embeds_list_allowed(self, monkeypatch):
601 + monkeypatch.setenv("VLLM_MAX_COMPLETION_PROMPTS", "5")
602 + from vllm import envs
603 +
604 + if hasattr(envs.__getattr__, "cache_clear"):
605 + envs.__getattr__.cache_clear()
606 +
607 + request = CompletionRequest(
608 + model=MODEL_NAME,
609 + prompt_embeds=[b"\x00"] * 5,
610 + max_tokens=1,
611 + )
612 + assert len(request.prompt_embeds) == 5
vllm/entrypoints/openai/completion/protocol.py
+34
@@ -9,6 +9,7 @@ from typing import Annotated, Any, Literal
9
10 from pydantic import Field, model_validator
11
12 +import vllm.envs as envs
13 from vllm.config import ModelConfig
14 from vllm.config.utils import replace
15 from vllm.entrypoints.openai.engine.protocol import (
@@ -35,6 +36,7 @@ from vllm.sampling_params import (
36 ThinkingTokenBudget,
37 )
38 from vllm.utils import random_uuid
39 +from vllm.utils.collection_utils import is_list_of
40
41 logger = init_logger(__name__)
42
@@ -497,6 +499,38 @@ class CompletionRequest(OpenAIBaseModel):
499
500 return data
501
502 + @model_validator(mode="before")
503 + @classmethod
504 + def validate_prompt_list_length(cls, data):
505 + max_prompts = envs.VLLM_MAX_COMPLETION_PROMPTS
506 +
507 + prompt = data.get("prompt")
508 + if (
509 + isinstance(prompt, list)
510 + and len(prompt) > 0
511 + and not is_list_of(prompt, int)
512 + and len(prompt) > max_prompts
513 + ):
514 + raise VLLMValidationError(
515 + f"prompt list length {len(prompt)} exceeds the maximum "
516 + f"allowed count of {max_prompts}. To increase this "
517 + "limit, set the VLLM_MAX_COMPLETION_PROMPTS "
518 + "environment variable.",
519 + parameter="prompt",
520 + )
521 +
522 + prompt_embeds = data.get("prompt_embeds")
523 + if isinstance(prompt_embeds, list) and len(prompt_embeds) > max_prompts:
524 + raise VLLMValidationError(
525 + f"prompt_embeds list length {len(prompt_embeds)} exceeds "
526 + f"the maximum allowed count of {max_prompts}. To increase "
527 + "this limit, set the VLLM_MAX_COMPLETION_PROMPTS "
528 + "environment variable.",
529 + parameter="prompt_embeds",
530 + )
531 +
532 + return data
533 +
534 @model_validator(mode="before")
535 @classmethod
536 def check_cache_salt_support(cls, data):
vllm/envs.py
+7
@@ -101,6 +101,7 @@ if TYPE_CHECKING:
101 VLLM_ALLOW_LONG_MAX_MODEL_LEN: bool = False
102 VLLM_HTTP_TIMEOUT_KEEP_ALIVE: int = 5 # seconds
103 VLLM_MAX_N_SEQUENCES: int = 16384
104 + VLLM_MAX_COMPLETION_PROMPTS: int = 1024
105 VLLM_PLUGINS: list[str] | None = None
106 VLLM_LORA_RESOLVER_CACHE_DIR: str | None = None
107 VLLM_LORA_RESOLVER_HF_REPO_LIST: str | None = None
@@ -1058,6 +1059,12 @@ environment_variables: dict[str, Callable[[], Any]] = {
1059 "VLLM_MAX_N_SEQUENCES": lambda: int(
1060 os.environ.get("VLLM_MAX_N_SEQUENCES", "16384")
1061 ),
1062 + # Maximum number of prompts allowed in a single /v1/completions request
1063 + # when the prompt field is a list. Prevents unbounded fan-out of engine
1064 + # requests from a single API call. Default: 1024.
1065 + "VLLM_MAX_COMPLETION_PROMPTS": lambda: int(
1066 + os.environ.get("VLLM_MAX_COMPLETION_PROMPTS", "1024")
1067 + ),
1068 # a list of plugin names to load, separated by commas.
1069 # if this is not set, it means all plugins will be loaded
1070 # if this is set to an empty string, no plugins will be loaded