diff --git a/litellm/litellm_core_utils/llm_request_utils.py b/litellm/litellm_core_utils/llm_request_utils.py index c833d57b6a9..a8d58b2c267 100644 --- a/litellm/litellm_core_utils/llm_request_utils.py +++ b/litellm/litellm_core_utils/llm_request_utils.py @@ -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) diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index b15a16ffc23..7518240b720 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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. ] diff --git a/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py b/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py index 3a09702de45..44383330e6e 100644 --- a/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_llm_request_utils.py @@ -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})