This is an automated email from the ASF dual-hosted git repository.

markt-asf pushed a commit to branch 11.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/11.0.x by this push:
     new 920e7c9401 Validate the server handshake response in the WebSocket 
client (#1114)
920e7c9401 is described below

commit 920e7c9401a689234ec117ef6e2ced90c26f7b68
Author: Arturo Bernal <[email protected]>
AuthorDate: Thu Nov 5 18:57:09 2026 +0100

    Validate the server handshake response in the WebSocket client (#1114)
    
    RFC 6455, section 4.1 requires the client to fail the WebSocket
    connection if the Upgrade header is missing or is not "websocket", if
    the Connection header does not contain the "Upgrade" token, if the
    Sec-WebSocket-Accept header is missing or does not match the key that
    was sent, or if the server selects a sub-protocol or an extension that
    the client did not request. None of these were checked and the
    connection
    was opened whatever the server sent. Fail the connection in these cases.
    
    HTTP header names are case insensitive
---
 java/org/apache/tomcat/websocket/Constants.java    |   8 +
 .../tomcat/websocket/LocalStrings.properties       |   6 +
 .../tomcat/websocket/WsWebSocketContainer.java     |  69 ++++++-
 .../websocket/TestWsClientHandshakeResponse.java   | 215 +++++++++++++++++++++
 4 files changed, 297 insertions(+), 1 deletion(-)

diff --git a/java/org/apache/tomcat/websocket/Constants.java 
b/java/org/apache/tomcat/websocket/Constants.java
index 05ac17b4b3..d30cdf07f0 100644
--- a/java/org/apache/tomcat/websocket/Constants.java
+++ b/java/org/apache/tomcat/websocket/Constants.java
@@ -147,6 +147,14 @@ public class Constants {
      * Sec-WebSocket-Version header value.
      */
     public static final String WS_VERSION_HEADER_VALUE = "13";
+    /**
+     * Sec-WebSocket-Accept header name.
+     */
+    public static final String WS_ACCEPT_HEADER_NAME = "Sec-WebSocket-Accept";
+    /**
+     * GUID that is appended to the Sec-WebSocket-Key header value to compute 
the Sec-WebSocket-Accept header value.
+     */
+    public static final String WS_ACCEPT_GUID = 
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
     /**
      * Sec-WebSocket-Key HTTP header name.
      */
diff --git a/java/org/apache/tomcat/websocket/LocalStrings.properties 
b/java/org/apache/tomcat/websocket/LocalStrings.properties
index fd12229b4e..13c08afbe8 100644
--- a/java/org/apache/tomcat/websocket/LocalStrings.properties
+++ b/java/org/apache/tomcat/websocket/LocalStrings.properties
@@ -147,12 +147,16 @@ wsWebSocketContainer.defaultConfiguratorFail=Failed to 
create the default config
 wsWebSocketContainer.failedAuthentication=Failed to handle HTTP response code 
[{0}]. [{1}] header was not accepted by server.
 wsWebSocketContainer.httpRequestFailed=The HTTP request to initiate the 
WebSocket connection to [{0}] failed
 wsWebSocketContainer.illegalBufferSize=Invalid default maximum message buffer 
size [{0}]. The value must be greater than zero.
+wsWebSocketContainer.invalidAcceptHeader=The Sec-WebSocket-Accept header of 
the server response is missing or does not have the expected value
+wsWebSocketContainer.invalidConnectionHeader=The Connection header of the 
server response is missing or does not contain the Upgrade token
 wsWebSocketContainer.invalidExtensionParameters=The server responded with 
extension parameters the client is unable to support
 wsWebSocketContainer.invalidExtensionsHeader=The server sent an invalid 
extension header
+wsWebSocketContainer.invalidExtensionsHeader=The server sent an invalid 
extension header
 wsWebSocketContainer.invalidHeader=Unable to parse HTTP header as no colon is 
present to delimit header name and header value in [{0}]. The header has been 
skipped.
 wsWebSocketContainer.invalidStatus=The HTTP response from the server [{0}] did 
not permit the HTTP upgrade to WebSocket
 wsWebSocketContainer.invalidSubProtocol=The WebSocket server returned multiple 
values for the Sec-WebSocket-Protocol header
 wsWebSocketContainer.maxBuffer=This implementation limits the maximum size of 
a buffer to Integer.MAX_VALUE
+wsWebSocketContainer.invalidUpgradeHeader=The Upgrade header of the server 
response is missing or does not have the value websocket
 wsWebSocketContainer.missingAnnotation=Cannot use POJO class [{0}] as it is 
not annotated with @ClientEndpoint
 wsWebSocketContainer.missingAuthenticateHeader=Failed to handle HTTP response 
code [{0}]. Missing [{1}] header in response
 wsWebSocketContainer.missingLocationHeader=Failed to handle HTTP response code 
[{0}]. Missing Location header in response
@@ -165,4 +169,6 @@ wsWebSocketContainer.responseHeadersLimit=The HTTP upgrade 
to WebSocket failed b
 wsWebSocketContainer.sessionCloseFail=Session with ID [{0}] did not close 
cleanly
 wsWebSocketContainer.shutdown=The web application is stopping
 wsWebSocketContainer.sslEngineFail=Unable to create SSLEngine to support 
SSL/TLS connections
+wsWebSocketContainer.unrequestedExtension=The server selected the extension 
[{0}] that was not requested by the client
+wsWebSocketContainer.unrequestedSubProtocol=The server selected the 
sub-protocol [{0}] that was not requested by the client
 wsWebSocketContainer.unsupportedAuthScheme=Failed to handle HTTP response code 
[{0}]. Unsupported Authentication scheme [{1}] returned in response
diff --git a/java/org/apache/tomcat/websocket/WsWebSocketContainer.java 
b/java/org/apache/tomcat/websocket/WsWebSocketContainer.java
index 82c3866558..af95426f6b 100644
--- a/java/org/apache/tomcat/websocket/WsWebSocketContainer.java
+++ b/java/org/apache/tomcat/websocket/WsWebSocketContainer.java
@@ -71,6 +71,7 @@ import org.apache.tomcat.util.buf.StringUtils;
 import org.apache.tomcat.util.collections.CaseInsensitiveKeyMap;
 import org.apache.tomcat.util.http.Method;
 import org.apache.tomcat.util.res.StringManager;
+import org.apache.tomcat.util.security.ConcurrentMessageDigest;
 
 /**
  * Client-side WebSocket container implementation.
@@ -420,12 +421,21 @@ public class WsWebSocketContainer implements 
WebSocketContainer, BackgroundProce
             HandshakeResponse handshakeResponse = 
httpResponse.handshakeResponse();
             
clientEndpointConfiguration.getConfigurator().afterResponse(handshakeResponse);
 
+            // RFC 6455, section 4.1: the client must validate the response
+            validateUpgradeResponse(upgradeRequestHeaders, 
handshakeResponse.getHeaders());
+
             // Sub-protocol
             List<String> protocolHeaders = 
handshakeResponse.getHeaders().get(Constants.WS_PROTOCOL_HEADER_NAME);
             if (protocolHeaders == null || protocolHeaders.isEmpty()) {
                 subProtocol = null;
             } else if (protocolHeaders.size() == 1) {
                 subProtocol = protocolHeaders.get(0);
+                List<String> requestedSubProtocols = 
upgradeRequestHeaders.get(Constants.WS_PROTOCOL_HEADER_NAME);
+                if (requestedSubProtocols == null || 
requestedSubProtocols.stream()
+                        .flatMap(v -> Arrays.stream(v.split(","))).noneMatch(v 
-> v.trim().equals(subProtocol))) {
+                    throw new DeploymentException(
+                            
sm.getString("wsWebSocketContainer.unrequestedSubProtocol", subProtocol));
+                }
             } else {
                 throw new 
DeploymentException(sm.getString("wsWebSocketContainer.invalidSubProtocol"));
             }
@@ -447,7 +457,12 @@ public class WsWebSocketContainer implements 
WebSocketContainer, BackgroundProce
 
             // Build the transformations
             TransformationFactory factory = 
TransformationFactory.getInstance();
+            Set<String> requestedExtensions = 
getRequestedExtensionNames(upgradeRequestHeaders);
             for (Extension extension : extensionsAgreed) {
+                if (!requestedExtensions.contains(extension.getName())) {
+                    throw new DeploymentException(
+                            
sm.getString("wsWebSocketContainer.unrequestedExtension", extension.getName()));
+                }
                 List<List<Extension.Parameter>> wrapper = new ArrayList<>(1);
                 wrapper.add(extension.getParameters());
                 Transformation t = factory.create(extension.getName(), 
wrapper, false);
@@ -691,7 +706,9 @@ public class WsWebSocketContainer implements 
WebSocketContainer, BackgroundProce
     private static Map<String,List<String>> createRequestHeaders(String host, 
int port, boolean secure,
             ClientEndpointConfig clientEndpointConfiguration) {
 
-        Map<String,List<String>> headers = new HashMap<>();
+        // HTTP header names are case-insensitive. A Configurator may modify 
these headers in beforeRequest() so the
+        // lookups used to validate the response must not depend on the case 
it uses.
+        Map<String,List<String>> headers = new CaseInsensitiveKeyMap<>();
         List<Extension> extensions = 
clientEndpointConfiguration.getExtensions();
         List<String> subProtocols = 
clientEndpointConfiguration.getPreferredSubprotocols();
         Map<String,Object> userProperties = 
clientEndpointConfiguration.getUserProperties();
@@ -918,6 +935,56 @@ public class WsWebSocketContainer implements 
WebSocketContainer, BackgroundProce
     }
 
 
+    /*
+     * RFC 6455, section 4.1. The sub-protocol and the extensions are 
validated once they have been parsed.
+     */
+    private static void validateUpgradeResponse(Map<String,List<String>> 
requestHeaders,
+            Map<String,List<String>> responseHeaders) throws 
DeploymentException {
+
+        List<String> upgrade = 
responseHeaders.get(Constants.UPGRADE_HEADER_NAME);
+        if (upgrade == null || upgrade.isEmpty() ||
+                !upgrade.stream().allMatch(v -> 
Constants.UPGRADE_HEADER_VALUE.equalsIgnoreCase(v.trim()))) {
+            throw new 
DeploymentException(sm.getString("wsWebSocketContainer.invalidUpgradeHeader"));
+        }
+
+        List<String> connection = 
responseHeaders.get(Constants.CONNECTION_HEADER_NAME);
+        if (connection == null || connection.stream().flatMap(v -> 
Arrays.stream(v.split(",")))
+                .noneMatch(v -> 
Constants.CONNECTION_HEADER_VALUE.equalsIgnoreCase(v.trim()))) {
+            throw new 
DeploymentException(sm.getString("wsWebSocketContainer.invalidConnectionHeader"));
+        }
+
+        List<String> accept = 
responseHeaders.get(Constants.WS_ACCEPT_HEADER_NAME);
+        List<String> key = requestHeaders.get(Constants.WS_KEY_HEADER_NAME);
+        if (accept == null || accept.size() != 1 || key == null || 
key.isEmpty() ||
+                
!generateWsAcceptValue(key.get(0)).equals(accept.get(0).trim())) {
+            throw new 
DeploymentException(sm.getString("wsWebSocketContainer.invalidAcceptHeader"));
+        }
+    }
+
+
+    private static String generateWsAcceptValue(String key) {
+        byte[] digest = 
ConcurrentMessageDigest.digestSHA1(key.getBytes(StandardCharsets.ISO_8859_1),
+                
Constants.WS_ACCEPT_GUID.getBytes(StandardCharsets.ISO_8859_1));
+        return Base64.getEncoder().encodeToString(digest);
+    }
+
+
+    private static Set<String> 
getRequestedExtensionNames(Map<String,List<String>> requestHeaders) {
+        Set<String> result = new HashSet<>();
+        List<String> headers = 
requestHeaders.get(Constants.WS_EXTENSIONS_HEADER_NAME);
+        if (headers != null) {
+            List<Extension> extensions = new ArrayList<>();
+            for (String header : headers) {
+                Util.parseExtensionHeader(extensions, header);
+            }
+            for (Extension extension : extensions) {
+                result.add(extension.getName());
+            }
+        }
+        return result;
+    }
+
+
     private int parseStatus(String line) throws DeploymentException {
         // This client only understands HTTP 1.
         // RFC2616 is case specific
diff --git 
a/test/org/apache/tomcat/websocket/TestWsClientHandshakeResponse.java 
b/test/org/apache/tomcat/websocket/TestWsClientHandshakeResponse.java
new file mode 100644
index 0000000000..fcebd6b113
--- /dev/null
+++ b/test/org/apache/tomcat/websocket/TestWsClientHandshakeResponse.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tomcat.websocket;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import jakarta.websocket.ClientEndpointConfig;
+import jakarta.websocket.ContainerProvider;
+import jakarta.websocket.DeploymentException;
+import jakarta.websocket.Endpoint;
+import jakarta.websocket.EndpointConfig;
+import jakarta.websocket.Session;
+import jakarta.websocket.WebSocketContainer;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.startup.LoggingBaseTest;
+
+/**
+ * RFC 6455, section 4.1 requires the client to fail the WebSocket connection 
if the server's handshake response is not
+ * valid. A minimal server that sends the response that is under test is used 
so that responses a compliant server would
+ * never send can be tested.
+ */
+public class TestWsClientHandshakeResponse extends LoggingBaseTest {
+
+    private static final String WS_ACCEPT_GUID = 
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
+
+    private static final String UPGRADE = "Upgrade: websocket\r\n";
+    private static final String CONNECTION = "Connection: Upgrade\r\n";
+
+    private static final int TIMEOUT_MS = 5000;
+
+    private static final Pattern KEY_PATTERN = 
Pattern.compile("Sec-WebSocket-Key: (.*)\r\n");
+
+
+    @Test
+    public void testValid() throws Exception {
+        Assert.assertTrue(connect(null, UPGRADE + CONNECTION + "%ACCEPT%"));
+    }
+
+
+    @Test
+    public void testValidCaseAndConnectionList() throws Exception {
+        Assert.assertTrue(connect(null, "upgrade: WebSocket\r\nconnection: 
keep-alive, upgrade\r\n%ACCEPT%"));
+    }
+
+
+    @Test
+    public void testValidRequestedSubProtocol() throws Exception {
+        Assert.assertTrue(connect("a", UPGRADE + CONNECTION + 
"%ACCEPT%Sec-WebSocket-Protocol: a\r\n"));
+    }
+
+
+    @Test
+    public void testMissingUpgrade() throws Exception {
+        Assert.assertFalse(connect(null, CONNECTION + "%ACCEPT%"));
+    }
+
+
+    @Test
+    public void testInvalidUpgrade() throws Exception {
+        Assert.assertFalse(connect(null, "Upgrade: foo\r\n" + CONNECTION + 
"%ACCEPT%"));
+    }
+
+
+    @Test
+    public void testMissingConnection() throws Exception {
+        Assert.assertFalse(connect(null, UPGRADE + "%ACCEPT%"));
+    }
+
+
+    @Test
+    public void testInvalidConnection() throws Exception {
+        Assert.assertFalse(connect(null, UPGRADE + "Connection: 
keep-alive\r\n%ACCEPT%"));
+    }
+
+
+    @Test
+    public void testMissingAccept() throws Exception {
+        Assert.assertFalse(connect(null, UPGRADE + CONNECTION));
+    }
+
+
+    @Test
+    public void testInvalidAccept() throws Exception {
+        Assert.assertFalse(
+                connect(null, UPGRADE + CONNECTION + "Sec-WebSocket-Accept: 
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n"));
+    }
+
+
+    @Test
+    public void testDuplicateAccept() throws Exception {
+        Assert.assertFalse(connect(null, UPGRADE + CONNECTION + 
"%ACCEPT%%ACCEPT%"));
+    }
+
+
+    @Test
+    public void testUnrequestedSubProtocol() throws Exception {
+        Assert.assertFalse(connect(null, UPGRADE + CONNECTION + 
"%ACCEPT%Sec-WebSocket-Protocol: a\r\n"));
+    }
+
+
+    @Test
+    public void testDifferentSubProtocol() throws Exception {
+        Assert.assertFalse(connect("a", UPGRADE + CONNECTION + 
"%ACCEPT%Sec-WebSocket-Protocol: b\r\n"));
+    }
+
+
+    @Test
+    public void testUnrequestedExtension() throws Exception {
+        Assert.assertFalse(
+                connect(null, UPGRADE + CONNECTION + 
"%ACCEPT%Sec-WebSocket-Extensions: permessage-deflate\r\n"));
+    }
+
+
+    /*
+     * Returns true if the client connected, false if the client rejected the 
response. Every %ACCEPT% marker in the
+     * response headers is replaced with the valid Sec-WebSocket-Accept header.
+     */
+    private boolean connect(String subProtocol, String responseHeaders) throws 
Exception {
+        AtomicReference<Throwable> serverFailure = new AtomicReference<>();
+        try (ServerSocket serverSocket = new ServerSocket(0)) {
+            serverSocket.setSoTimeout(TIMEOUT_MS);
+            Thread server = new Thread(() -> {
+                try {
+                    respond(serverSocket, responseHeaders);
+                } catch (Throwable t) {
+                    serverFailure.set(t);
+                }
+            });
+            server.start();
+
+            try {
+                ClientEndpointConfig.Builder builder = 
ClientEndpointConfig.Builder.create();
+                if (subProtocol != null) {
+                    builder.preferredSubprotocols(List.of(subProtocol));
+                }
+                WebSocketContainer container = 
ContainerProvider.getWebSocketContainer();
+                URI uri = new URI("ws://localhost:" + 
serverSocket.getLocalPort() + "/test");
+                try {
+                    // The server drops the connection once it has sent the 
response
+                    Session session = container.connectToServer(new Endpoint() 
{
+                        @Override
+                        public void onOpen(Session session, EndpointConfig 
config) {
+                            // NO-OP
+                        }
+                    }, builder.build(), uri);
+                    session.close();
+                    return true;
+                } catch (DeploymentException e) {
+                    return false;
+                }
+            } finally {
+                server.join(2 * TIMEOUT_MS);
+                Assert.assertFalse("The server thread did not terminate", 
server.isAlive());
+                Assert.assertNull("The server failed", serverFailure.get());
+            }
+        }
+    }
+
+
+    private static void respond(ServerSocket serverSocket, String 
responseHeaders) throws Exception {
+        try (Socket socket = serverSocket.accept()) {
+            socket.setSoTimeout(TIMEOUT_MS);
+            InputStream is = socket.getInputStream();
+            ByteArrayOutputStream request = new ByteArrayOutputStream();
+            byte[] buffer = new byte[1024];
+            while 
(!request.toString(StandardCharsets.ISO_8859_1).contains("\r\n\r\n")) {
+                int read = is.read(buffer);
+                if (read == -1) {
+                    throw new IOException("The client closed the connection 
before it sent the request");
+                }
+                request.write(buffer, 0, read);
+            }
+
+            Matcher matcher = 
KEY_PATTERN.matcher(request.toString(StandardCharsets.ISO_8859_1));
+            Assert.assertTrue(matcher.find());
+            byte[] digest = MessageDigest.getInstance("SHA-1").digest(
+                    (matcher.group(1).trim() + 
WS_ACCEPT_GUID).getBytes(StandardCharsets.ISO_8859_1));
+            String accept = "Sec-WebSocket-Accept: " + 
Base64.getEncoder().encodeToString(digest) + "\r\n";
+
+            String response = "HTTP/1.1 101 Switching Protocols\r\n" + 
responseHeaders.replace("%ACCEPT%", accept) +
+                    "\r\n";
+            
socket.getOutputStream().write(response.getBytes(StandardCharsets.ISO_8859_1));
+            socket.getOutputStream().flush();
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to