[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"