[Bugfix][Security] Bound the validation-error response body (#54684)
Signed-off-by: lzhan011 <35493221+lzhan011@users.noreply.github.com>
lzhan011 committed
Sep 1, 2026 at 01:51 UTC
fa99a6fea6dcc0de51707d5db26edcd6c662b5d9
2 files changed
+176
-1
tests/entrypoints/serve/exception_handling/test_validation_exception_handler.py
+118
@@ -151,3 +151,121 @@ class TestValidationErrorDoesNotLeakServerPaths:
151
152
assert "missing" in message
153
assert "Field required" in message
154
+
155
+
156
+class TestValidationErrorBodyIsBounded:
157
+ """A malformed request must not produce an unbounded 400 body.
158
+
159
+ Pydantic repeats the whole offending value under `input` in every
160
+ error entry, so an input that fails per-element echoes itself once
161
+ per error. On `/v1/responses` a 4,475-byte body produced 12,001
162
+ errors and a 23.6 MB response before this was bounded. See #49239.
163
+ """
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_many_errors_are_capped(self):
167
+ big_input = [{"type": "input_text", "text": 1} for _ in range(500)]
168
+ errors = [
169
+ {
170
+ "type": "string_type",
171
+ "loc": ("body", "input", i),
172
+ "msg": "Input should be a valid string",
173
+ "input": big_input,
174
+ }
175
+ for i in range(5000)
176
+ ]
177
+ exc = RequestValidationError(errors)
178
+
179
+ response = await validation_exception_handler(_fake_request(), exc)
180
+ body = json.loads(response.body)
181
+ message = body["error"]["message"]
182
+
183
+ assert len(response.body) < 32_000, f"body was {len(response.body)} bytes"
184
+ # The true count is still reported, and the remainder acknowledged.
185
+ assert message.startswith("5000 validation errors:")
186
+ assert "more errors" in message
187
+
188
+ @pytest.mark.asyncio
189
+ async def test_container_input_is_described_not_echoed(self):
190
+ errors = [
191
+ {
192
+ "type": "string_type",
193
+ "loc": ("body", "input"),
194
+ "msg": "Input should be a valid string",
195
+ "input": [{"text": "payload"} for _ in range(300)],
196
+ }
197
+ ]
198
+ exc = RequestValidationError(errors)
199
+
200
+ response = await validation_exception_handler(_fake_request(), exc)
201
+ message = json.loads(response.body)["error"]["message"]
202
+
203
+ assert "<list of 300 items>" in message
204
+ assert "payload" not in message
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_long_string_input_is_truncated(self):
208
+ errors = [
209
+ {
210
+ "type": "int_type",
211
+ "loc": ("body", "max_tokens"),
212
+ "msg": "Input should be a valid integer",
213
+ "input": "A" * 100_000,
214
+ }
215
+ ]
216
+ exc = RequestValidationError(errors)
217
+
218
+ response = await validation_exception_handler(_fake_request(), exc)
219
+ message = json.loads(response.body)["error"]["message"]
220
+
221
+ assert "[truncated]" in message
222
+ assert len(message) < 4_000
223
+
224
+ @pytest.mark.asyncio
225
+ async def test_small_input_is_still_reported_verbatim(self):
226
+ """Bounding must not cost legitimate clients their diagnostics."""
227
+ errors = [
228
+ {
229
+ "type": "list_type",
230
+ "loc": ("body", "messages"),
231
+ "msg": "Input should be a valid list",
232
+ "input": "not-a-list",
233
+ }
234
+ ]
235
+ exc = RequestValidationError(errors)
236
+
237
+ response = await validation_exception_handler(_fake_request(), exc)
238
+ message = json.loads(response.body)["error"]["message"]
239
+
240
+ assert "not-a-list" in message
241
+ assert "[truncated]" not in message
242
+
243
+ @pytest.mark.asyncio
244
+ async def test_union_loc_is_cleaned_in_the_message(self):
245
+ """A union-typed field spells every branch out in `loc`.
246
+
247
+ Left raw, that is ~800 characters of type names per entry and it
248
+ dominates the bounded message. `param` is already cleaned this way.
249
+ """
250
+ loc = (
251
+ "body",
252
+ "input",
253
+ "list[union[EasyInputMessageParam,Message,ResponseOutputMessageParam]]",
254
+ 0,
255
+ "content",
256
+ )
257
+ errors = [
258
+ {
259
+ "type": "string_type",
260
+ "loc": loc,
261
+ "msg": "Input should be a valid string",
262
+ "input": 12345,
263
+ }
264
+ ]
265
+ exc = RequestValidationError(errors)
266
+
267
+ response = await validation_exception_handler(_fake_request(), exc)
268
+ message = json.loads(response.body)["error"]["message"]
269
+
270
+ assert "'loc': 'body.input.0.content'" in message
271
+ assert "EasyInputMessageParam" not in message
vllm/entrypoints/serve/exception_handling/handlers/validation.py
+58
-1
@@ -70,6 +70,60 @@ _INTERNAL_LOC_MARKERS = frozenset(
70
)
71
72
73
+# Pydantic repeats the whole offending value under `input` in *every*
74
+# error entry, so one malformed request whose input fails per-element
75
+# echoes that input once per error. Measured on `/v1/responses`: a
76
+# 4,475-byte body produced 12,001 errors and a 23.6 MB response, an
77
+# amplification of ~5,300x that scales linearly with the request size.
78
+# Bound both the number of entries reported and the value echoed in each.
79
+_MAX_REPORTED_ERRORS = 10
80
+_MAX_ERROR_INPUT_CHARS = 200
81
+_MAX_ERROR_CHARS = 1000
82
+
83
+
84
+def _summarize_error_input(value: object) -> object:
85
+ """A size-bounded stand-in for a validation error's `input` value.
86
+
87
+ Containers are described rather than rendered: `repr()` of a large
88
+ list materializes the whole string first, which is the cost this is
89
+ meant to avoid.
90
+ """
91
+ if isinstance(value, str):
92
+ if len(value) > _MAX_ERROR_INPUT_CHARS:
93
+ return value[:_MAX_ERROR_INPUT_CHARS] + "...[truncated]"
94
+ return value
95
+ if isinstance(value, (bytes, bytearray)):
96
+ return f"<{type(value).__name__} of {len(value)} bytes>"
97
+ if isinstance(value, (list, tuple, set, frozenset, dict)):
98
+ return f"<{type(value).__name__} of {len(value)} items>"
99
+ try:
100
+ text = str(value)
101
+ except Exception:
102
+ # e.g. an int past `sys.get_int_max_str_digits()`.
103
+ return f"<{type(value).__name__}>"
104
+ if len(text) > _MAX_ERROR_INPUT_CHARS:
105
+ return text[:_MAX_ERROR_INPUT_CHARS] + "...[truncated]"
106
+ return text
107
+
108
+
109
+def _format_error(err: object) -> str:
110
+ """Render one validation error, bounded in size."""
111
+ if isinstance(err, dict):
112
+ err = dict(err)
113
+ if "input" in err:
114
+ err["input"] = _summarize_error_input(err["input"])
115
+ loc = err.get("loc")
116
+ if isinstance(loc, (tuple, list)):
117
+ # For a union-typed field pydantic spells every branch out in
118
+ # `loc`, which is ~800 characters of type names per entry here.
119
+ # `param` is already cleaned this way.
120
+ err["loc"] = clean_loc_for_param(tuple(loc))
121
+ text = str(err)
122
+ if len(text) > _MAX_ERROR_CHARS:
123
+ text = text[:_MAX_ERROR_CHARS] + "...[truncated]"
124
+ return text
125
+
126
+
127
def _is_internal_loc_segment(segment: str) -> bool:
128
"""True if `segment` is a Pydantic-internal wrapper/union-branch
129
marker rather than a user-meaningful field name or list index."""
@@ -121,8 +175,11 @@ async def validation_exception_handler(req: Request, exc: RequestValidationError
175
if errors:
176
count = len(errors)
177
label = "error" if count == 1 else "errors"
178
+ reported = errors[:_MAX_REPORTED_ERRORS]
179
message = f"{count} validation {label}:\n"
125
- message += "".join(f" {err}\n" for err in errors)
180
+ message += "".join(f" {_format_error(err)}\n" for err in reported)
181
+ if count > len(reported):
182
+ message += f" ...and {count - len(reported)} more {label}\n"
183
message = message.rstrip()
184
else:
185
message = "Validation error"