Skip to content

Commit 1867330

Browse files
fix: add recursive scanning to regex_filter and deny_filter (#5243)
* fix:add recursive scanning to regex_filter and deny_filter Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * removed additional comments Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix precommit Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * correct recursion depth check in regex_filter and deny_filter plugins Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * remove hardcoded Recursion parameter/ added try catch to catch recursion range error Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * Added documentation for recursive behavior/feature Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> --------- Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> Merged-by: Jitesh Nair <jiteshnair@ibm.com>
1 parent 06fa8f7 commit 1867330

7 files changed

Lines changed: 955 additions & 33 deletions

File tree

.secrets.baseline

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "(?x)( package-lock\\.json$ |Cargo\\.lock$ |uv\\.lock$ |go\\.sum$ |mcpgateway/sri_hashes\\.json$ )|^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-06-29T10:05:28Z",
6+
"generated_at": "2026-06-18T08:57:41Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"

docs/docs/using/plugins/plugins.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ Plugins for filtering, validating, and controlling content.
7575

7676
| Plugin | Type | Description |
7777
|--------|------|-------------|
78-
| [Deny Filter](https://github.com/IBM/mcp-context-forge/tree/main/plugins/deny_filter) | Native | Deny list plugin for blocking specific content patterns |
79-
| [Regex Filter](https://github.com/IBM/mcp-context-forge/tree/main/plugins/regex_filter) | Native | Search and replace plugin using regex patterns for content filtering |
78+
| [Deny Filter](https://github.com/IBM/mcp-context-forge/tree/main/plugins/deny_filter) | Native | Deny list plugin for blocking specific content patterns; recursively scans nested dicts and lists so words are caught at any depth in the payload |
79+
| [Regex Filter](https://github.com/IBM/mcp-context-forge/tree/main/plugins/regex_filter) | Native | Search and replace plugin using regex patterns for content filtering; recursively rewrites string values inside nested dicts and lists at any depth in the payload |
8080
| [Resource Filter](https://github.com/IBM/mcp-context-forge/tree/main/plugins/resource_filter) | Native | Resource filtering with max content size, protocol restrictions, blocked domains, and content pattern replacement |
8181
| [File Type Allowlist](https://github.com/IBM/mcp-context-forge/tree/main/plugins/file_type_allowlist) | Native | Allows only configured MIME types and file extensions for resources |
8282
| [Output Length Guard](https://github.com/IBM/mcp-context-forge/tree/main/plugins/output_length_guard) | Native | Guards tool outputs by length with block or truncate strategies |

docs/docs/using/rest-passthrough.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,11 @@ Configure pre and post-request plugin processing:
185185

186186
**Allowed Plugins:**
187187

188-
- `deny_filter` - Block requests matching deny patterns
188+
- `deny_filter` - Block requests matching deny patterns; scans all string values recursively through nested dicts and lists
189189
- `rate_limit` - Rate limiting enforcement
190190
- `pii_filter` - PII detection and filtering
191191
- `response_shape` - Response transformation
192-
- `regex_filter` - Regex-based content filtering
192+
- `regex_filter` - Regex-based content filtering; applies search/replace patterns recursively through nested dicts and lists
193193
- `resource_filter` - Resource access control
194194

195195
**Execution Order:**

plugins/deny_filter/deny.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88
This module loads configurations for plugins.
99
"""
1010

11+
# Standard
12+
from typing import Any
13+
1114
# Third-Party
1215
from pydantic import BaseModel
1316

1417
# First-Party
1518
from cpex.framework import Plugin, PluginConfig, PluginContext, PluginViolation, PromptPrehookPayload, PromptPrehookResult
1619
from mcpgateway.services.logging_service import LoggingService
1720

18-
# Initialize logging service first
21+
# Initialize logging service
1922
logging_service = LoggingService()
2023
logger = logging_service.get_logger(__name__)
2124

@@ -30,6 +33,40 @@ class DenyListConfig(BaseModel):
3033
words: list[str]
3134

3235

36+
def _scan_for_denied_words(value: Any, deny_list: list[str], path: str = "") -> list[str]:
37+
"""Recursively scan for denied words in nested structures.
38+
39+
This function walks through nested dictionaries, lists, and strings,
40+
checking all string values for denied words regardless of nesting depth.
41+
42+
Args:
43+
value: The value to scan (can be str, dict, list, or other types).
44+
deny_list: List of denied words to check for.
45+
path: Current path in the structure (for error reporting).
46+
47+
Returns:
48+
List of paths where denied words were found.
49+
50+
Raises:
51+
RecursionError: Propagated naturally if the payload structure exceeds
52+
Python's recursion limit (~950 levels on CPython).
53+
"""
54+
violations: list[str] = []
55+
if isinstance(value, str):
56+
for word in deny_list:
57+
if word.lower() in value.lower():
58+
violations.append(f"{path}: contains '{word}'")
59+
elif isinstance(value, dict):
60+
for k, v in value.items():
61+
new_path = f"{path}.{k}" if path else k
62+
violations.extend(_scan_for_denied_words(v, deny_list, new_path))
63+
elif isinstance(value, list):
64+
for i, item in enumerate(value):
65+
new_path = f"{path}[{i}]"
66+
violations.extend(_scan_for_denied_words(item, deny_list, new_path))
67+
return violations
68+
69+
3370
class DenyListPlugin(Plugin):
3471
"""Example deny list plugin."""
3572

@@ -56,16 +93,24 @@ async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginC
5693
The result of the plugin's analysis, including whether the prompt can proceed.
5794
"""
5895
if payload.args:
59-
for key in payload.args:
60-
if any(word in payload.args[key] for word in self._deny_list):
61-
violation = PluginViolation(
62-
reason="Prompt not allowed",
63-
description="A deny word was found in the prompt",
64-
code="deny",
65-
details={},
66-
)
67-
logger.warning(f"Deny word detected in prompt argument '{key}'")
68-
return PromptPrehookResult(modified_payload=payload, violation=violation, continue_processing=False)
96+
try:
97+
violations = _scan_for_denied_words(payload.args, self._deny_list)
98+
except RecursionError:
99+
logger.error("deny_filter: RecursionError scanning prompt args — payload is pathologically nested; aborting hook")
100+
raise
101+
if violations:
102+
violation = PluginViolation(
103+
reason="Prompt not allowed",
104+
description="Denied words found in prompt",
105+
code="deny",
106+
details={"violations": violations},
107+
)
108+
logger.warning(f"Denied words detected: {violations}")
109+
return PromptPrehookResult(
110+
modified_payload=payload,
111+
violation=violation,
112+
continue_processing=False,
113+
)
69114
return PromptPrehookResult(modified_payload=payload)
70115

71116
async def shutdown(self) -> None:

plugins/regex_filter/search_replace.py

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@
1111
# Standard
1212
import copy
1313
import re
14+
from typing import Any
1415

1516
# Third-Party
1617
from pydantic import BaseModel
1718

18-
# Third-Party
19+
# First-Party
1920
from cpex.framework import (
2021
Plugin,
2122
PluginConfig,
@@ -29,6 +30,11 @@
2930
ToolPreInvokePayload,
3031
ToolPreInvokeResult,
3132
)
33+
from mcpgateway.services.logging_service import LoggingService
34+
35+
# Initialize logging service
36+
logging_service = LoggingService()
37+
logger = logging_service.get_logger(__name__)
3238

3339

3440
class SearchReplace(BaseModel):
@@ -53,6 +59,35 @@ class SearchReplaceConfig(BaseModel):
5359
words: list[SearchReplace]
5460

5561

62+
def _scan_and_replace_recursive(value: Any, patterns: list[tuple]) -> Any:
63+
"""Recursively scan and replace patterns in nested structures.
64+
65+
This function walks through nested dictionaries, lists, and strings,
66+
applying regex patterns to all string values regardless of nesting depth.
67+
68+
Args:
69+
value: The value to scan (can be str, dict, list, or other types).
70+
patterns: List of (compiled_pattern, replacement) tuples.
71+
72+
Returns:
73+
Modified value with patterns replaced in all nested strings.
74+
75+
Raises:
76+
RecursionError: Propagated naturally if the payload structure exceeds
77+
Python's recursion limit (~950 levels on CPython).
78+
"""
79+
if isinstance(value, str):
80+
result = value
81+
for pattern, replacement in patterns:
82+
result = pattern.sub(replacement, result)
83+
return result
84+
elif isinstance(value, dict):
85+
return {k: _scan_and_replace_recursive(v, patterns) for k, v in value.items()}
86+
elif isinstance(value, list):
87+
return [_scan_and_replace_recursive(item, patterns) for item in value]
88+
return value
89+
90+
5691
class SearchReplacePlugin(Plugin):
5792
"""Example search replace plugin."""
5893

@@ -85,11 +120,11 @@ async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginC
85120
The result of the plugin's analysis, including whether the prompt can proceed.
86121
"""
87122
if payload.args:
88-
modified_args = dict(payload.args)
89-
for pattern, replacement in self.__patterns:
90-
for key, value in modified_args.items():
91-
if isinstance(value, str):
92-
modified_args[key] = pattern.sub(replacement, value)
123+
try:
124+
modified_args = _scan_and_replace_recursive(payload.args, self.__patterns)
125+
except RecursionError:
126+
logger.error("regex_filter: RecursionError scanning prompt args — payload is pathologically nested; aborting hook")
127+
raise
93128
payload = payload.model_copy(update={"args": modified_args})
94129
return PromptPrehookResult(modified_payload=payload)
95130

@@ -103,7 +138,6 @@ async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: Plugi
103138
Returns:
104139
The result of the plugin's analysis, including whether the prompt can proceed.
105140
"""
106-
107141
if payload.result.messages:
108142
modified_result = copy.deepcopy(payload.result)
109143
for index, message in enumerate(modified_result.messages):
@@ -123,11 +157,11 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo
123157
The result of the plugin's analysis, including whether the tool can proceed.
124158
"""
125159
if payload.args:
126-
modified_args = dict(payload.args)
127-
for pattern, replacement in self.__patterns:
128-
for key, value in modified_args.items():
129-
if isinstance(value, str):
130-
modified_args[key] = pattern.sub(replacement, value)
160+
try:
161+
modified_args = _scan_and_replace_recursive(payload.args, self.__patterns)
162+
except RecursionError:
163+
logger.error("regex_filter: RecursionError scanning tool args — payload is pathologically nested; aborting hook")
164+
raise
131165
payload = payload.model_copy(update={"args": modified_args})
132166
return ToolPreInvokeResult(modified_payload=payload)
133167

@@ -142,11 +176,11 @@ async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: Plugin
142176
The result of the plugin's analysis, including whether the tool result should proceed.
143177
"""
144178
if payload.result and isinstance(payload.result, dict):
145-
modified_result = dict(payload.result)
146-
for pattern, replacement in self.__patterns:
147-
for key, value in modified_result.items():
148-
if isinstance(value, str):
149-
modified_result[key] = pattern.sub(replacement, value)
179+
try:
180+
modified_result = _scan_and_replace_recursive(payload.result, self.__patterns)
181+
except RecursionError:
182+
logger.error("regex_filter: RecursionError scanning tool result — payload is pathologically nested; aborting hook")
183+
raise
150184
payload = payload.model_copy(update={"result": modified_result})
151185
elif payload.result and isinstance(payload.result, str):
152186
result = payload.result

0 commit comments

Comments
 (0)