[Bugfix][Security] Bound the validation-error response body (#54684)

Signed-off-by: lzhan011 <35493221+lzhan011@users.noreply.github.com>
This commit is contained in:
lzhan011
2026-09-01 06:51:16 +00:00
committed by GitHub
parent 4ac452ad98
commit fa99a6fea6
2 changed files with 176 additions and 1 deletions
@@ -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 "<list of 300 items>" 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
@@ -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"