[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>
This commit is contained in:
Clinton Thomas
2026-09-05 03:56:06 +00:00
committed by GitHub
co-authored by Lucas Bourtoule Cyrus Leung
parent 4ee2595512
commit e962733e08
8 changed files with 101 additions and 3 deletions
@@ -11,6 +11,7 @@ import pytest
from pydantic import ValidationError
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.exceptions import VLLMValidationError
@pytest.mark.parametrize(
@@ -28,7 +29,7 @@ def test_chat_completion_request_rejects_non_object_body(payload):
def test_chat_completion_request_cache_salt_still_validated_on_dict():
with pytest.raises(ValidationError, match="cache_salt"):
with pytest.raises(VLLMValidationError, match="cache_salt"):
ChatCompletionRequest.model_validate(
{
"model": "qwen",
@@ -13,11 +13,13 @@ request models whose before-validators were missing the same guard.
import pytest
from pydantic import ValidationError
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.completion.protocol import CompletionRequest
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.entrypoints.pooling.classify.protocol import ClassificationChatRequest
from vllm.entrypoints.pooling.embed.protocol import EmbeddingChatRequest
from vllm.entrypoints.pooling.pooling.protocol import PoolingChatRequest
from vllm.entrypoints.scale_out.token_in_token_out.protocol import GenerateRequest
from vllm.entrypoints.serve.tokenize.protocol import TokenizeChatRequest
from vllm.entrypoints.speech_to_text.transcription.protocol import TranscriptionRequest
from vllm.entrypoints.speech_to_text.translation.protocol import TranslationRequest
@@ -69,3 +71,33 @@ def test_tokenize_chat_request_still_validates_dict_bodies():
"add_generation_prompt": True,
}
)
CACHE_SALT_MODELS = [
(CompletionRequest, {"prompt": "hello"}),
(ChatCompletionRequest, {"messages": [{"role": "user", "content": "hello"}]}),
(ResponsesRequest, {"input": "hello"}),
(GenerateRequest, {"token_ids": [1], "sampling_params": {}}),
]
@pytest.mark.parametrize(
("request_model", "payload"),
CACHE_SALT_MODELS,
ids=[m.__name__ for m, _ in CACHE_SALT_MODELS],
)
def test_request_models_reject_forbidden_cache_salt(request_model, payload):
with pytest.raises(VLLMValidationError, match="cache_salt"):
request_model.model_validate({**payload, "cache_salt": "/"})
def test_request_models_reject_overlong_cache_salt():
with pytest.raises(VLLMValidationError, match="cache_salt"):
CompletionRequest.model_validate({"prompt": "hello", "cache_salt": "a" * 129})
def test_request_models_accept_safe_cache_salt():
request = CompletionRequest.model_validate(
{"prompt": "hello", "cache_salt": "safe-cache_salt-123"}
)
assert request.cache_salt == "safe-cache_salt-123"
@@ -29,6 +29,28 @@ StopParam: TypeAlias = (
str | Annotated[list[str], Field(max_length=envs.VLLM_MAX_STOP_STRINGS)] | None
)
_CACHE_SALT_FORBIDDEN_CHARS = frozenset("@/\\\x00")
_MAX_CACHE_SALT_LENGTH = 128
def validate_cache_salt(cache_salt: object) -> None:
"""Validate cache salts before they reach downstream cache backends."""
if cache_salt is None:
return
if not isinstance(cache_salt, str) or not cache_salt:
raise VLLMValidationError(
"Parameter 'cache_salt' must be a non-empty string if provided.",
parameter="cache_salt",
)
if len(cache_salt) > _MAX_CACHE_SALT_LENGTH or any(
char in _CACHE_SALT_FORBIDDEN_CHARS for char in cache_salt
):
raise VLLMValidationError(
"Parameter 'cache_salt' must be at most 128 characters and must "
"not contain '@', '/', '\\\\', or NUL.",
parameter="cache_salt",
)
class SpeculativeDecodingMetrics(OpenAIBaseModel):
"""Per-request speculative-decoding acceptance metrics.
@@ -33,6 +33,7 @@ from vllm.entrypoints.generate.base.protocol import (
StreamOptions,
ToolCall,
structured_outputs_from_response_format,
validate_cache_salt,
validate_structural_tag_response_format,
validate_structured_outputs_structural_tag,
)
@@ -519,6 +520,14 @@ class ChatCompletionRequest(OpenAIBaseModel):
# --8<-- [end:chat-completion-extra-params]
@model_validator(mode="before")
@classmethod
def check_cache_salt_support(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
validate_cache_salt(data.get("cache_salt"))
return data
@model_validator(mode="before")
@classmethod
def _normalize_messages_before(cls, data: Any) -> Any:
@@ -16,6 +16,7 @@ from vllm.entrypoints.generate.base.protocol import (
StopParam,
StreamOptions,
structured_outputs_from_response_format,
validate_cache_salt,
validate_structural_tag_response_format,
validate_structured_outputs_structural_tag,
)
@@ -402,6 +403,14 @@ class CompletionRequest(OpenAIBaseModel):
routed_experts_prompt_start=self.routed_experts_prompt_start,
)
@model_validator(mode="before")
@classmethod
def check_cache_salt_support(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
validate_cache_salt(data.get("cache_salt"))
return data
@model_validator(mode="before")
@classmethod
def normalize_null_max_tokens(cls, data):
@@ -60,7 +60,7 @@ from vllm.entrypoints.chat_utils import (
ChatCompletionMessageParam,
ChatTemplateContentFormatOption,
)
from vllm.entrypoints.generate.base.protocol import StopParam
from vllm.entrypoints.generate.base.protocol import StopParam, validate_cache_salt
from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel
from vllm.exceptions import VLLMValidationError
from vllm.logger import init_logger
@@ -466,6 +466,14 @@ class ResponsesRequest(OpenAIBaseModel):
and "message.output_text.logprobs" in self.include
)
@model_validator(mode="before")
@classmethod
def check_cache_salt_support(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
validate_cache_salt(data.get("cache_salt"))
return data
@model_validator(mode="before")
@classmethod
def validate_background(cls, data):
+10
View File
@@ -11,6 +11,7 @@ from vllm.entrypoints.chat_utils import (
ChatCompletionMessageParam,
ChatTemplateContentFormatOption,
)
from vllm.entrypoints.generate.base.protocol import validate_cache_salt
from vllm.entrypoints.serve.engine.protocol import OpenAIBaseModel
from vllm.exceptions import VLLMValidationError
from vllm.renderers import ChatParams, TokenizeParams, merge_kwargs
@@ -95,6 +96,15 @@ class PoolingBasicRequestMixin(OpenAIBaseModel):
)
# --8<-- [end:pooling-common-extra-params]
@model_validator(mode="before")
@classmethod
def check_cache_salt_support(cls, data: Any) -> Any:
if not isinstance(data, dict):
return data
validate_cache_salt(data.get("cache_salt"))
return data
def _build_pooling_tok_params(
self,
model_config: ModelConfig,
@@ -11,7 +11,7 @@ from pydantic import (
)
from vllm.config import ModelConfig
from vllm.entrypoints.generate.base.protocol import StreamOptions
from vllm.entrypoints.generate.base.protocol import StreamOptions, validate_cache_salt
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionLogProbs,
ChatCompletionRequest,
@@ -177,6 +177,13 @@ class GenerateRequest(BaseModel):
instance._sampling_params_provided_keys = provided
return instance
@model_validator(mode="before")
@classmethod
def _validate_cache_salt(cls, data: Any) -> Any:
if isinstance(data, dict):
validate_cache_salt(data.get("cache_salt"))
return data
def is_sampling_param_provided(self, name: str) -> bool:
"""Whether the caller explicitly set ``sampling_params.<name>``.