[type:feat] add the sensitive word plugin - #7153
Conversation
Reject a request whose body contains a word of the dictionary configured for the matched rule, so that a forbidden prompt never reaches the model. The dictionary is a redis set and it is compiled into an Aho-Corasick automaton, which scans the whole dictionary in a single pass and reports every matching word, nested and overlapping ones included. - the automaton is compiled on a bounded elastic thread and cached per rule dictionary, so rules pointing to different dictionaries never share an automaton and the gateway event loop is never blocked - a cached dictionary is read from redis again after refreshIntervalSeconds, and it is dropped immediately when the rule is configured again - a redis failure is logged and the request is passed through (fail open) - only the request body is inspected, so the plugin is not limited to AI routes - unit tests for the automaton, the plugin, the data handler, the rule handle and the starter
| final SensitiveWordHandle handle, | ||
| final String body) { | ||
| return dictionary(redisTemplate, handle) | ||
| .map(automaton -> automaton.search(body)) |
There was a problem hiding this comment.
Blocking: this scan runs on the event loop.
Only the redis path hops off it (subscribeOn(Schedulers.boundedElastic()) further down); the cached path returns Mono.just(cached.getAutomaton()) with no scheduler, which is the path every request takes after the first one. Nothing here switches threads, so automaton.search(body) executes on the subscribing thread - the Netty event loop of the request currently being filtered - over the entire request body.
That contradicts point 4 of the PR description ("the automaton is compiled on a bounded elastic thread, never inside the request thread"): compilation is off-loop, but execution is on-loop, per request. search(...) is also O(n * failure-chain depth) since every character walks the failure link chain.
Suggestion: hop once for both paths, e.g.
return dictionary(redisTemplate, handle)
.publishOn(Schedulers.boundedElastic())
.map(automaton -> automaton.search(body));
(or put the publishOn inside dictionary() so both branches are covered). Precomputing an output list per node while building the failure links would additionally turn search into plain O(n).
There was a problem hiding this comment.
Fixed in 06f2b6109. dictionary() now ends with publishOn(Schedulers.boundedElastic()), so the cached path and the redis path are both off the event loop before the scan. I also took the second half of your suggestion: buildFailureLinks() resolves the matches of every node once, so search(...) is linear instead of walking the failure chain per character. Thanks for catching it — only the very first request was off the loop before.
| return Mono.just(body); | ||
| } | ||
| return Mono.error(new ResponsiveException(SENSITIVE_WORD_CODE, | ||
| String.format("The request contains sensitive words: %s", matches), exchange)); |
There was a problem hiding this comment.
Blocking: this echoes the matched dictionary words back to the caller.
matches goes into the ResponsiveException message and reaches the client through WebFluxResultUtils#failedResult; it will also be captured by the logging plugins and any access log. For a compliance filter that is the wrong direction:
- it hands the blacklisted words back to the requester, turning the gateway into an oracle that confirms what is on the list;
- it re-emits content the plugin has just classified as forbidden, including into logs (where
shenyu-plugin-logging-*desensitization cannot know these words).
Suggestion: return a generic message to the client (for example "request rejected: sensitive content detected") and log the matches server-side only.
There was a problem hiding this comment.
Fixed in 06f2b6109. The client now gets a generic message (Request rejected: sensitive content detected) and the matched words go to the plugin log only; the rejection code follows the waf plugin (403) instead of a plugin-invented code. The test now asserts the response body does not contain the matched word.
Aias00
left a comment
There was a problem hiding this comment.
Thanks for a very well-prepared contribution. The motivation section ("waf only matches conditions and never looks at the body"), the explicit "why not extend waf" section, and the test matrix (34 tests: nested, overlapping and suffix words, blank entries, empty dictionaries, both fail-open paths) make this pleasant to review. Reusing ServerWebExchangeUtils#rewriteRequestBody instead of adding yet another body decorator is the right call, keying the automaton cache on the redis key rather than on the plugin is the right granularity, and the README documents the trade-offs honestly.
I am requesting changes for two defects, both localised and neither touching the design. I will re-review as soon as they are addressed.
-
[blocking] scan runs on the Netty event loop - SensitiveWordPlugin.java:98
check()maps.map(automaton -> automaton.search(body))onto whateverdictionary()emits. Only the redis path hassubscribeOn(Schedulers.boundedElastic()); the cached path returnsMono.just(cached.getAutomaton())(line 123) with no scheduler, and that is the path taken by every request after the first. Nothing hops threads, so the CPU-bound scan of the whole request body runs on the event loop of the request being filtered - the opposite of point 4 of your own description: the automaton is compiled off-loop but executed on-loop, per request.search(...)is additionally O(n x failure-chain depth) because every character walks the failure link chain. Fix: hop once for both paths (see inline comment). -
[blocking] the rejection message echoes the matched words back to the caller - SensitiveWordPlugin.java:104
The matches are formatted into theResponsiveExceptionmessage and reach the client throughWebFluxResultUtils#failedResult, and will equally be captured by the logging plugins and any access log. A compliance filter should neither serve as an oracle that confirms which words are blacklisted nor re-emit content it just classified as forbidden. Fix: generic client-facing message, details in server-side logs only.
Non-blocking suggestions
-
Fail-open is not obviously the safe default here. If redis is unreachable every prompt is forwarded untouched and silently. I would keep the current behaviour as the default but expose a rule-level strategy (for example
failStrategy/failClosed) so that deployments with a hard compliance requirement can opt into blocking. -
The replaced redis connection is never released. In
handlerPlugin()a newRedisConnectionFactory+ template are cached while the previous pair is dropped without being closed, leaking the pool and its resources.RedisConnectionFactoryonly exposesgetLettuceConnectionFactory()(nodestroy()), so nothing today can release it - andAiTokenLimiterPluginHandleralready does exactly the same, so this is not debt you are introducing. If you want to fix it: keep the obsolete factory reference and calldestroy()before replacing it, or adddestroy()toRedisConnectionFactoryin a separate small PR and use it here. -
There is no bound on the body being inspected: every request body is fully buffered and scanned. Consider a
maxBodySizeguard, otherwise putting this plugin on an upload route means unbounded buffering and CPU per request. -
removeRule()drops the handle but keeps theDICTIONARIESentry, and there is noremovePlugin()releasing the redis template when the plugin is removed. Bounded in practice, but worth a follow-up. -
Agreed that the
db/initscripts and the console form are out of scope - please open a follow-up issue for the plugin row + resource rows and link it from this PR, otherwise the plugin ships unreachable through the admin UI.
CI is green (build, integrated tests, e2e, checkstyle, RAT). Items 1 and 2 are the only reason for request changes.
- hop off the netty event loop on both dictionary paths: the cached path emitted on the subscribing thread, so the body scan ran on the event loop of the request being filtered - precompute the words matched at every trie node while building the failure links, which makes the scan linear in the length of the text instead of walking the failure chain for every character - never echo the matched words back to the caller: the client gets a generic rejection message, the matches are logged server side only, and the rejection code follows the waf plugin (403) - add the rule level failClosed option: when the dictionary cannot be read and no cached dictionary is available, such a rule rejects the request instead of passing it through - release the cached redis template in removePlugin - tests for the failClosed paths, the stale dictionary fallback and removePlugin
|
Thanks for the careful review — both blocking points were real and are fixed in 1. The scan ran on the event loop — fixed. 2. The rejection echoed the matched words — fixed. 3. fail strategy — done. 4 / 5 / 6
7 Follow-up issue opened: #7154 — the Local, on |
Motivation
A gateway that fronts an LLM should be able to stop a prompt that carries forbidden content
before the model ever sees it, and this is a compliance requirement for a lot of deployments.
ShenYu has no content-based filter today: the
wafplugin only matches request conditions(uri / header / param) and never looks at the body, and the logging plugins desensitize log
records only.
This PR adds a sensitive word filter for the request body.
What is added
shenyu-plugin-ai/shenyu-plugin-ai-sensitive-wordshenyu-spring-boot-starter-plugin-ai-sensitive-wordshenyu-commonSensitiveWordHandle(rule level) andPluginEnum.SENSITIVE_WORDshenyu-plugin-ai/pom.xml, starter pom,shenyu-bootstrap/pom.xmlDesign
redisKey(defaultshenyu:sensitive:words) and the plugin reads the set withSMEMBERS, so the dictionary ismaintained by operations without redeploying shenyu, and every rule can point at its own set.
single pass and every matching word is reported, nested and overlapping ones included (both
中国and中国银行for中国银行).never share an automaton. A cached dictionary is read from redis again after
refreshIntervalSeconds(default 300), and it is dropped immediately when the rule isconfigured again, so a dictionary change does not need a gateway restart.
ServerWebExchangeUtils#rewriteRequestBody, and the automaton is compiled on a bounded elasticthread, never inside the request thread.
A dictionary that cannot be read must not take the traffic down.
routes; response-side (including SSE streaming) detection is a possible follow-up.
Configuration
Plugin level, the redis client used to read the dictionaries:
{"url": "127.0.0.1:6379", "password": "", "database": 0, "mode": "standalone"}Rule level:
redisKeyshenyu:sensitive:wordsrefreshIntervalSeconds3000reads it on every requestWhy not extend the
wafpluginWafHandleonly carriespermissionandstatusCode, andwafruns at order 50, before thebody is available: its contract is "reject on the matched conditions", not "inspect the content".
A dictionary, a refresh policy and a matched-word report do not fit that handle, and the body has
to be read at a later order anyway.
Testing
All green: checkstyle 0 violations, RAT ok, and 38 tests
(
AhoCorasickTest14,SensitiveWordPluginTest9,SensitiveWordPluginDataHandlerTest10,SensitiveWordHandleTest3, starter 2). The automaton tests cover nested, overlapping and suffixwords, blank entries, empty dictionaries and long texts; the plugin tests cover the reject path (the
response body must not contain the matched words), the fail-open and fail-closed paths, and the
stale dictionary fallback.
Manual check:
Addressed after the review
In
06f2b6109:request after the first scanned the body on the event loop of that request.
dictionary()now endswith
publishOn(Schedulers.boundedElastic()), so both paths are covered before the scan.buildFailureLinks()also resolves the words matched at every node once, which makessearch(...)linear in the length of the text instead of walking the failure chain per character.
(
Request rejected: sensitive content detected) and the matches are written to the plugin log only.The rejection code follows the
wafplugin (403).failClosedis a rule level option now (defaultfalse, i.e. the previousbehaviour). With it a rule rejects the request when the dictionary cannot be read and no cached
dictionary is available; a cached dictionary, even a stale one, is still enforced.
removePlugin()releases the cached redis template and properties. TheDICTIONARIESentry isintentionally kept when a rule is removed: it is keyed by the rule's
redisKey, so one dictionary canbe shared by several rules and dropping it on a single rule removal would force the others to rebuild.
Not included in this PR
Tracked in #7154, and left out on purpose so that this PR stays about the plugin implementation:
db/initanddb/upgraderows that register the plugin (theplugin,plugin_handleandresourcerows) together with the console rule form (shenyu-dashboard). Until they land the pluginis not reachable from the admin;
maxBodySize);not released, because
RedisConnectionFactoryexposes nodestroy()yet. This is pre-existing — theAI token limiter handler has the same shape — and will be a separate small PR against
shenyu-infra/shenyu-infra-redis.The dictionary itself is deliberately not bundled: a word list depends on the country, the business and
the applicable compliance rules, so it must not be shipped inside a gateway. Deployments provide their
own redis set (see the module README).