From 9a99e317ec53cd3f9c191a0f69db3496705df6a2 Mon Sep 17 00:00:00 2001 From: OrangerXu Date: Wed, 14 Oct 2026 06:27:12 +0800 Subject: [PATCH] fix(guardrails): extract nested-wildcard request_fields so the check runs JsonPathExtractor.evaluate normalized an expression by replacing [*] with .[*]. and splitting on dots, then recursed into wildcard items passing the ALREADY-normalized remaining path. Re-running the replace on that path injected an empty segment (content..[*].text), so every expression with two array wildcards - e.g. messages[*].content[*].text, the natural request_fields for chat-shaped pass-through payloads - evaluated to nothing. extract_fields returned an empty string and the pass-through handler skipped the guardrail entirely ('No text to check'), sending the request upstream unchecked while still recording the guardrail as applied. The normalization now splits each dot segment by peeling trailing wildcards one at a time, which is idempotent for the paths recursion feeds back through, and adjacent wildcards keep working. Fixes #46302 --- .../jsonpath_extractor.py | 21 ++++++++++++++++++- .../test_passthrough_guardrails.py | 20 ++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py index 64a8c81624c..8ed54443b7e 100644 --- a/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py +++ b/litellm/proxy/pass_through_endpoints/jsonpath_extractor.py @@ -55,7 +55,26 @@ class JsonPathExtractor: if not expr or not data: return None - parts: Final = expr.replace("[*]", ".[*]").split(".") + # Normalize "key[*]" into "key" + "[*]" segments. Idempotent: the + # recursion below passes already-normalized paths (e.g. + # "content.[*].text") back through here, and re-running + # expr.replace("[*]", ".[*]") on those would inject empty segments + # ("content..[*].text") and silently drop every nested-wildcard + # expression, skipping the guardrail (#46302). + segments: list[str] = [] + for segment in expr.split("."): + if segment == "[*]": + segments.append("[*]") + continue + # Peel trailing wildcards one at a time so adjacent wildcards + # ("a[*][*]") and already-normalized paths both split cleanly. + while segment.endswith("[*]"): + segments.append(segment[:-3]) + segments.append("[*]") + segment = "" + if segment: + segments.append(segment) + parts: Final = segments current: Any = data for i, part in enumerate(parts): diff --git a/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py b/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py index 40403de846f..56525e3cf72 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py +++ b/tests/unit/proxy/pass_through_endpoints/test_passthrough_guardrails.py @@ -155,6 +155,26 @@ class TestJsonPathExtractorEvaluate: result = JsonPathExtractor.evaluate(data, "messages[*].content") assert result == ["You are helpful", "Hello"] + def test_nested_array_wildcards(self): + """Should extract values through two array wildcards. + + Regression test for #46302: the recursive wildcard branch passes the + already-normalized remaining path (e.g. "content.[*].text") back + through evaluate, where the old replace-based normalization injected + an empty segment ("content..[*].text") and silently dropped every + nested-wildcard expression - so a pass-through guardrail whose only + request_fields entry is messages[*].content[*].text never saw any + text and skipped the check entirely. + """ + data = { + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "item1"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "item2"}]}, + ] + } + assert JsonPathExtractor.evaluate(data, "messages[*].content[*].text") == ["item1", "item2"] + assert JsonPathExtractor.extract_fields(data, ["messages[*].content[*].text"]) == "item1" + chr(10) + "item2" + def test_missing_key_returns_none(self): """Should return None for missing key.""" data = {"query": "test"}