fix: bound recursive form serialization

Reject excessively nested multipart values before recursion can exhaust the process stack. This also restores the code-quality gate broken by the unbounded walkers.
This commit is contained in:
Erik Bogado 2026-08-24 18:10:36 -03:00
parent ca0b951a43
commit f1a499acfa
3 changed files with 26 additions and 8 deletions

View file

@ -2,6 +2,7 @@ from collections.abc import Mapping
from typing import Final
import litellm
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
def _form_field_value(value: object) -> str:
@ -12,13 +13,17 @@ def _form_field_value(value: object) -> str:
return str(value)
def _flatten_form_field(key: str, value: object) -> tuple[tuple[str, str], ...]:
def _flatten_form_field(key: str, value: object, depth: int = 0) -> tuple[tuple[str, str], ...]:
if depth > DEFAULT_MAX_RECURSE_DEPTH:
raise ValueError("Form field value exceeds maximum nesting depth")
if isinstance(value, Mapping):
return tuple(
item for subkey, subvalue in value.items() for item in _flatten_form_field(f"{key}[{subkey}]", subvalue)
item
for subkey, subvalue in value.items()
for item in _flatten_form_field(f"{key}[{subkey}]", subvalue, depth + 1)
)
if isinstance(value, (list, tuple)):
return tuple(item for entry in value for item in _flatten_form_field(f"{key}[]", entry))
return tuple(item for entry in value for item in _flatten_form_field(f"{key}[]", entry, depth + 1))
if value is None:
return ()
serialized: Final = _form_field_value(value)
@ -31,18 +36,20 @@ def _is_form_scalar(value: object) -> bool:
return value is not None and not isinstance(value, (Mapping, list, tuple))
def _flatten_form_data_field(key: str, value: object) -> tuple[tuple[str, str | tuple[str, ...]], ...]:
def _flatten_form_data_field(key: str, value: object, depth: int = 0) -> tuple[tuple[str, str | tuple[str, ...]], ...]:
if depth > DEFAULT_MAX_RECURSE_DEPTH:
raise ValueError("Form field value exceeds maximum nesting depth")
if isinstance(value, Mapping):
return tuple(
item
for subkey, subvalue in value.items()
for item in _flatten_form_data_field(f"{key}[{subkey}]", subvalue)
for item in _flatten_form_data_field(f"{key}[{subkey}]", subvalue, depth + 1)
)
if isinstance(value, (list, tuple)):
if all(_is_form_scalar(entry) for entry in value):
serialized_fields: Final = tuple(field for entry in value if (field := _form_field_value(entry)))
return ((key, serialized_fields),) if serialized_fields else ()
return tuple(item for entry in value for item in _flatten_form_data_field(f"{key}[]", entry))
return tuple(item for entry in value for item in _flatten_form_data_field(f"{key}[]", entry, depth + 1))
if value is None:
return ()
serialized: Final = _form_field_value(value)

View file

@ -60,8 +60,8 @@ IGNORE_FUNCTIONS = [
"json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
"with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
"_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
"_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
"_flatten_form_field", # bounded by DEFAULT_MAX_RECURSE_DEPTH; raises at the cap.
"_flatten_form_data_field", # bounded by DEFAULT_MAX_RECURSE_DEPTH; raises at the cap.
]

View file

@ -1,4 +1,5 @@
import httpx
import pytest
from litellm.litellm_core_utils.llm_request_utils import (
flatten_form_field_values,
@ -97,3 +98,13 @@ def test_flatten_form_field_values_scalar_list_survives_update_into_multipart():
assert names.count("loras") == 2
assert names.count("model") == 1
def test_form_field_serializers_reject_cyclic_values():
cyclic: dict[str, object] = {}
cyclic["self"] = cyclic
with pytest.raises(ValueError, match="maximum nesting depth"):
flatten_form_field_values({"metadata": cyclic})
with pytest.raises(ValueError, match="maximum nesting depth"):
serialize_multipart_form_fields({"metadata": cyclic})