From fa99a6fea6dcc0de51707d5db26edcd6c662b5d9 Mon Sep 17 00:00:00 2001 From: lzhan011 <35493221+lzhan011@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:51:16 -0500 Subject: [PATCH] [Bugfix][Security] Bound the validation-error response body (#54684) Signed-off-by: lzhan011 <35493221+lzhan011@users.noreply.github.com> --- .../test_validation_exception_handler.py | 118 ++++++++++++++++++ .../exception_handling/handlers/validation.py | 59 ++++++++- 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/tests/entrypoints/serve/exception_handling/test_validation_exception_handler.py b/tests/entrypoints/serve/exception_handling/test_validation_exception_handler.py index 9d393b414f..942dd95c18 100644 --- a/tests/entrypoints/serve/exception_handling/test_validation_exception_handler.py +++ b/tests/entrypoints/serve/exception_handling/test_validation_exception_handler.py @@ -151,3 +151,121 @@ class TestValidationErrorDoesNotLeakServerPaths: assert "missing" in message assert "Field required" in message + + +class TestValidationErrorBodyIsBounded: + """A malformed request must not produce an unbounded 400 body. + + Pydantic repeats the whole offending value under `input` in every + error entry, so an input that fails per-element echoes itself once + per error. On `/v1/responses` a 4,475-byte body produced 12,001 + errors and a 23.6 MB response before this was bounded. See #49239. + """ + + @pytest.mark.asyncio + async def test_many_errors_are_capped(self): + big_input = [{"type": "input_text", "text": 1} for _ in range(500)] + errors = [ + { + "type": "string_type", + "loc": ("body", "input", i), + "msg": "Input should be a valid string", + "input": big_input, + } + for i in range(5000) + ] + exc = RequestValidationError(errors) + + response = await validation_exception_handler(_fake_request(), exc) + body = json.loads(response.body) + message = body["error"]["message"] + + assert len(response.body) < 32_000, f"body was {len(response.body)} bytes" + # The true count is still reported, and the remainder acknowledged. + assert message.startswith("5000 validation errors:") + assert "more errors" in message + + @pytest.mark.asyncio + async def test_container_input_is_described_not_echoed(self): + errors = [ + { + "type": "string_type", + "loc": ("body", "input"), + "msg": "Input should be a valid string", + "input": [{"text": "payload"} for _ in range(300)], + } + ] + exc = RequestValidationError(errors) + + response = await validation_exception_handler(_fake_request(), exc) + message = json.loads(response.body)["error"]["message"] + + assert "" in message + assert "payload" not in message + + @pytest.mark.asyncio + async def test_long_string_input_is_truncated(self): + errors = [ + { + "type": "int_type", + "loc": ("body", "max_tokens"), + "msg": "Input should be a valid integer", + "input": "A" * 100_000, + } + ] + exc = RequestValidationError(errors) + + response = await validation_exception_handler(_fake_request(), exc) + message = json.loads(response.body)["error"]["message"] + + assert "[truncated]" in message + assert len(message) < 4_000 + + @pytest.mark.asyncio + async def test_small_input_is_still_reported_verbatim(self): + """Bounding must not cost legitimate clients their diagnostics.""" + errors = [ + { + "type": "list_type", + "loc": ("body", "messages"), + "msg": "Input should be a valid list", + "input": "not-a-list", + } + ] + exc = RequestValidationError(errors) + + response = await validation_exception_handler(_fake_request(), exc) + message = json.loads(response.body)["error"]["message"] + + assert "not-a-list" in message + assert "[truncated]" not in message + + @pytest.mark.asyncio + async def test_union_loc_is_cleaned_in_the_message(self): + """A union-typed field spells every branch out in `loc`. + + Left raw, that is ~800 characters of type names per entry and it + dominates the bounded message. `param` is already cleaned this way. + """ + loc = ( + "body", + "input", + "list[union[EasyInputMessageParam,Message,ResponseOutputMessageParam]]", + 0, + "content", + ) + errors = [ + { + "type": "string_type", + "loc": loc, + "msg": "Input should be a valid string", + "input": 12345, + } + ] + exc = RequestValidationError(errors) + + response = await validation_exception_handler(_fake_request(), exc) + message = json.loads(response.body)["error"]["message"] + + assert "'loc': 'body.input.0.content'" in message + assert "EasyInputMessageParam" not in message diff --git a/vllm/entrypoints/serve/exception_handling/handlers/validation.py b/vllm/entrypoints/serve/exception_handling/handlers/validation.py index 7c1f568eda..7cdcb3970e 100644 --- a/vllm/entrypoints/serve/exception_handling/handlers/validation.py +++ b/vllm/entrypoints/serve/exception_handling/handlers/validation.py @@ -70,6 +70,60 @@ _INTERNAL_LOC_MARKERS = frozenset( ) +# Pydantic repeats the whole offending value under `input` in *every* +# error entry, so one malformed request whose input fails per-element +# echoes that input once per error. Measured on `/v1/responses`: a +# 4,475-byte body produced 12,001 errors and a 23.6 MB response, an +# amplification of ~5,300x that scales linearly with the request size. +# Bound both the number of entries reported and the value echoed in each. +_MAX_REPORTED_ERRORS = 10 +_MAX_ERROR_INPUT_CHARS = 200 +_MAX_ERROR_CHARS = 1000 + + +def _summarize_error_input(value: object) -> object: + """A size-bounded stand-in for a validation error's `input` value. + + Containers are described rather than rendered: `repr()` of a large + list materializes the whole string first, which is the cost this is + meant to avoid. + """ + if isinstance(value, str): + if len(value) > _MAX_ERROR_INPUT_CHARS: + return value[:_MAX_ERROR_INPUT_CHARS] + "...[truncated]" + return value + if isinstance(value, (bytes, bytearray)): + return f"<{type(value).__name__} of {len(value)} bytes>" + if isinstance(value, (list, tuple, set, frozenset, dict)): + return f"<{type(value).__name__} of {len(value)} items>" + try: + text = str(value) + except Exception: + # e.g. an int past `sys.get_int_max_str_digits()`. + return f"<{type(value).__name__}>" + if len(text) > _MAX_ERROR_INPUT_CHARS: + return text[:_MAX_ERROR_INPUT_CHARS] + "...[truncated]" + return text + + +def _format_error(err: object) -> str: + """Render one validation error, bounded in size.""" + if isinstance(err, dict): + err = dict(err) + if "input" in err: + err["input"] = _summarize_error_input(err["input"]) + loc = err.get("loc") + if isinstance(loc, (tuple, list)): + # For a union-typed field pydantic spells every branch out in + # `loc`, which is ~800 characters of type names per entry here. + # `param` is already cleaned this way. + err["loc"] = clean_loc_for_param(tuple(loc)) + text = str(err) + if len(text) > _MAX_ERROR_CHARS: + text = text[:_MAX_ERROR_CHARS] + "...[truncated]" + return text + + def _is_internal_loc_segment(segment: str) -> bool: """True if `segment` is a Pydantic-internal wrapper/union-branch marker rather than a user-meaningful field name or list index.""" @@ -121,8 +175,11 @@ async def validation_exception_handler(req: Request, exc: RequestValidationError if errors: count = len(errors) label = "error" if count == 1 else "errors" + reported = errors[:_MAX_REPORTED_ERRORS] message = f"{count} validation {label}:\n" - message += "".join(f" {err}\n" for err in errors) + message += "".join(f" {_format_error(err)}\n" for err in reported) + if count > len(reported): + message += f" ...and {count - len(reported)} more {label}\n" message = message.rstrip() else: message = "Validation error"