Skip to content

[type:feat] add the sensitive word plugin - #7153

Merged
Aias00 merged 6 commits into
apache:masterfrom
HY-love-sleep:feat/sensitive-word-plugin
Sep 21, 2026
Merged

Aias00 merged 6 commits into
apache:masterfrom
HY-love-sleep:feat/sensitive-word-plugin

Conversation

@HY-love-sleep

@HY-love-sleep HY-love-sleep commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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 waf plugin 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-word the plugin, the Aho-Corasick automaton and the data handler
shenyu-spring-boot-starter-plugin-ai-sensitive-word the starter
shenyu-common SensitiveWordHandle (rule level) and PluginEnum.SENSITIVE_WORD
shenyu-plugin-ai/pom.xml, starter pom, shenyu-bootstrap/pom.xml module registration

Design

  1. The dictionary lives in redis. The rule carries a redisKey (default
    shenyu:sensitive:words) and the plugin reads the set with SMEMBERS, so the dictionary is
    maintained by operations without redeploying shenyu, and every rule can point at its own set.
  2. Aho-Corasick. The dictionary is compiled into an automaton, so a body is scanned in a
    single pass and every matching word is reported, nested and overlapping ones included (both
    中国 and 中国银行 for 中国银行).
  3. The automaton is cached per redis key, not per plugin: rules with different dictionaries
    never share an automaton. A cached dictionary is read from redis again after
    refreshIntervalSeconds (default 300), and it is dropped immediately when the rule is
    configured again, so a dictionary change does not need a gateway restart.
  4. The event loop is never blocked. The body is read with the shared
    ServerWebExchangeUtils#rewriteRequestBody, and the automaton is compiled on a bounded elastic
    thread, never inside the request thread.
  5. Fail open. If redis is unreachable the request is passed through and the failure is logged.
    A dictionary that cannot be read must not take the traffic down.
  6. Request body only. Only the request body is inspected, so the plugin is not limited to AI
    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:

field type default description
redisKey string shenyu:sensitive:words the redis set holding the dictionary of this rule
refreshIntervalSeconds long 300 how long a compiled dictionary is reused, 0 reads it on every request

Why not extend the waf plugin

WafHandle only carries permission and statusCode, and waf runs at order 50, before the
body 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

./mvnw -pl shenyu-common -Dtest=SensitiveWordHandleTest test
./mvnw -pl shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-sensitive-word,<starter> -am test
./mvnw -pl shenyu-bootstrap -am -DskipTests package

All green: checkstyle 0 violations, RAT ok, and 38 tests
(AhoCorasickTest 14, SensitiveWordPluginTest 9, SensitiveWordPluginDataHandlerTest 10,
SensitiveWordHandleTest 3, starter 2). The automaton tests cover nested, overlapping and suffix
words, 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:

redis-cli SADD shenyu:sensitive:words "bad word"
curl -X POST http://localhost:9195/ai/chat -d 'a bad word here'

Addressed after the review

In 06f2b6109:

  • the scan ran on the netty event loop — the cached path emits on the subscribing thread, so every
    request after the first scanned the body on the event loop of that request. dictionary() now ends
    with publishOn(Schedulers.boundedElastic()), so both paths are covered before the scan.
    buildFailureLinks() also resolves the words matched at every node once, which makes search(...)
    linear in the length of the text instead of walking the failure chain per character.
  • the rejection echoed the matched words — the client now gets a generic message
    (Request rejected: sensitive content detected) and the matches are written to the plugin log only.
    The rejection code follows the waf plugin (403).
  • fail strategyfailClosed is a rule level option now (default false, i.e. the previous
    behaviour). 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. The DICTIONARIES entry is
    intentionally kept when a rule is removed: it is keyed by the rule's redisKey, so one dictionary can
    be 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:

  • the db/init and db/upgrade rows that register the plugin (the plugin, plugin_handle and
    resource rows) together with the console rule form (shenyu-dashboard). Until they land the plugin
    is not reachable from the admin;
  • a bound on the inspected body (maxBodySize);
  • when the plugin config changes the redis connection is rebuilt and the previous lettuce connection is
    not released, because RedisConnectionFactory exposes no destroy() yet. This is pre-existing — the
    AI 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).

