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