[Bugfix][Security] Bound the validation-error response body (#54684)
Signed-off-by: lzhan011 <35493221+lzhan011@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user