From 6875ca00d07931abe63348528b900ae28f835224 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 11 Jul 2026 09:36:15 -0700 Subject: [PATCH] fix(team): bound json merge patch recursion depth apply_json_merge_patch recurses into nested objects, which the repo's recursive_detector code-quality check flags because unbounded recursion over caller-supplied JSON has caused CPU/stack issues before. Cap the recursion at a depth far above any realistic team-metadata shape and reject deeper patches with a ValueError so a pathologically nested body fails closed instead of overflowing the stack, then register the function in the detector's ignore list alongside the other depth-bounded JSON walkers --- .../proxy/common_utils/json_merge_patch.py | 18 ++++++++++--- .../code_coverage_tests/recursive_detector.py | 1 + .../common_utils/test_json_merge_patch.py | 25 ++++++++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/common_utils/json_merge_patch.py b/litellm/proxy/common_utils/json_merge_patch.py index 24a025eb410..ec576a8ec70 100644 --- a/litellm/proxy/common_utils/json_merge_patch.py +++ b/litellm/proxy/common_utils/json_merge_patch.py @@ -2,20 +2,32 @@ from pydantic import JsonValue +# A merge patch recurses as deep as the client's JSON nests. Cap it far above any +# realistic team-metadata shape but well below Python's stack limit, so a +# pathologically deep patch is rejected instead of overflowing the stack. +_MAX_MERGE_DEPTH = 64 -def apply_json_merge_patch(target: JsonValue, patch: JsonValue) -> JsonValue: + +def apply_json_merge_patch(target: JsonValue, patch: JsonValue, _depth: int = 0) -> JsonValue: """Apply an RFC 7386 JSON Merge Patch to ``target`` and return the result. - a key absent from ``patch`` keeps its value in ``target`` - a key mapped to ``null`` in ``patch`` is removed from the result - any other value overwrites, recursing into nested objects - ``target`` is never mutated; a new value is returned. + ``target`` is never mutated; a new value is returned. Raises ``ValueError`` + if ``patch`` nests deeper than ``_MAX_MERGE_DEPTH``. """ if not isinstance(patch, dict): return patch + if _depth >= _MAX_MERGE_DEPTH: + raise ValueError(f"JSON merge patch nesting exceeds the maximum depth of {_MAX_MERGE_DEPTH}") base = target if isinstance(target, dict) else {} preserved = {key: value for key, value in base.items() if key not in patch} - applied = {key: apply_json_merge_patch(base.get(key), value) for key, value in patch.items() if value is not None} + applied = { + key: apply_json_merge_patch(base.get(key), value, _depth + 1) + for key, value in patch.items() + if value is not None + } return {**preserved, **applied} diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 2af14e1e544..f9bd4a012cc 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -53,6 +53,7 @@ IGNORE_FUNCTIONS = [ "resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input). "sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth. "_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap. + "apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap. ] diff --git a/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py b/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py index 89c2d474ed3..d4b60d6a1e7 100644 --- a/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py +++ b/tests/test_litellm/proxy/common_utils/test_json_merge_patch.py @@ -2,7 +2,7 @@ import copy import pytest -from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch +from litellm.proxy.common_utils.json_merge_patch import _MAX_MERGE_DEPTH, apply_json_merge_patch # RFC 7386 Appendix A — the normative test suite for JSON Merge Patch. # https://www.rfc-editor.org/rfc/rfc7386#appendix-A @@ -69,3 +69,26 @@ def test_scalar_patch_replaces_object_wholesale(): def test_object_patch_over_non_object_target_starts_from_empty(): assert apply_json_merge_patch("not-an-object", {"a": 1, "b": None}) == {"a": 1} + + +def _nest(levels: int) -> dict: + """A patch nested ``levels`` dicts deep with a scalar leaf at the bottom.""" + value: object = "leaf" + for _ in range(levels): + value = {"a": value} + return value # type: ignore[return-value] + + +def test_merge_within_max_depth_is_allowed(): + """A deeply-but-not-pathologically nested patch merges without raising.""" + result = apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH - 1)) + for _ in range(_MAX_MERGE_DEPTH - 1): + result = result["a"] + assert result == "leaf" + + +def test_merge_beyond_max_depth_raises(): + """A patch nested past the cap fails closed (ValueError) rather than + overflowing the Python stack — the guard the recursion detector requires.""" + with pytest.raises(ValueError, match="maximum depth"): + apply_json_merge_patch({}, _nest(_MAX_MERGE_DEPTH + 5))