[Security] Validate cache salts before they reach LMCache (#51444)

Signed-off-by: Clinton Thomas <1033162+KernelClint@users.noreply.github.com> Co-authored-by: Lucas Bourtoule <35483370+dhalf@users.noreply.github.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>

Clinton Thomas committed Sep 4, 2026 at 23:56 UTC e962733e08d10f7ca65dac4df99e116460b8b174
8 files changed +101 -3
tests/entrypoints/openai/chat_completion/test_non_object_body_validation.py
+2 -1
@@ -11,6 +11,7 @@ import pytest
11 from pydantic import ValidationError
12
13 from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
14 +from vllm.exceptions import VLLMValidationError
15
16
17 @pytest.mark.parametrize(
@@ -28,7 +29,7 @@ def test_chat_completion_request_rejects_non_object_body(payload):
29
30
31 def test_chat_completion_request_cache_salt_still_validated_on_dict():
31 - with pytest.raises(ValidationError, match="cache_salt"):
32 + with pytest.raises(VLLMValidationError, match="cache_salt"):
33 ChatCompletionRequest.model_validate(
34 {
35 "model": "qwen",
tests/entrypoints/unit_tests/test_non_object_body_validation.py
+32
@@ -13,11 +13,13 @@ request models whose before-validators were missing the same guard.
13 import pytest
14 from pydantic import ValidationError
15
16 +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
17 from vllm.entrypoints.openai.completion.protocol import CompletionRequest
18 from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
19 from vllm.entrypoints.pooling.classify.protocol import ClassificationChatRequest
20 from vllm.entrypoints.pooling.embed.protocol import EmbeddingChatRequest
21 from vllm.entrypoints.pooling.pooling.protocol import PoolingChatRequest
22 +from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateRequest
23 from vllm.entrypoints.serve.tokenize.protocol import TokenizeChatRequest
24 from vllm.entrypoints.speech_to_text.transcription.protocol import TranscriptionRequest
25 from vllm.entrypoints.speech_to_text.translation.protocol import TranslationRequest
@@ -69,3 +71,33 @@ def test_tokenize_chat_request_still_validates_dict_bodies():
71 "add_generation_prompt": True,
72 }
73 )
74 +
75 +
76 +CACHE_SALT_MODELS = [
77 + (CompletionRequest, {"prompt": "hello"}),
78 + (ChatCompletionRequest, {"messages": [{"role": "user", "content": "hello"}]}),
79 + (ResponsesRequest, {"input": "hello"}),
80 + (GenerateRequest, {"token_ids": [1], "sampling_params": {}}),
81 +]
82 +
83 +
84 +@pytest.mark.parametrize(
85 + ("request_model", "payload"),
86 + CACHE_SALT_MODELS,
87 + ids=[m.__name__ for m, _ in CACHE_SALT_MODELS],
88 +)
89 +def test_request_models_reject_forbidden_cache_salt(request_model, payload):
90 + with pytest.raises(VLLMValidationError, match="cache_salt"):
91 + request_model.model_validate({**payload, "cache_salt": "/"})
92 +
93 +
94 +def test_request_models_reject_overlong_cache_salt():
95 + with pytest.raises(VLLMValidationError, match="cache_salt"):
96 + CompletionRequest.model_validate({"prompt": "hello", "cache_salt": "a" * 129})
97 +
98 +
99 +def test_request_models_accept_safe_cache_salt():
100 + request = CompletionRequest.model_validate(
101 + {"prompt": "hello", "cache_salt": "safe-cache_salt-123"}
102 + )
103 + assert request.cache_salt == "safe-cache_salt-123"
vllm/entrypoints/generate/base/protocol.py
+22
@@ -29,6 +29,28 @@ StopParam: TypeAlias = (
29 str | Annotated[list[str], Field(max_length=envs.VLLM_MAX_STOP_STRINGS)] | None
30 )
31
32 +_CACHE_SALT_FORBIDDEN_CHARS = frozenset("@/\\\x00")
33 +_MAX_CACHE_SALT_LENGTH = 128
34 +
35 +
36 +def validate_cache_salt(cache_salt: object) -> None:
37 + """Validate cache salts before they reach downstream cache backends."""
38 + if cache_salt is None:
39 + return
40 + if not isinstance(cache_salt, str) or not cache_salt:
41 + raise VLLMValidationError(
42 + "Parameter 'cache_salt' must be a non-empty string if provided.",
43 + parameter="cache_salt",
44 + )
45 + if len(cache_salt) > _MAX_CACHE_SALT_LENGTH or any(
46 + char in _CACHE_SALT_FORBIDDEN_CHARS for char in cache_salt
47 + ):
48 + raise VLLMValidationError(
49 + "Parameter 'cache_salt' must be at most 128 characters and must "
50 + "not contain '@', '/', '\\\\', or NUL.",
51 + parameter="cache_salt",
52 + )
53 +
54
55 class SpeculativeDecodingMetrics(OpenAIBaseModel):
56 """Per-request speculative-decoding acceptance metrics.
vllm/entrypoints/openai/chat_completion/protocol.py
+9
@@ -33,6 +33,7 @@ from vllm.entrypoints.generate.base.protocol import (
33 StreamOptions,
34 ToolCall,
35 structured_outputs_from_response_format,
36 + validate_cache_salt,
37 validate_structural_tag_response_format,
38 validate_structured_outputs_structural_tag,
39 )
@@ -519,6 +520,14 @@ class ChatCompletionRequest(OpenAIBaseModel):
520
521 # --8<-- [end:chat-completion-extra-params]
522
523 + @model_validator(mode="before")
524 + @classmethod
525 + def check_cache_salt_support(cls, data: Any) -> Any:
526 + if not isinstance(data, dict):
527 + return data
528 + validate_cache_salt(data.get("cache_salt"))
529 + return data
530 +
531 @model_validator(mode="before")
532 @classmethod
533 def _normalize_messages_before(cls, data: Any) -> Any:
vllm/entrypoints/openai/completion/protocol.py
+9
@@ -16,6 +16,7 @@ from vllm.entrypoints.generate.base.protocol import (
16 StopParam,
17 StreamOptions,
18 structured_outputs_from_response_format,
19 + validate_cache_salt,
20 validate_structural_tag_response_format,
21 validate_structured_outputs_structural_tag,
22 )
@@ -402,6 +403,14 @@ class CompletionRequest(OpenAIBaseModel):
403 routed_experts_prompt_start=self.routed_experts_prompt_start,
404 )
405
406 + @model_validator(mode="before")
407 + @classmethod
408 + def check_cache_salt_support(cls, data: Any) -> Any:
409 + if not isinstance(data, dict):
410 + return data
411 + validate_cache_salt(data.get("cache_salt"))
412 + return data
413 +
414 @model_validator(mode="before")
415 @classmethod
416 def normalize_null_max_tokens(cls, data):
vllm/entrypoints/openai/responses/protocol.py
+9 -1
@@ -60,7 +60,7 @@ from vllm.entrypoints.chat_utils import (
60 ChatCompletionMessageParam,
61 ChatTemplateContentFormatOption,
62 )
63 -from vllm.entrypoints.generate.base.protocol import StopParam
63 +from vllm.entrypoints.generate.base.protocol import StopParam, validate_cache_salt
64 from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel
65 from vllm.exceptions import VLLMValidationError
66 from vllm.logger import init_logger
@@ -466,6 +466,14 @@ class ResponsesRequest(OpenAIBaseModel):
466 and "message.output_text.logprobs" in self.include
467 )
468
469 + @model_validator(mode="before")
470 + @classmethod
471 + def check_cache_salt_support(cls, data: Any) -> Any:
472 + if not isinstance(data, dict):
473 + return data
474 + validate_cache_salt(data.get("cache_salt"))
475 + return data
476 +
477 @model_validator(mode="before")
478 @classmethod
479 def validate_background(cls, data):
vllm/entrypoints/pooling/base/protocol.py
+10
@@ -11,6 +11,7 @@ from vllm.entrypoints.chat_utils import (
11 ChatCompletionMessageParam,
12 ChatTemplateContentFormatOption,
13 )
14 +from vllm.entrypoints.generate.base.protocol import validate_cache_salt
15 from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel
16 from vllm.exceptions import VLLMValidationError
17 from vllm.renderers import ChatParams, TokenizeParams, merge_kwargs
@@ -95,6 +96,15 @@ class PoolingBasicRequestMixin(OpenAIBaseModel):
96 )
97 # --8<-- [end:pooling-common-extra-params]
98
99 + @model_validator(mode="before")
100 + @classmethod
101 + def check_cache_salt_support(cls, data: Any) -> Any:
102 + if not isinstance(data, dict):
103 + return data
104 +
105 + validate_cache_salt(data.get("cache_salt"))
106 + return data
107 +
108 def _build_pooling_tok_params(
109 self,
110 model_config: ModelConfig,
vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+8 -1
@@ -11,7 +11,7 @@ from pydantic import (
11 )
12
13 from vllm.config import ModelConfig
14 -from vllm.entrypoints.generate.base.protocol import StreamOptions
14 +from vllm.entrypoints.generate.base.protocol import StreamOptions, validate_cache_salt
15 from vllm.entrypoints.openai.chat_completion.protocol import (
16 ChatCompletionLogProbs,
17 ChatCompletionRequest,
@@ -177,6 +177,13 @@ class GenerateRequest(BaseModel):
177 instance._sampling_params_provided_keys = provided
178 return instance
179
180 + @model_validator(mode="before")
181 + @classmethod
182 + def _validate_cache_salt(cls, data: Any) -> Any:
183 + if isinstance(data, dict):
184 + validate_cache_salt(data.get("cache_salt"))
185 + return data
186 +
187 def is_sampling_param_provided(self, name: str) -> bool:
188 """Whether the caller explicitly set ``sampling_params.<name>``.
189