[Bugfix] Unreadable `prompt_embeds` payload should be a 400, not a 500 (#55451)
Signed-off-by: lzhan011 <35493221+lzhan011@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
lzhan011 committed
Sep 16, 2026 at 00:53 UTC
323a29d8f2b2a50ed77a6173f120822376a8b14a
4 files changed
+230
-17
tests/renderers/test_prompt_embeds_payload_validation.py
new
+163
@@ -0,0 +1,163 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""`prompt_embeds` payloads that are not tensor files must be client errors.
4
+
5
+`prompt_embeds` is a base64 blob the caller writes, and every other way of
6
+getting it wrong -- wrong rank, wrong hidden_size, wrong dtype, not a tensor at
7
+all -- already answers 400. A payload that `torch.load` cannot read at all was
8
+the exception: torch has no single error type for that (the zip reader raises
9
+`RuntimeError`, the legacy pickle path raises an unpickling error, `KeyError` or
10
+`IndexError`, an empty payload raises `EOFError`), none of them is a
11
+`ValueError`, and the entrypoints' fallback therefore mapped them to 500.
12
+
13
+The assertions go through `create_error_response`, which is what actually turns
14
+an exception into a status code, so they pin the HTTP answer rather than the
15
+exception class alone.
16
+"""
17
+
18
+import io
19
+import zipfile
20
+from unittest.mock import patch
21
+
22
+import pybase64 as base64
23
+import pytest
24
+import torch
25
+
26
+from vllm.entrypoints.serve.exception_handling.error_response import (
27
+ create_error_response,
28
+)
29
+from vllm.exceptions import VLLMValidationError
30
+from vllm.renderers.embed_utils import (
31
+ _MAX_EMBED_ERROR_REASON_CHARS,
32
+ safe_load_prompt_embeds,
33
+)
34
+
35
+
36
+@pytest.fixture
37
+def model_config():
38
+ from vllm.config import ModelConfig
39
+
40
+ return ModelConfig(
41
+ model="facebook/opt-125m",
42
+ tokenizer="facebook/opt-125m",
43
+ tokenizer_mode="auto",
44
+ trust_remote_code=False,
45
+ dtype="float32",
46
+ seed=0,
47
+ enable_prompt_embeds=True,
48
+ )
49
+
50
+
51
+def _encode(raw: bytes) -> bytes:
52
+ return base64.b64encode(raw)
53
+
54
+
55
+def _zip_without_a_tensor() -> bytes:
56
+ buffer = io.BytesIO()
57
+ with zipfile.ZipFile(buffer, "w") as archive:
58
+ archive.writestr("junk.txt", "hello")
59
+ return buffer.getvalue()
60
+
61
+
62
+def _saved_tensor(tensor: torch.Tensor) -> bytes:
63
+ buffer = io.BytesIO()
64
+ torch.save(tensor, buffer)
65
+ return buffer.getvalue()
66
+
67
+
68
+UNPARSABLE_PAYLOADS = [
69
+ pytest.param(b"", id="empty"),
70
+ pytest.param(b"\x80", id="single-byte"),
71
+ pytest.param(b"\x00\x01\x02\x03not-a-tensor", id="random-bytes"),
72
+ pytest.param(b"hello world this is not a torch file", id="ascii-text"),
73
+ pytest.param(b"PK\x03\x04" + b"\x00" * 20, id="truncated-zip"),
74
+ pytest.param(_zip_without_a_tensor(), id="zip-without-a-tensor"),
75
+ # pickle.dumps(42): a valid pickle stream that is not a torch checkpoint.
76
+ # Spelled out rather than built, so this file does not import pickle.
77
+ pytest.param(b"\x80\x04K*.", id="legacy-pickle-of-an-int"),
78
+]
79
+
80
+
81
+@pytest.mark.parametrize("payload", UNPARSABLE_PAYLOADS)
82
+def test_unreadable_prompt_embeds_payload_is_a_client_error(model_config, payload):
83
+ with pytest.raises(VLLMValidationError) as excinfo:
84
+ safe_load_prompt_embeds(model_config, _encode(payload))
85
+
86
+ assert excinfo.value.parameter == "prompt_embeds"
87
+ assert int(create_error_response(excinfo.value).error.code) == 400
88
+
89
+
90
+@pytest.mark.parametrize("payload", UNPARSABLE_PAYLOADS)
91
+def test_the_error_body_stays_bounded(model_config, payload):
92
+ """torch's reason is built from the caller's bytes, so it must be truncated.
93
+
94
+ An unpickler that reports the offending global, for instance, quotes a name
95
+ the caller chose; without a bound a large one would be reflected whole.
96
+ """
97
+ padding = b"A" * 100_000
98
+ with pytest.raises(VLLMValidationError) as excinfo:
99
+ safe_load_prompt_embeds(model_config, _encode(payload + padding))
100
+
101
+ assert len(str(excinfo.value)) < 500
102
+
103
+
104
+def test_a_long_reason_is_truncated_and_says_how_much_was_left_out(model_config):
105
+ """The cap is pinned against a reason of known length, not a real payload.
106
+
107
+ Which of torch's messages happen to exceed the cap is torch's business and
108
+ changes between releases, so the reason is injected here: that keeps the
109
+ assertion about *this* code's truncation rule rather than about whichever
110
+ error text the installed torch produces.
111
+ """
112
+ reason = "E" * (_MAX_EMBED_ERROR_REASON_CHARS + 37)
113
+
114
+ with (
115
+ patch(
116
+ "vllm.renderers.embed_utils.torch.load", side_effect=RuntimeError(reason)
117
+ ),
118
+ pytest.raises(VLLMValidationError) as excinfo,
119
+ ):
120
+ safe_load_prompt_embeds(model_config, _encode(b"ignored"))
121
+
122
+ message = str(excinfo.value)
123
+ assert "E" * _MAX_EMBED_ERROR_REASON_CHARS in message
124
+ assert "E" * (_MAX_EMBED_ERROR_REASON_CHARS + 1) not in message
125
+ assert "... (37 more characters truncated)" in message
126
+
127
+
128
+def test_a_short_reason_is_passed_through_whole(model_config):
129
+ """A reason that fits is not annotated -- the count only appears when it is
130
+ telling the reader something."""
131
+ reason = "invalid magic number; corrupt file?"
132
+ assert len(reason) <= _MAX_EMBED_ERROR_REASON_CHARS
133
+
134
+ with (
135
+ patch(
136
+ "vllm.renderers.embed_utils.torch.load", side_effect=RuntimeError(reason)
137
+ ),
138
+ pytest.raises(VLLMValidationError) as excinfo,
139
+ ):
140
+ safe_load_prompt_embeds(model_config, _encode(b"ignored"))
141
+
142
+ message = str(excinfo.value)
143
+ assert reason in message
144
+ assert "truncated" not in message
145
+
146
+
147
+def test_a_well_formed_payload_still_loads(model_config):
148
+ """Positive control: the healthy path is untouched."""
149
+ hidden_size = model_config.get_hidden_size()
150
+ tensor = torch.zeros(3, hidden_size, dtype=torch.float32)
151
+
152
+ loaded = safe_load_prompt_embeds(model_config, _encode(_saved_tensor(tensor)))
153
+
154
+ assert loaded.shape == (3, hidden_size)
155
+ assert loaded.dtype == model_config.dtype
156
+
157
+
158
+def test_a_payload_that_is_not_base64_is_still_a_client_error(model_config):
159
+ """Positive control: the base64 stage already answered 400 and still does."""
160
+ with pytest.raises(ValueError) as excinfo:
161
+ safe_load_prompt_embeds(model_config, b"!!!!not base64!!!!")
162
+
163
+ assert int(create_error_response(excinfo.value).error.code) == 400
tests/renderers/test_sparse_tensor_concurrent_race.py
+6
-5
@@ -11,6 +11,7 @@ import pytest
11
import torch
12
13
from vllm.config import ModelConfig
14
+from vllm.exceptions import VLLMValidationError
15
from vllm.multimodal.media import AudioEmbeddingMediaIO, ImageEmbeddingMediaIO
16
from vllm.renderers.embed_utils import safe_load_prompt_embeds
17
from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
@@ -54,7 +55,7 @@ class TestNegativeControlWithoutRace:
55
56
def test_malicious_sparse_rejected_by_prompt_loader(self, model_config):
57
encoded = _encode_tensor(_create_malicious_sparse_tensor())
57
- with pytest.raises((RuntimeError, ValueError)):
58
+ with pytest.raises((RuntimeError, ValueError, VLLMValidationError)):
59
safe_load_prompt_embeds(model_config, encoded)
60
61
def test_malicious_sparse_rejected_by_image_loader(self):
@@ -147,7 +148,7 @@ class TestConcurrentRaceProtection:
148
try:
149
safe_load_prompt_embeds(model_config, malicious_encoded)
150
bypassed.append(True)
150
- except (RuntimeError, ValueError):
151
+ except (RuntimeError, ValueError, VLLMValidationError):
152
rejected.append(True)
153
154
with ThreadPoolExecutor(max_workers=num_workers) as pool:
@@ -192,7 +193,7 @@ class TestGlobalFlagRestoration:
193
initial = torch.sparse.check_sparse_tensor_invariants.is_enabled()
194
malicious_encoded = _encode_tensor(_create_malicious_sparse_tensor())
195
195
- with pytest.raises((RuntimeError, ValueError)):
196
+ with pytest.raises((RuntimeError, ValueError, VLLMValidationError)):
197
safe_load_prompt_embeds(model_config, malicious_encoded)
198
199
assert torch.sparse.check_sparse_tensor_invariants.is_enabled() == initial
@@ -203,7 +204,7 @@ class TestGlobalFlagRestoration:
204
malicious_encoded = _encode_tensor(_create_malicious_sparse_tensor())
205
206
def attempt_load(_):
206
- with contextlib.suppress(RuntimeError, ValueError):
207
+ with contextlib.suppress(RuntimeError, ValueError, VLLMValidationError):
208
safe_load_prompt_embeds(model_config, malicious_encoded)
209
210
with ThreadPoolExecutor(max_workers=4) as pool:
@@ -244,7 +245,7 @@ class TestCrossLoaderLockSharing:
245
# serialization — one finishes before the other starts), AND invalid
246
# tensors are still rejected.
247
malicious = _encode_tensor(_create_malicious_sparse_tensor())
247
- with pytest.raises((RuntimeError, ValueError)):
248
+ with pytest.raises((RuntimeError, ValueError, VLLMValidationError)):
249
safe_load_prompt_embeds(model_config, malicious)
250
with pytest.raises((RuntimeError, ValueError)):
251
io_handler.load_base64("", malicious.decode("utf-8"))
tests/renderers/test_sparse_tensor_validation.py
+8
-6
@@ -117,8 +117,10 @@ class TestPromptEmbedsValidation:
117
malicious_tensor = _create_malicious_sparse_tensor()
118
encoded = _encode_tensor(malicious_tensor)
119
120
- # Should raise RuntimeError due to invalid sparse tensor
121
- with pytest.raises((RuntimeError, ValueError)) as exc_info:
120
+ # The invariant check raises RuntimeError from inside torch.load;
121
+ # safe_load_prompt_embeds now reports that as a client error so the
122
+ # caller gets a 400 rather than the 500 a bare RuntimeError produced.
123
+ with pytest.raises(VLLMValidationError) as exc_info:
124
safe_load_prompt_embeds(model_config, encoded)
125
126
# Error should indicate sparse tensor validation failure
@@ -137,7 +139,7 @@ class TestPromptEmbedsValidation:
139
)
140
encoded = _encode_tensor(malicious_tensor)
141
140
- with pytest.raises((RuntimeError, ValueError)):
142
+ with pytest.raises(VLLMValidationError):
143
safe_load_prompt_embeds(model_config, encoded)
144
145
def test_negative_indices_rejected(self, model_config):
@@ -152,7 +154,7 @@ class TestPromptEmbedsValidation:
154
)
155
encoded = _encode_tensor(malicious_tensor)
156
155
- with pytest.raises((RuntimeError, ValueError)):
157
+ with pytest.raises(VLLMValidationError):
158
safe_load_prompt_embeds(model_config, encoded)
159
160
def test_hidden_size_mismatch_rejected(self, model_config):
@@ -379,8 +381,8 @@ class TestSparseTensorValidationIntegration:
381
# Step 1-2: Attacker creates malicious payload
382
attack_payload = _encode_tensor(_create_malicious_sparse_tensor())
383
382
- # Step 3-4: Server processes and should reject
383
- with pytest.raises((RuntimeError, ValueError)):
384
+ # Step 3-4: Server processes and should reject, as a client error
385
+ with pytest.raises(VLLMValidationError):
386
safe_load_prompt_embeds(model_config, attack_payload)
387
388
def test_attack_scenario_chat_api_image(self):
vllm/renderers/embed_utils.py
+53
-6
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from io import BytesIO
4
-from typing import TYPE_CHECKING
4
+from typing import TYPE_CHECKING, Final
5
6
import pybase64
7
import torch
@@ -17,6 +17,27 @@ if TYPE_CHECKING:
17
from vllm.config import ModelConfig
18
19
20
+# How much of torch's own reason is repeated back to the caller. The reason is
21
+# built from bytes the caller sent, so it is bounded rather than echoed whole.
22
+_MAX_EMBED_ERROR_REASON_CHARS: Final = 200
23
+
24
+
25
+def _truncated_reason(exc: Exception) -> str:
26
+ """`exc`'s message, capped, saying how much was left out when it is capped.
27
+
28
+ Without the count a capped reason reads like the whole one, so a caller
29
+ debugging a large payload cannot tell that torch said more than this.
30
+ """
31
+ reason: Final = str(exc).strip() or type(exc).__name__
32
+ omitted: Final = len(reason) - _MAX_EMBED_ERROR_REASON_CHARS
33
+ if omitted <= 0:
34
+ return reason
35
+ return (
36
+ f"{reason[:_MAX_EMBED_ERROR_REASON_CHARS]}"
37
+ f"... ({omitted} more characters truncated)"
38
+ )
39
+
40
+
41
def safe_load_prompt_embeds(
42
model_config: "ModelConfig",
43
embed: bytes,
@@ -27,12 +48,38 @@ def safe_load_prompt_embeds(
48
parameter="prompt_embeds",
49
)
50
51
+ # Decoded outside the try below so that a body which is not base64 keeps
52
+ # raising binascii.Error, which is a ValueError and already answered 400.
53
+ payload: Final = pybase64.b64decode(embed, validate=True)
54
+
55
with check_sparse_tensor_invariants_threadsafe():
31
- tensor = torch.load(
32
- BytesIO(pybase64.b64decode(embed, validate=True)),
33
- weights_only=True,
34
- map_location=torch.device("cpu"),
35
- )
56
+ try:
57
+ tensor = torch.load(
58
+ BytesIO(payload),
59
+ weights_only=True,
60
+ map_location=torch.device("cpu"),
61
+ )
62
+ except (torch.OutOfMemoryError, MemoryError):
63
+ # Running out of memory is the server's condition, not the caller's.
64
+ raise
65
+ except Exception as exc:
66
+ # Everything else here is about the bytes the caller sent, and torch
67
+ # has no single error type for "this is not a tensor file": the zip
68
+ # reader raises `RuntimeError`, the legacy pickle path raises
69
+ # `UnpicklingError`, `KeyError` or `IndexError`, an empty payload
70
+ # raises `EOFError`. None of them is a `ValueError`, so the
71
+ # entrypoints' fallback mapped them all to 500. Catching by
72
+ # behaviour rather than by type also survives torch changing which
73
+ # error it raises for a given malformed payload.
74
+ #
75
+ # torch's reason is worth keeping -- for a malformed sparse tensor
76
+ # it names the offending index -- but it is built from the caller's
77
+ # own bytes, so it is truncated rather than echoed whole.
78
+ raise VLLMValidationError(
79
+ "`prompt_embeds` could not be deserialized as a torch tensor: "
80
+ f"{_truncated_reason(exc)}",
81
+ parameter="prompt_embeds",
82
+ ) from exc
83
tensor = safe_to_dense(tensor, parameter="prompt_embeds")
84
85
if tensor.dim() > 2: