fix(security): add resource bounds validation to derender endpoints (#47260)
Signed-off-by: jperezde <jperezde@redhat.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Juan Pérez de Algaba committed
Jul 7, 2026 at 08:58 UTC
8e61b646e2d157f9b93451fa048f9c8530c8a67b
3 files changed
+272
-3
tests/entrypoints/scale_out/derender/test_derender.py
+194
@@ -489,6 +489,200 @@ async def test_derender_completion_kv_transfer_params_passthrough(client):
489
assert response.json()["kv_transfer_params"] == kv
490
491
492
+# ---------------------------------------------------------------------------
493
+# Resource bounds regression tests
494
+# ---------------------------------------------------------------------------
495
+
496
+
497
+@pytest.mark.asyncio
498
+async def test_derender_chat_bounded_payload_succeeds(client):
499
+ """Normal bounded derender payload succeeds (positive control)."""
500
+ gen_req = await _render_chat(client)
501
+ synthetic_ids = gen_req["token_ids"][:5]
502
+
503
+ response = await client.post(
504
+ "/v1/chat/completions/derender",
505
+ json={
506
+ "model": MODEL_NAME,
507
+ "generate_response": _make_generate_response(synthetic_ids),
508
+ },
509
+ )
510
+ assert response.status_code == 200
511
+ data = response.json()
512
+ assert len(data["choices"]) == 1
513
+ assert data["choices"][0]["message"]["content"]
514
+
515
+
516
+@pytest.mark.asyncio
517
+async def test_derender_chat_oversized_token_ids_rejected(client):
518
+ """token_ids longer than max_model_len returns 400."""
519
+ response = await client.get("/v1/models")
520
+ assert response.status_code == 200
521
+
522
+ # Use a token_ids list that exceeds any reasonable max_model_len.
523
+ # The tiny-random model has max_model_len of 2048.
524
+ oversized_ids = [42] * 1_000_000
525
+
526
+ response = await client.post(
527
+ "/v1/chat/completions/derender",
528
+ json={
529
+ "model": MODEL_NAME,
530
+ "generate_response": _make_generate_response(oversized_ids),
531
+ },
532
+ )
533
+ assert response.status_code == 400
534
+ assert "max_model_len" in response.json()["error"]["message"]
535
+
536
+
537
+@pytest.mark.asyncio
538
+async def test_derender_chat_too_many_choices_rejected(client):
539
+ """choices count exceeding VLLM_MAX_N_SEQUENCES returns 400."""
540
+ # Default VLLM_MAX_N_SEQUENCES is 16384; use a larger count.
541
+ oversized_choices = [
542
+ {"index": i, "token_ids": [42], "finish_reason": "stop"} for i in range(20_000)
543
+ ]
544
+ response = await client.post(
545
+ "/v1/chat/completions/derender",
546
+ json={
547
+ "model": MODEL_NAME,
548
+ "generate_response": {
549
+ "request_id": "test-choices-bound",
550
+ "choices": oversized_choices,
551
+ },
552
+ },
553
+ )
554
+ assert response.status_code == 400
555
+ assert "choices count" in response.json()["error"]["message"]
556
+
557
+
558
+@pytest.mark.asyncio
559
+async def test_derender_completion_too_many_generate_responses_rejected(client):
560
+ """generate_responses count exceeding limit returns 400."""
561
+ oversized_responses = [
562
+ {
563
+ "request_id": f"gen-{i}",
564
+ "choices": [{"index": 0, "token_ids": [42], "finish_reason": "stop"}],
565
+ }
566
+ for i in range(20_000)
567
+ ]
568
+ response = await client.post(
569
+ "/v1/completions/derender",
570
+ json={
571
+ "model": MODEL_NAME,
572
+ "generate_responses": oversized_responses,
573
+ },
574
+ )
575
+ assert response.status_code == 400
576
+ assert "generate_responses count" in response.json()["error"]["message"]
577
+
578
+
579
+@pytest.mark.asyncio
580
+async def test_derender_chat_negative_token_ids_rejected(client):
581
+ """Negative token_ids are rejected at the protocol validation level."""
582
+ response = await client.post(
583
+ "/v1/chat/completions/derender",
584
+ json={
585
+ "model": MODEL_NAME,
586
+ "generate_response": _make_generate_response([-1, 42, 100]),
587
+ },
588
+ )
589
+ # vLLM's validation_exception_handler converts Pydantic errors to 400
590
+ assert response.status_code == 400
591
+
592
+
593
+@pytest.mark.asyncio
594
+async def test_derender_chat_oversized_logprobs_rejected(client):
595
+ """logprobs.content longer than max_model_len returns 400."""
596
+ oversized_logprobs: dict = {
597
+ "content": [
598
+ {"token": "x", "logprob": -1.0, "bytes": None, "top_logprobs": []}
599
+ for _ in range(1_000_000)
600
+ ]
601
+ }
602
+ response = await client.post(
603
+ "/v1/chat/completions/derender",
604
+ json={
605
+ "model": MODEL_NAME,
606
+ "generate_response": {
607
+ "request_id": "test-logprobs-bound",
608
+ "choices": [
609
+ {
610
+ "index": 0,
611
+ "token_ids": [42],
612
+ "finish_reason": "stop",
613
+ "logprobs": oversized_logprobs,
614
+ }
615
+ ],
616
+ },
617
+ },
618
+ )
619
+ assert response.status_code == 400
620
+ assert "logprobs.content length" in response.json()["error"]["message"]
621
+
622
+
623
+@pytest.mark.asyncio
624
+async def test_derender_chat_oversized_top_logprobs_rejected(client):
625
+ """top_logprobs count exceeding 20 returns 400."""
626
+ oversized_top_logprobs = {
627
+ "content": [
628
+ {
629
+ "token": "x",
630
+ "logprob": -1.0,
631
+ "bytes": None,
632
+ "top_logprobs": [
633
+ {"token": f"t{i}", "logprob": -float(i), "bytes": None}
634
+ for i in range(25)
635
+ ],
636
+ }
637
+ ]
638
+ }
639
+ response = await client.post(
640
+ "/v1/chat/completions/derender",
641
+ json={
642
+ "model": MODEL_NAME,
643
+ "generate_response": {
644
+ "request_id": "test-top-logprobs-bound",
645
+ "choices": [
646
+ {
647
+ "index": 0,
648
+ "token_ids": [42],
649
+ "finish_reason": "stop",
650
+ "logprobs": oversized_top_logprobs,
651
+ }
652
+ ],
653
+ },
654
+ },
655
+ )
656
+ assert response.status_code == 400
657
+ assert "top_logprobs count" in response.json()["error"]["message"]
658
+
659
+
660
+@pytest.mark.asyncio
661
+async def test_derender_completion_oversized_token_ids_rejected(client):
662
+ """Completion endpoint also rejects oversized token_ids."""
663
+ oversized_ids = [42] * 1_000_000
664
+ response = await client.post(
665
+ "/v1/completions/derender",
666
+ json={
667
+ "model": MODEL_NAME,
668
+ "generate_responses": [
669
+ {
670
+ "request_id": "gen-0",
671
+ "choices": [
672
+ {
673
+ "index": 0,
674
+ "token_ids": oversized_ids,
675
+ "finish_reason": "stop",
676
+ }
677
+ ],
678
+ }
679
+ ],
680
+ },
681
+ )
682
+ assert response.status_code == 400
683
+ assert "max_model_len" in response.json()["error"]["message"]
684
+
685
+
686
# ---------------------------------------------------------------------------
687
# E2E: render -> derender roundtrip with parser (reasoning + tool calls)
688
# ---------------------------------------------------------------------------
vllm/entrypoints/scale_out/derender/serving.py
+71
-3
@@ -3,6 +3,7 @@
3
import time
4
from typing import cast
5
6
+import vllm.envs as envs
7
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionResponse
8
from vllm.entrypoints.openai.completion.protocol import CompletionResponse
9
from vllm.entrypoints.openai.engine.protocol import (
@@ -28,6 +29,7 @@ from ..token_in_token_out.mm_serde import encode_mm_kwargs_item
29
from ..token_in_token_out.protocol import (
30
DerenderChatRequest,
31
DerenderCompletionRequest,
32
+ GenerateResponse,
33
MultiModalFeatures,
34
PlaceholderRangeInfo,
35
)
@@ -51,6 +53,64 @@ class ServingDerender(BaseServing):
53
54
self.online_derenderer = online_derenderer
55
56
+ def _validate_derender_bounds(
57
+ self,
58
+ generate_responses: list[GenerateResponse],
59
+ ) -> ErrorResponse | None:
60
+ """Reject derender payloads that exceed resource bounds.
61
+
62
+ Runs before any tokenizer.decode() or parser invocation to prevent
63
+ CPU/memory exhaustion from oversized caller-supplied token structures.
64
+ """
65
+ max_n = envs.VLLM_MAX_N_SEQUENCES
66
+ max_model_len = self.model_config.max_model_len
67
+
68
+ if len(generate_responses) > max_n:
69
+ return self.create_error_response(
70
+ f"generate_responses count ({len(generate_responses)}) "
71
+ f"exceeds server maximum ({max_n}). "
72
+ f"Set VLLM_MAX_N_SEQUENCES to increase this limit."
73
+ )
74
+
75
+ for gen in generate_responses:
76
+ if len(gen.choices) > max_n:
77
+ return self.create_error_response(
78
+ f"choices count ({len(gen.choices)}) in response "
79
+ f"'{gen.request_id}' exceeds server maximum ({max_n})."
80
+ )
81
+
82
+ for choice in gen.choices:
83
+ if choice.token_ids and len(choice.token_ids) > max_model_len:
84
+ return self.create_error_response(
85
+ f"token_ids length ({len(choice.token_ids)}) in "
86
+ f"choice {choice.index} exceeds "
87
+ f"max_model_len ({max_model_len})."
88
+ )
89
+ if choice.logprobs and choice.logprobs.content:
90
+ if len(choice.logprobs.content) > max_model_len:
91
+ return self.create_error_response(
92
+ f"logprobs.content length "
93
+ f"({len(choice.logprobs.content)}) in "
94
+ f"choice {choice.index} exceeds "
95
+ f"max_model_len ({max_model_len})."
96
+ )
97
+ for entry in choice.logprobs.content:
98
+ if entry.top_logprobs and len(entry.top_logprobs) > 20:
99
+ return self.create_error_response(
100
+ f"top_logprobs count "
101
+ f"({len(entry.top_logprobs)}) in "
102
+ f"choice {choice.index} exceeds maximum (20)."
103
+ )
104
+
105
+ if gen.prompt_logprobs and len(gen.prompt_logprobs) > max_model_len:
106
+ return self.create_error_response(
107
+ f"prompt_logprobs length ({len(gen.prompt_logprobs)}) "
108
+ f"in response '{gen.request_id}' exceeds "
109
+ f"max_model_len ({max_model_len})."
110
+ )
111
+
112
+ return None
113
+
114
async def derender_chat_response(
115
self,
116
request: DerenderChatRequest,
@@ -68,6 +128,10 @@ class ServingDerender(BaseServing):
128
if error_check_ret is not None:
129
return error_check_ret
130
131
+ bounds_error = self._validate_derender_bounds([request.generate_response])
132
+ if bounds_error is not None:
133
+ return bounds_error
134
+
135
try:
136
choices = await self.online_derenderer.derender_chat(
137
request.generate_response, request.chat_request
@@ -117,6 +181,13 @@ class ServingDerender(BaseServing):
181
if error_check_ret is not None:
182
return error_check_ret
183
184
+ if not request.generate_responses:
185
+ return self.create_error_response("generate_responses must not be empty")
186
+
187
+ bounds_error = self._validate_derender_bounds(request.generate_responses)
188
+ if bounds_error is not None:
189
+ return bounds_error
190
+
191
(
192
choices,
193
total_prompt_tokens,
@@ -125,9 +196,6 @@ class ServingDerender(BaseServing):
196
request.generate_responses, request.prompt_tokens
197
)
198
128
- if not request.generate_responses:
129
- return self.create_error_response("generate_responses must not be empty")
130
-
199
first = request.generate_responses[0]
200
kv_params = first.kv_transfer_params
201
if any(
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+7
@@ -190,6 +190,13 @@ class GenerateResponseChoice(BaseModel):
190
# or (b) ``enable_return_routed_experts`` is off server-side.
191
routed_experts: str | None = None
192
193
+ @field_validator("token_ids")
194
+ @classmethod
195
+ def validate_token_ids(cls, v: list[int] | None) -> list[int] | None:
196
+ if v is not None and any(t < 0 for t in v):
197
+ raise ValueError("token_ids must not contain negative values")
198
+ return v
199
+
200
201
class GenerateResponseStreamChoice(BaseModel):
202
index: int