From 51cdce0883a7ac18b5ba3537f682caf0543e2c13 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Sat, 28 Feb 2026 17:39:21 -0800 Subject: [PATCH] Validate :: name separator with identifier check Only treat :: as a name::regex separator when the left-hand side is a valid identifier (letters, digits, underscores, hyphens). This prevents regex patterns containing :: (e.g. IPv6 ::ffff:\d+) from being silently corrupted by splitting on ::. Added tests for bare :: in regex and valid name:: prefix. Co-Authored-By: Claude Opus 4.6 --- .../proxy/guardrails/guardrail_endpoints.py | 25 +++++++++++++------ .../guardrails/test_guardrail_endpoints.py | 20 +++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 0b931997646..ea0ec7ef3c8 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -1016,6 +1016,12 @@ _NESTED_QUANTIFIER_RE = re.compile( r"\([^)]*[*+]\)[*+?]|\([^)]*[*+]\)\{", ) +# A valid pattern name must look like an identifier: letters, digits, +# underscores, hyphens — no regex metacharacters. Used to decide whether +# a ``::`` in a line is a name separator or part of the regex itself +# (e.g. IPv6 patterns like ``::ffff:\d+``). +_VALID_PATTERN_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") + def _check_redos_heuristic(pattern: str) -> Optional[str]: """Return an error message if the pattern looks like it could cause ReDoS.""" @@ -1105,15 +1111,20 @@ async def validate_patterns_file(request: Dict[str, str]): ) break - # Parse optional name::regex format (:: avoids conflict with - # regex alternation operator |) + # Parse optional name::regex format. Only treat :: as a + # name separator when the left-hand side is a valid + # identifier; otherwise the whole line is the regex + # (e.g. ``::ffff:\d+`` for IPv6). + name: Optional[str] = None + regex_str = stripped if "::" in stripped: parts = stripped.split("::", 1) - name = parts[0].strip() - regex_str = parts[1].strip() - else: - name = None - regex_str = stripped + candidate_name = parts[0].strip() + if candidate_name and _VALID_PATTERN_NAME_RE.match( + candidate_name + ): + name = candidate_name + regex_str = parts[1].strip() if not regex_str: errors.append(f"Line {line_num}: empty regex pattern") diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 063b9fbdd5f..14dc5885696 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -1203,6 +1203,26 @@ class TestValidatePatternsFile: assert result["valid"] is False assert "too large" in result.get("error", "").lower() + @pytest.mark.asyncio + async def test_validate_patterns_double_colon_in_regex(self): + """:: in regex (e.g. IPv6) should not be treated as name separator.""" + file_content = "::ffff:\\d+" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is True + assert len(result["patterns"]) == 1 + assert result["patterns"][0]["pattern"] == "::ffff:\\d+" + assert result["patterns"][0]["name"] == "pattern_line_1" + + @pytest.mark.asyncio + async def test_validate_patterns_named_with_double_colon(self): + """Valid identifier before :: is treated as name, rest as regex.""" + file_content = "ipv6_match::fe80::\\w+" + result = await validate_patterns_file({"file_content": file_content}) + assert result["valid"] is True + assert len(result["patterns"]) == 1 + assert result["patterns"][0]["name"] == "ipv6_match" + assert result["patterns"][0]["pattern"] == "fe80::\\w+" + @pytest.mark.asyncio async def test_validate_patterns_only_comments(self): """File with only comments returns no patterns error."""