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();
+    }
 }

Reply via email to