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 0f61285cde fix(admin): strip hop-by-hop headers in cluster forward 
filter (#7807) (#7822)
0f61285cde is described below

commit 0f61285cde56e920316720321f8400324c88b9e1
Author: Harsh Raj <[email protected]>
AuthorDate: Tue Oct 20 09:56:43 2026 +0530

    fix(admin): strip hop-by-hop headers in cluster forward filter (#7807) 
(#7822)
    
    Co-authored-by: Harsh <[email protected]>
---
 .../mode/cluster/filter/ClusterForwardFilter.java  | 111 ++++++++++-
 .../cluster/filter/ClusterForwardFilterTest.java   | 212 ++++++++++++++++++++-
 2 files changed, 313 insertions(+), 10 deletions(-)

diff --git 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilter.java
 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilter.java
index 7cc1445739..6e598b1dad 100644
--- 
a/shenyu-admin/src/main/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilter.java
+++ 
b/shenyu-admin/src/main/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilter.java
@@ -22,6 +22,7 @@ import jakarta.servlet.FilterChain;
 import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.io.IOUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.shenyu.admin.config.properties.ClusterProperties;
@@ -46,7 +47,12 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
 import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
 import java.util.Objects;
+import java.util.Set;
 
 /**
  * Cluster forward filter.
@@ -56,6 +62,17 @@ public class ClusterForwardFilter extends 
OncePerRequestFilter {
     private static final Logger LOG = 
LoggerFactory.getLogger(ClusterForwardFilter.class);
     
     private static final PathMatcher PATH_MATCHER = new AntPathMatcher();
+
+    private static final Set<String> HOP_BY_HOP_HEADERS = Set.of(
+            "connection",
+            "keep-alive",
+            "proxy-authenticate",
+            "proxy-authorization",
+            "te",
+            "trailer",
+            "transfer-encoding",
+            "upgrade"
+    );
     
     private final RestTemplate restTemplate;
     
@@ -68,6 +85,14 @@ public class ClusterForwardFilter extends 
OncePerRequestFilter {
     public ClusterForwardFilter(final RestTemplate restTemplate) {
         this.restTemplate = restTemplate;
     }
+
+    public ClusterForwardFilter(final RestTemplate restTemplate,
+                                final ClusterSelectMasterService 
clusterSelectMasterService,
+                                final ClusterProperties clusterProperties) {
+        this.restTemplate = restTemplate;
+        this.clusterSelectMasterService = clusterSelectMasterService;
+        this.clusterProperties = clusterProperties;
+    }
     
     @Override
     protected void doFilterInternal(@NotNull final HttpServletRequest request,
@@ -138,15 +163,30 @@ public class ClusterForwardFilter extends 
OncePerRequestFilter {
         ResponseEntity<byte[]> responseEntity = 
restTemplate.exchange(targetUrl, HttpMethod.valueOf(request.getMethod()), 
requestEntity, byte[].class);
         
         // Set response status and headers
-        response.setStatus(responseEntity.getStatusCode().value());
+        int statusCode = responseEntity.getStatusCode().value();
+        response.setStatus(statusCode);
         // Copy response headers
         copyHeaders(responseEntity.getHeaders(), response);
         // fix cors error
-        response.addHeader("Access-Control-Allow-Origin", "*");
-        response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, 
DELETE");
+        if (!response.containsHeader("Access-Control-Allow-Origin")) {
+            response.setHeader("Access-Control-Allow-Origin", "*");
+        }
+        if (!response.containsHeader("Access-Control-Allow-Methods")) {
+            response.setHeader("Access-Control-Allow-Methods", "GET, POST, 
PUT, DELETE");
+        }
         // write response back
-        IOUtils.copy(new 
ByteArrayInputStream(Objects.requireNonNull(responseEntity.getBody())), 
response.getOutputStream());
-        response.getOutputStream().flush();
+        byte[] body = Objects.nonNull(responseEntity.getBody()) ? 
responseEntity.getBody() : new byte[0];
+        if (statusCode != HttpServletResponse.SC_NO_CONTENT
+                && statusCode != HttpServletResponse.SC_NOT_MODIFIED
+                && (statusCode < 100 || statusCode >= 200)) {
+            response.setContentLength(body.length);
+        }
+        if (body.length > 0) {
+            IOUtils.copy(new ByteArrayInputStream(body), 
response.getOutputStream());
+            response.getOutputStream().flush();
+        } else {
+            response.flushBuffer();
+        }
     }
     
     @NotNull
@@ -178,19 +218,64 @@ public class ClusterForwardFilter extends 
OncePerRequestFilter {
         return builder.toUriString();
     }
     
-    private void copyHeaders(final HttpServletRequest request, final 
HttpHeaders headers) {
+    void copyHeaders(final HttpServletRequest request, final HttpHeaders 
headers) {
+        Set<String> hopByHopHeaders = buildHopByHopHeaders(request);
         Collections.list(request.getHeaderNames())
-                .forEach(headerName -> headers.add(headerName, 
removeSpecial(request.getHeader(headerName))));
+                .forEach(headerName -> {
+                    String name = removeSpecial(headerName);
+                    String lowerName = name.toLowerCase(Locale.ROOT);
+                    if (!hopByHopHeaders.contains(lowerName)
+                            && !HttpHeaders.HOST.equalsIgnoreCase(lowerName)
+                            && 
!HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(lowerName)) {
+                        Collections.list(request.getHeaders(headerName))
+                                .forEach(headerValue -> headers.add(name, 
removeSpecial(headerValue)));
+                    }
+                });
     }
     
-    private void copyHeaders(final HttpHeaders sourceHeaders, final 
HttpServletResponse response) {
+    void copyHeaders(final HttpHeaders sourceHeaders, final 
HttpServletResponse response) {
+        Set<String> hopByHopHeaders = buildHopByHopHeaders(sourceHeaders);
         sourceHeaders.forEach((headerName, headerValues) -> {
             String name = removeSpecial(headerName);
-            if (!response.containsHeader(name)) {
+            String lowerName = name.toLowerCase(Locale.ROOT);
+            if (!hopByHopHeaders.contains(lowerName)
+                    && !HttpHeaders.CONTENT_LENGTH.equalsIgnoreCase(lowerName)
+                    && !response.containsHeader(name)) {
                 headerValues.forEach(headerValue -> response.addHeader(name, 
removeSpecial(headerValue)));
             }
         });
     }
+
+    private Set<String> buildHopByHopHeaders(final HttpServletRequest request) 
{
+        Set<String> hopByHopHeaders = new HashSet<>(HOP_BY_HOP_HEADERS);
+        Enumeration<String> connectionHeaders = 
request.getHeaders(HttpHeaders.CONNECTION);
+        if (Objects.nonNull(connectionHeaders)) {
+            while (connectionHeaders.hasMoreElements()) {
+                String value = connectionHeaders.nextElement();
+                if (StringUtils.isNotEmpty(value)) {
+                    for (String token : value.split(",")) {
+                        
hopByHopHeaders.add(token.trim().toLowerCase(Locale.ROOT));
+                    }
+                }
+            }
+        }
+        return hopByHopHeaders;
+    }
+
+    private Set<String> buildHopByHopHeaders(final HttpHeaders sourceHeaders) {
+        Set<String> hopByHopHeaders = new HashSet<>(HOP_BY_HOP_HEADERS);
+        List<String> connectionValues = 
sourceHeaders.get(HttpHeaders.CONNECTION);
+        if (CollectionUtils.isNotEmpty(connectionValues)) {
+            for (String value : connectionValues) {
+                if (StringUtils.isNotEmpty(value)) {
+                    for (String token : value.split(",")) {
+                        
hopByHopHeaders.add(token.trim().toLowerCase(Locale.ROOT));
+                    }
+                }
+            }
+        }
+        return hopByHopHeaders;
+    }
     
     private String removeSpecial(final String str) {
         return str.replaceAll("\r", "").replaceAll("\n", "");
@@ -206,5 +291,13 @@ public class ClusterForwardFilter extends 
OncePerRequestFilter {
         }
         return baos.toByteArray();
     }
+
+    public void setClusterSelectMasterService(final ClusterSelectMasterService 
clusterSelectMasterService) {
+        this.clusterSelectMasterService = clusterSelectMasterService;
+    }
+
+    public void setClusterProperties(final ClusterProperties 
clusterProperties) {
+        this.clusterProperties = clusterProperties;
+    }
     
 }
diff --git 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilterTest.java
 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilterTest.java
index 0436d20af9..7fb8b5eb6b 100644
--- 
a/shenyu-admin/src/test/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilterTest.java
+++ 
b/shenyu-admin/src/test/java/org/apache/shenyu/admin/mode/cluster/filter/ClusterForwardFilterTest.java
@@ -17,9 +17,13 @@
 
 package org.apache.shenyu.admin.mode.cluster.filter;
 
+import com.sun.net.httpserver.HttpServer;
+import jakarta.servlet.DispatcherType;
 import org.apache.shenyu.admin.config.properties.ClusterProperties;
 import org.apache.shenyu.admin.model.dto.ClusterMasterDTO;
 import org.apache.shenyu.admin.mode.cluster.service.ClusterSelectMasterService;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -28,7 +32,10 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
+import 
org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
+import org.springframework.boot.web.server.WebServer;
 import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpMethod;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
@@ -39,25 +46,37 @@ import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.web.client.RestTemplate;
 import org.yaml.snakeyaml.Yaml;
 
+import java.io.BufferedReader;
 import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.net.URI;
 import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
- * Test case for {@link ClusterForwardFilter}.
+ * Test cases for {@link ClusterForwardFilter}.
  */
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
@@ -67,6 +86,16 @@ public final class ClusterForwardFilterTest {
 
     private static final String MASTER_PORT = "9095";
 
+    private static final String MASTER_BODY = 
"{\"code\":200,\"message\":\"success\",\"data\":{\"id\":\"123\"}}";
+
+    private static HttpServer masterServer;
+
+    private static WebServer followerServer;
+
+    private static int masterPort;
+
+    private static int followerPort;
+
     @Mock
     private RestTemplate restTemplate;
 
@@ -91,6 +120,56 @@ public final class ClusterForwardFilterTest {
         when(clusterSelectMasterService.getMaster()).thenReturn(master);
     }
 
+    @BeforeAll
+    public static void setUpServers() throws Exception {
+        masterServer = HttpServer.create(new InetSocketAddress(0), 0);
+        masterServer.createContext("/selector", exchange -> {
+            byte[] bytes = MASTER_BODY.getBytes(StandardCharsets.UTF_8);
+            exchange.getResponseHeaders().set("Content-Type", 
"application/json");
+            // sendResponseHeaders with 0 triggers HTTP/1.1 chunked transfer 
encoding in JDK HttpServer
+            exchange.sendResponseHeaders(200, 0);
+            OutputStream os = exchange.getResponseBody();
+            os.write(bytes);
+            os.close();
+        });
+        masterServer.start();
+        masterPort = masterServer.getAddress().getPort();
+
+        ClusterSelectMasterService selectMasterService = 
mock(ClusterSelectMasterService.class);
+        when(selectMasterService.isMaster()).thenReturn(false);
+        ClusterMasterDTO masterDTO = new ClusterMasterDTO();
+        masterDTO.setMasterHost("127.0.0.1");
+        masterDTO.setMasterPort(String.valueOf(masterPort));
+        masterDTO.setContextPath("");
+        when(selectMasterService.getMaster()).thenReturn(masterDTO);
+
+        ClusterProperties forwardingProperties = new ClusterProperties();
+        forwardingProperties.setForwardList(Collections.singletonList("/**"));
+        forwardingProperties.setIgnoredList(Collections.emptyList());
+
+        ClusterForwardFilter filter = new ClusterForwardFilter(new 
RestTemplate(), selectMasterService, forwardingProperties);
+
+        TomcatServletWebServerFactory factory = new 
TomcatServletWebServerFactory(0);
+        followerServer = factory.getWebServer(servletContext -> {
+            servletContext.addFilter("clusterForwardFilter", filter)
+                    
.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, "/*");
+            servletContext.addServlet("testServlet", new 
jakarta.servlet.http.HttpServlet() { })
+                    .addMapping("/*");
+        });
+        followerServer.start();
+        followerPort = followerServer.getPort();
+    }
+
+    @AfterAll
+    public static void tearDownServers() {
+        if (Objects.nonNull(followerServer)) {
+            followerServer.stop();
+        }
+        if (Objects.nonNull(masterServer)) {
+            masterServer.stop(0);
+        }
+    }
+
     @Test
     public void testMasterHandlesRequestsLocally() throws Exception {
         when(clusterSelectMasterService.isMaster()).thenReturn(true);
@@ -153,6 +232,137 @@ public final class ClusterForwardFilterTest {
         }
     }
 
+    @Test
+    public void 
testCopyRequestHeadersExcludesHopByHopAndHostAndContentLength() {
+        MockHttpServletRequest request = new MockHttpServletRequest();
+        request.addHeader(HttpHeaders.CONNECTION, "close, X-Custom-Hop");
+        request.addHeader(HttpHeaders.HOST, "follower.shenyu.apache.org:9096");
+        request.addHeader(HttpHeaders.CONTENT_LENGTH, "100");
+        request.addHeader(HttpHeaders.TRANSFER_ENCODING, "chunked");
+        request.addHeader(HttpHeaders.UPGRADE, "websocket");
+        request.addHeader("X-Custom-Hop", "remove-this");
+        request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer test-token");
+        request.addHeader("X-Forwarded-For", "192.168.1.1");
+
+        HttpHeaders headers = new HttpHeaders();
+        ClusterForwardFilter filter = new 
ClusterForwardFilter(mock(RestTemplate.class));
+        filter.copyHeaders(request, headers);
+
+        assertFalse(headers.containsKey(HttpHeaders.CONNECTION));
+        assertFalse(headers.containsKey(HttpHeaders.HOST));
+        assertFalse(headers.containsKey(HttpHeaders.CONTENT_LENGTH));
+        assertFalse(headers.containsKey(HttpHeaders.TRANSFER_ENCODING));
+        assertFalse(headers.containsKey(HttpHeaders.UPGRADE));
+        assertFalse(headers.containsKey("X-Custom-Hop"));
+
+        assertTrue(headers.containsKey(HttpHeaders.AUTHORIZATION));
+        assertEquals("Bearer test-token", 
headers.getFirst(HttpHeaders.AUTHORIZATION));
+        assertTrue(headers.containsKey("X-Forwarded-For"));
+        assertEquals("192.168.1.1", headers.getFirst("X-Forwarded-For"));
+    }
+
+    @Test
+    public void testCopyResponseHeadersExcludesHopByHopAndContentLength() {
+        HttpHeaders sourceHeaders = new HttpHeaders();
+        sourceHeaders.add(HttpHeaders.TRANSFER_ENCODING, "chunked");
+        sourceHeaders.add(HttpHeaders.CONNECTION, "keep-alive, X-Resp-Hop");
+        sourceHeaders.add("Keep-Alive", "timeout=5");
+        sourceHeaders.add(HttpHeaders.CONTENT_LENGTH, "456");
+        sourceHeaders.add("X-Resp-Hop", "strip-this");
+        sourceHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
+        sourceHeaders.add("X-Custom-Header", "allowed-value");
+
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        ClusterForwardFilter filter = new 
ClusterForwardFilter(mock(RestTemplate.class));
+        filter.copyHeaders(sourceHeaders, response);
+
+        assertFalse(response.containsHeader(HttpHeaders.TRANSFER_ENCODING));
+        assertFalse(response.containsHeader(HttpHeaders.CONNECTION));
+        assertFalse(response.containsHeader("Keep-Alive"));
+        assertFalse(response.containsHeader(HttpHeaders.CONTENT_LENGTH));
+        assertFalse(response.containsHeader("X-Resp-Hop"));
+
+        assertTrue(response.containsHeader(HttpHeaders.CONTENT_TYPE));
+        assertEquals("application/json", 
response.getHeader(HttpHeaders.CONTENT_TYPE));
+        assertTrue(response.containsHeader("X-Custom-Header"));
+        assertEquals("allowed-value", response.getHeader("X-Custom-Header"));
+    }
+
+    @Test
+    public void testRealHttpFollowerForwardingWithConnectionClose() throws 
Exception {
+        URI uri = URI.create("http://127.0.0.1:"; + followerPort + "/selector");
+        HttpURLConnection connection = (HttpURLConnection) 
uri.toURL().openConnection();
+        connection.setRequestMethod("GET");
+        connection.setRequestProperty(HttpHeaders.CONNECTION, "close");
+        connection.setConnectTimeout(5000);
+        connection.setReadTimeout(5000);
+
+        int responseCode = connection.getResponseCode();
+        assertEquals(200, responseCode);
+
+        // Verify that follower response does NOT advertise chunked encoding 
when Connection: close
+        assertNull(connection.getHeaderField(HttpHeaders.TRANSFER_ENCODING));
+
+        // Content-Length must be present and match the forwarded payload bytes
+        String contentLengthHeader = 
connection.getHeaderField(HttpHeaders.CONTENT_LENGTH);
+        byte[] expectedBytes = MASTER_BODY.getBytes(StandardCharsets.UTF_8);
+        assertEquals(String.valueOf(expectedBytes.length), 
contentLengthHeader);
+
+        // Reading the body must succeed completely without IncompleteRead
+        InputStream is = connection.getInputStream();
+        byte[] actualBytes = is.readAllBytes();
+        assertEquals(new String(expectedBytes, StandardCharsets.UTF_8), new 
String(actualBytes, StandardCharsets.UTF_8));
+        connection.disconnect();
+    }
+
+    @Test
+    public void testRealHttpFollowerForwardingWithConnectionKeepAlive() throws 
Exception {
+        try (Socket socket = new Socket("127.0.0.1", followerPort)) {
+            socket.setSoTimeout(5000);
+            OutputStream out = socket.getOutputStream();
+            String rawRequest = "GET /selector HTTP/1.1\r\n"
+                    + "Host: 127.0.0.1:" + followerPort + "\r\n"
+                    + "Connection: keep-alive\r\n\r\n";
+            out.write(rawRequest.getBytes(StandardCharsets.UTF_8));
+            out.flush();
+
+            BufferedReader reader = new BufferedReader(new 
InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
+            String statusLine = reader.readLine();
+            assertTrue(statusLine.contains("200"));
+
+            List<String> transferEncodingHeaders = new ArrayList<>();
+            List<String> connectionHeaders = new ArrayList<>();
+            String line;
+            int contentLength = -1;
+            while (Objects.nonNull(line = reader.readLine()) && 
!line.isEmpty()) {
+                String lower = line.toLowerCase();
+                if (lower.startsWith("transfer-encoding:")) {
+                    transferEncodingHeaders.add(line);
+                } else if (lower.startsWith("connection:")) {
+                    connectionHeaders.add(line);
+                } else if (lower.startsWith("content-length:")) {
+                    contentLength = 
Integer.parseInt(line.split(":")[1].trim());
+                }
+            }
+
+            // Must NOT have duplicate Transfer-Encoding headers
+            assertEquals(0, transferEncodingHeaders.size());
+
+            // Connection header must not have duplicated tokens like 
"keep-alive, keep-alive"
+            assertEquals(1, connectionHeaders.size());
+            assertFalse(connectionHeaders.get(0).contains("keep-alive, 
keep-alive"));
+
+            // Content-Length must match the forwarded payload
+            byte[] expectedBytes = 
MASTER_BODY.getBytes(StandardCharsets.UTF_8);
+            assertEquals(expectedBytes.length, contentLength);
+
+            char[] bodyChars = new char[contentLength];
+            int read = reader.read(bodyChars, 0, contentLength);
+            assertEquals(contentLength, read);
+            assertEquals(MASTER_BODY, new String(bodyChars));
+        }
+    }
+
     private MockHttpServletRequest beatRequest() {
         final MockHttpServletRequest request = new MockHttpServletRequest();
         request.setMethod("POST");

Reply via email to