[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