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