This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new e247700150 fix(ai): accept AI-generated request targets carrying a
query string (#7459) (#7534)
e247700150 is described below
commit e247700150f52d3e999af8298a81f1b9e9bd7dbc
Author: HY-love-sleep <[email protected]>
AuthorDate: Sat Oct 10 16:21:08 2026 +0800
fix(ai): accept AI-generated request targets carrying a query string
(#7459) (#7534)
The request transformer rejected a generated origin-form target that
contains a
query string, because the whole target was validated against the path
character
set. Split the target into path and query, validate each component, and
apply
both to the request URI.
---
.../request/AiRequestTransformerPlugin.java | 38 +++++++++---
.../request/AiRequestTransformerPluginTest.java | 72 ++++++++++++++++++++++
2 files changed, 103 insertions(+), 7 deletions(-)
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/main/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPlugin.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/main/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPlugin.java
index be6e0bdd92..dbaa551d73 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/main/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPlugin.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/main/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPlugin.java
@@ -66,6 +66,17 @@ public class AiRequestTransformerPlugin extends
AbstractShenyuPlugin {
private static final Logger LOG =
LoggerFactory.getLogger(AiRequestTransformerPlugin.class);
+ /**
+ * Valid characters of a generated origin-form path component.
+ */
+ private static final String PATH_PATTERN = "^/[a-zA-Z0-9/_\\-]*$";
+
+ /**
+ * Valid characters of a generated query component: RFC 3986 query syntax
(percent-encoded
+ * sequences must be well formed so that the target is always a legal URI
reference).
+ */
+ private static final String QUERY_PATTERN =
"^(%[0-9a-fA-F]{2}|[a-zA-Z0-9\\-._~!$&'()*+,;=:@/?])*$";
+
private final List<HttpMessageReader<?>> messageReaders;
private final AiModelFactoryRegistry aiModelFactoryRegistry;
@@ -232,33 +243,46 @@ public class AiRequestTransformerPlugin extends
AbstractShenyuPlugin {
}
private static Mono<ServerWebExchange> rewriteRequestPath(final
ServerWebExchange exchange, final String aiResponse) {
- String newPath = extractRequestPathFromAiResponse(aiResponse);
+ String newTarget = extractRequestPathFromAiResponse(aiResponse);
- if (Objects.isNull(newPath) || newPath.isEmpty()) {
+ if (Objects.isNull(newTarget) || newTarget.isEmpty()) {
return Mono.just(exchange);
}
- if (newPath.contains("..")) {
- LOG.warn("Detected potential path traversal attempt in extracted
path: {} , Will continue to use the original path.", newPath);
+ if (newTarget.contains("..")) {
+ LOG.warn("Detected potential path traversal attempt in extracted
path: {} , Will continue to use the original path.", newTarget);
return Mono.just(exchange);
}
+ // The model answers with an origin-form request target, so it may
carry a query string.
+ // Validate the path and the query separately, then apply both to the
request URI.
+ int queryIndex = newTarget.indexOf('?');
+ String newPath = queryIndex < 0 ? newTarget : newTarget.substring(0,
queryIndex);
+ String newQuery = queryIndex < 0 ? null :
newTarget.substring(queryIndex + 1);
+
if (!newPath.startsWith("/")) {
LOG.warn("Extracted path does not start with '/': {} , Will
continue to use the original path.", newPath);
return Mono.just(exchange);
}
- if (!newPath.matches("^/[a-zA-Z0-9/_\\-]*$")) {
+ if (!newPath.matches(PATH_PATTERN)) {
LOG.warn("Extracted path contains invalid characters: {}, Will
continue to use the original path.", newPath);
return Mono.just(exchange);
}
- LOG.debug("Request path after validation and rewriting: {}", newPath);
+ if (Objects.nonNull(newQuery) && !newQuery.matches(QUERY_PATTERN)) {
+ LOG.warn("Extracted query contains invalid characters: {}, Will
continue to use the original path.", newQuery);
+ return Mono.just(exchange);
+ }
+
+ String rewrittenTarget = Objects.isNull(newQuery) ? newPath : newPath
+ "?" + newQuery;
+
+ LOG.debug("Request path after validation and rewriting: {}",
rewrittenTarget);
ServerHttpRequest originalRequest = exchange.getRequest();
URI originalUri = originalRequest.getURI();
- URI newUri = originalUri.resolve(newPath);
+ URI newUri = originalUri.resolve(rewrittenTarget);
ServerHttpRequest newRequest = originalRequest.mutate()
.uri(newUri)
diff --git
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/test/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPluginTest.java
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/test/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPluginTest.java
index 119e023338..3258960e9f 100644
---
a/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/test/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPluginTest.java
+++
b/shenyu-plugin/shenyu-plugin-ai/shenyu-plugin-ai-request-transformer/src/test/java/org/apache/shenyu/plugin/ai/transformer/request/AiRequestTransformerPluginTest.java
@@ -44,6 +44,7 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
+import java.net.URI;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@@ -158,4 +159,75 @@ class AiRequestTransformerPluginTest {
String result =
AiRequestTransformerPlugin.extractRequestPathFromAiResponse(aiResponse);
assertEquals("/", result);
}
+
+ @Test
+ void testRewriteRequestPathWithQueryString() {
+ String aiResponse = "POST /rewritten?new=2 HTTP/1.1\nContent-Type:
application/json\n\n{\"ai\":\"query\"}";
+
+ URI forwardedUri = forwardAndCaptureRequestUri(aiResponse,
"/original?old=1");
+
+ assertEquals("/rewritten", forwardedUri.getPath());
+ assertEquals("new=2", forwardedUri.getQuery());
+ }
+
+ @Test
+ void testRewriteRequestPathKeepsPercentEncodedQueryString() {
+ String aiResponse = "POST /rewritten?q=a%20b&n=1
HTTP/1.1\nContent-Type: application/json\n\n{\"ai\":\"query\"}";
+
+ URI forwardedUri = forwardAndCaptureRequestUri(aiResponse,
"/original");
+
+ assertEquals("/rewritten", forwardedUri.getPath());
+ assertEquals("q=a%20b&n=1", forwardedUri.getRawQuery());
+ }
+
+ @Test
+ void testRewriteRequestPathKeepsOriginalTargetOnMalformedQueryString() {
+ String aiResponse = "POST /rewritten?bad=%zz HTTP/1.1\nContent-Type:
application/json\n\n{\"ai\":\"query\"}";
+
+ URI forwardedUri = forwardAndCaptureRequestUri(aiResponse,
"/original?old=1");
+
+ assertEquals("/original", forwardedUri.getPath());
+ assertEquals("old=1", forwardedUri.getQuery());
+ }
+
+ /**
+ * Runs the plugin with a mocked model answer and returns the request URI
the plugin chain received.
+ *
+ * @param aiResponse the mocked model answer, a full HTTP request message
+ * @param originalUri the URI of the incoming request
+ * @return the URI handed to the downstream plugin chain
+ */
+ private URI forwardAndCaptureRequestUri(final String aiResponse, final
String originalUri) {
+ AiRequestTransformerHandle handle = new AiRequestTransformerHandle();
+ handle.setProvider("TEST_PROVIDER");
+ handle.setBaseUrl("http://test.com");
+ handle.setApiKey("test-api-key");
+ handle.setModel("test-model");
+
+ ChatClient mockClient = mock(ChatClient.class, RETURNS_DEEP_STUBS);
+
when(mockClient.prompt().user(anyString()).stream().content()).thenReturn(Flux.just(aiResponse));
+ MockServerWebExchange exchange =
MockServerWebExchange.from(MockServerHttpRequest.post(originalUri)
+ .header(HttpHeaders.CONTENT_TYPE,
MediaType.APPLICATION_JSON_VALUE)
+ .body("{\"original\":true}"));
+ RuleData rule = new RuleData();
+ rule.setId("test-request-target-rule-id");
+ rule.setSelectorId("test-selector-id");
+ when(chatClientCache.getClient(rule.getId())).thenReturn(mockClient);
+ AtomicReference<URI> downstreamUri = new AtomicReference<>();
+
when(chain.execute(any(ServerWebExchange.class))).thenAnswer(invocation -> {
+ downstreamUri.set(invocation.getArgument(0,
ServerWebExchange.class).getRequest().getURI());
+ return Mono.empty();
+ });
+
+ String cacheKey = CacheKeyUtils.INST.getKey(rule);
+
AiRequestTransformerPluginHandler.CACHED_HANDLE.get().cachedHandle(cacheKey,
handle);
+ try (MockedStatic<ChatClientCache> mockedCache =
mockStatic(ChatClientCache.class)) {
+
mockedCache.when(ChatClientCache::getInstance).thenReturn(chatClientCache);
+ StepVerifier.create(plugin.doExecute(exchange, chain, new
SelectorData(), rule)).verifyComplete();
+ } finally {
+
AiRequestTransformerPluginHandler.CACHED_HANDLE.get().removeHandle(cacheKey);
+ }
+
+ return downstreamUri.get();
+ }
}