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]