HY-love-sleep and others added 2 commits September 21, 2026 11:20
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Aias00 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. [blocking] scan runs on the Netty event loop - SensitiveWordPlugin.java:98
    check() maps .map(automaton -> automaton.search(body)) onto whatever dictionary() emits. Only the redis path has subscribeOn(Schedulers.boundedElastic()); the cached path returns Mono.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).

  2. [blocking] the rejection message echoes the matched words back to the caller - SensitiveWordPlugin.java:104
    The matches are formatted into the ResponsiveException message and reach the client through WebFluxResultUtils#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

  1. 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.

  2. The replaced redis connection is never released. In handlerPlugin() a new RedisConnectionFactory + template are cached while the previous pair is dropped without being closed, leaking the pool and its resources. RedisConnectionFactory only exposes getLettuceConnectionFactory() (no destroy()), so nothing today can release it - and AiTokenLimiterPluginHandler already does exactly the same, so this is not debt you are introducing. If you want to fix it: keep the obsolete factory reference and call destroy() before replacing it, or add destroy() to RedisConnectionFactory in a separate small PR and use it here.

  3. There is no bound on the body being inspected: every request body is fully buffered and scanned. Consider a maxBodySize guard, otherwise putting this plugin on an upload route means unbounded buffering and CPU per request.

  4. removeRule() drops the handle but keeps the DICTIONARIES entry, and there is no removePlugin() releasing the redis template when the plugin is removed. Bounded in practice, but worth a follow-up.

  5. Agreed that the db/init scripts 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.

847850277 and others added 2 commits September 21, 2026 15:23
- 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
@HY-love-sleep

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review — both blocking points were real and are fixed in 06f2b6109.

1. The scan ran on the event loop — fixed.
You were right: only the redis path hopped threads, and every request after the first takes the cached
path. dictionary() now ends with publishOn(Schedulers.boundedElastic()), so both branches are
covered before the scan. I also took your second suggestion: buildFailureLinks() now resolves the
matched words of every node once (outputs = own word + fail.outputs), so search(...) no longer walks
the failure chain and is linear in the length of the text.

2. The rejection echoed the matched words — fixed.
The client now receives Request rejected: sensitive content detected; the matched words go to the
plugin log only. While there, the rejection code follows the waf plugin (403) instead of the
plugin-invented 1500, and the test asserts that the response body does not contain the matched word.

3. fail strategy — done.
failClosed is a rule level option now (default false, i.e. the current behaviour). With
failClosed: true 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.

4 / 5 / 6

  • 4 (redis connection not released) agreed, and as you said it is pre-existing:
    RedisConnectionFactory exposes no destroy() and AiTokenLimiterPluginHandler has the same shape. I
    would rather do it as a separate small PR on shenyu-infra-redis (add destroy(), call it before
    replacing the factory) and keep this one about the plugin.
  • 5 (no bound on the body) agreed; it needs a policy decision (skip the scan and warn, or reject, and
    how that interacts with failClosed). It is in the follow-up issue below.
  • 6 removePlugin() is implemented (releases the cached template and properties). I deliberately did
    not drop the DICTIONARIES entry in removeRule(): that cache is keyed by the rule's redisKey,
    so one dictionary can be shared by several rules and dropping it on a single rule removal would force
    the others to rebuild. Happy to change it if you prefer the rebuild.

7 Follow-up issue opened: #7154 — the plugin / plugin_handle / resource rows, the console rule
form and the body size bound. It is linked from the PR description as well, so this PR stays focused on
the plugin implementation.

Local, on 06f2b6109: 38 tests green (including the new fail-closed paths, the stale dictionary fallback
and removePlugin), checkstyle 0, RAT ok, shenyu-bootstrap -am package ok.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants