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

Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 56aa5f4970a6 CAMEL-25917: camel-undertow - a WebSocket connection 
accepted while only a producer served the path is not treated as having passed 
a later consumer's custom handlers (#28237)
56aa5f4970a6 is described below

commit 56aa5f4970a6fb7126275fc02b5bb9694dd184d1
Author: Federico Mariani <[email protected]>
AuthorDate: Thu Oct 22 16:09:28 2026 +0200

    CAMEL-25917: camel-undertow - a WebSocket connection accepted while only a 
producer served the path is not treated as having passed a later consumer's 
custom handlers (#28237)
    
    * CAMEL-25917: camel-undertow - a WebSocket connection accepted while only 
a producer served the path is not treated as having passed a later consumer's 
custom handlers
    
    * CAMEL-25917: close WebSocket peers that no longer pass the path checks 
when the consumer starts
    
    Close the already-open connections of a path right away when a consumer is
    set, rather than only when they next send a message, so a receive-only 
client
    is told to reconnect instead of silently receiving nothing after the 
consumer
    with custom handlers starts. Add a regression test that awaits the close
    without the client sending, and adjust the existing window tests for the now
    proactive close.
    
    * CAMEL-25917: camel-undertow - make the window tests tolerate the 
proactive close
    
    The consumer now closes a connection opened before it started as soon as it
    starts, so sending on that connection afterwards is best effort and bounded.
---
 .../undertow/handlers/CamelWebSocketHandler.java   |  38 ++++-
 ...roviderWebSocketConsumerHandlersWindowTest.java | 170 +++++++++++++++++++++
 .../UndertowWsOAuthProfileConsumerWindowTest.java  |   4 +-
 .../ws/UndertowWsSecurityWithoutProviderTest.java  |  12 +-
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  13 ++
 5 files changed, 228 insertions(+), 9 deletions(-)

diff --git 
a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/CamelWebSocketHandler.java
 
b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/CamelWebSocketHandler.java
index 9d855f8e0742..b16eb2a75314 100644
--- 
a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/CamelWebSocketHandler.java
+++ 
b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/handlers/CamelWebSocketHandler.java
@@ -245,7 +245,7 @@ public class CamelWebSocketHandler implements HttpHandler {
         }
         if (!authenticatedEndpoints.isEmpty()) {
             exchange.putAttachment(HANDSHAKE_RESULT_ATTACHMENT,
-                    new HandshakeResult(authenticatedEndpoints, headers));
+                    new HandshakeResult(authenticatedEndpoints, headers, 
path.consumer));
         }
         this.delegate.handleRequest(exchange);
     }
@@ -303,9 +303,14 @@ public class CamelWebSocketHandler implements HttpHandler {
             return false;
         }
         if (requiresHandshakeResult(endpoint, consumer)) {
-            return channel != null
-                    && channel.getAttribute(HANDSHAKE_RESULT) instanceof 
HandshakeResult result
-                    && result.endpointUris.contains(endpoint.getEndpointUri());
+            if (channel == null
+                    || !(channel.getAttribute(HANDSHAKE_RESULT) instanceof 
HandshakeResult result)
+                    || 
!result.endpointUris.contains(endpoint.getEndpointUri())) {
+                return false;
+            }
+            // the custom handlers run only during a consumer-mode handshake, 
so a result recorded while only
+            // producers served the path does not account for them
+            return !consumer || endpoint.getHandlers() == null || 
result.consumer;
         }
         return true;
     }
@@ -403,12 +408,30 @@ public class CamelWebSocketHandler implements HttpHandler 
{
                         = new ConsumerHandlers(
                                 consumer.getEndpoint(), consumerHandler != 
null ? consumerHandler : upgradeHandler);
                 producerEndpoints.forEach(this::warnIfProducerSettingsUnused);
+                closePeersThatNoLongerPassChecks();
             }
         } finally {
             consumerLock.unlock();
         }
     }
 
+    /**
+     * Closes the already-open peers whose handshake did not pass the security 
checks that now apply to the path. A
+     * connection accepted while only a producer served the path ran the 
producer checks but not the custom handlers a
+     * consumer configures, so once that consumer is set such a peer no longer 
passes the path checks. It is closed
+     * right away rather than only when it next sends a message, so that a 
receive-only client is told to reconnect
+     * instead of silently receiving nothing. {@link #send} filters the same 
peers out of the meantime.
+     */
+    private void closePeersThatNoLongerPassChecks() {
+        PathSecurity path = pathSecurity();
+        delegate.getPeerConnections().stream()
+                .filter(peer -> !isAuthenticated(peer, path))
+                .forEach(peer -> {
+                    LOG.warn("Closing WebSocket channel whose handshake did 
not pass the security checks of the consumer");
+                    WebSockets.sendClose(CloseMessage.MSG_VIOLATES_POLICY, 
"Authentication required", peer, null);
+                });
+    }
+
     /**
      * Registers the endpoint of a producer that sends to the peers of this 
handler. A path without a consumer applies
      * the security settings of its producers.
@@ -663,15 +686,18 @@ public class CamelWebSocketHandler implements HttpHandler 
{
 
     /**
      * The endpoints whose security provider, allowed roles and custom 
handlers a handshake passed, and the headers that
-     * the providers added.
+     * the providers added. {@code consumer} records whether the handshake ran 
in consumer mode: only then were the
+     * consumer-only custom handlers part of the checks.
      */
     private static final class HandshakeResult {
         private final Set<String> endpointUris;
         private final Map<String, Object> headers;
+        private final boolean consumer;
 
-        private HandshakeResult(Set<String> endpointUris, Map<String, Object> 
headers) {
+        private HandshakeResult(Set<String> endpointUris, Map<String, Object> 
headers, boolean consumer) {
             this.endpointUris = Collections.unmodifiableSet(endpointUris);
             this.headers = Collections.unmodifiableMap(headers);
+            this.consumer = consumer;
         }
     }
 
diff --git 
a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketConsumerHandlersWindowTest.java
 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketConsumerHandlersWindowTest.java
new file mode 100644
index 000000000000..232c0c51c5f1
--- /dev/null
+++ 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketConsumerHandlersWindowTest.java
@@ -0,0 +1,170 @@
+/*
+ * 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.camel.component.undertow.spi;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import io.undertow.websockets.core.CloseMessage;
+import org.apache.camel.CamelContext;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.component.undertow.UndertowBasicAuthHandler;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A WebSocket path whose producer applies the allowed roles while its 
consumer, started later, adds its own handlers on
+ * the same endpoint URI: a connection accepted while only the producer served 
the path runs the allowed-roles check but
+ * not the consumer handlers, so once that consumer is up the connection is 
neither served by the route nor sent
+ * messages, while a connection that goes through the consumer handlers is 
served.
+ */
+class SecurityProviderWebSocketConsumerHandlersWindowTest extends 
AbstractSecurityProviderTest {
+
+    private final AtomicInteger routeInvocations = new AtomicInteger();
+
+    @Override
+    protected CamelContext createCamelContext() throws Exception {
+        CamelContext context = super.createCamelContext();
+        context.getRegistry().bind("wsHandlers", new 
UndertowBasicAuthHandler());
+        return context;
+    }
+
+    @Override
+    protected RouteBuilder createRouteBuilder() {
+        return new RouteBuilder() {
+            @Override
+            public void configure() {
+                // producer and consumer share the same endpoint URI: the 
producer keeps the shared handler
+                // registered and applies allowedRoles, the consumer adds the 
handlers on top
+                from("direct:feed")
+                        
.to("undertow:ws://localhost:{{port}}/guarded?allowedRoles=user&handlers=#wsHandlers&sendToAll=true");
+                
from("undertow:ws://localhost:{{port}}/guarded?allowedRoles=user&handlers=#wsHandlers&sendToAll=true")
+                        .routeId("guarded").autoStartup(false)
+                        .process(exchange -> 
routeInvocations.incrementAndGet())
+                        .to("mock:guarded");
+            }
+        };
+    }
+
+    @Test
+    void connectionAcceptedBeforeAConsumerWithHandlersStartedIsNotServed() 
throws Exception {
+        securityConfiguration.setRoleToAssign("user");
+
+        // only the producer serves the path, so the handshake runs the 
allowed-roles check but not the consumer handlers
+        RecordingListener listener = new RecordingListener();
+        WebSocket webSocket = connect("/guarded", null, listener);
+
+        context.getRouteController().startRoute("guarded");
+
+        // the connection does not get what the producer sends
+        template.sendBody("direct:feed", "broadcast");
+        // a message from the stale connection never reaches the route; the 
send may already fail because the
+        // connection was closed when the consumer started, so it is best 
effort here
+        webSocket.sendText("hello", true).whenComplete((result, error) -> {
+        });
+
+        assertEquals(CloseMessage.MSG_VIOLATES_POLICY, 
listener.closeCode.orTimeout(10, TimeUnit.SECONDS).join());
+        assertTrue(listener.received.isEmpty());
+        assertEquals(0, routeInvocations.get());
+    }
+
+    @Test
+    void receiveOnlyConnectionBeforeConsumerStartsIsClosedWhenConsumerStarts() 
throws Exception {
+        securityConfiguration.setRoleToAssign("user");
+
+        // only the producer serves the path: the connection passes the 
allowed-roles check but not the consumer handlers
+        RecordingListener listener = new RecordingListener();
+        connect("/guarded", null, listener);
+
+        // a first broadcast confirms the peer is registered server-side while 
only the producer serves the path
+        template.sendBody("direct:feed", "before");
+        assertEquals("before", listener.firstText.orTimeout(10, 
TimeUnit.SECONDS).join());
+
+        context.getRouteController().startRoute("guarded");
+
+        // the client sends nothing: a receive-only connection must still be 
closed when the consumer starts, so that
+        // reconnect-on-close logic runs instead of the client silently 
receiving no further broadcasts
+        assertEquals(CloseMessage.MSG_VIOLATES_POLICY, 
listener.closeCode.orTimeout(10, TimeUnit.SECONDS).join());
+
+        // a broadcast after the close does not reach the stale connection
+        template.sendBody("direct:feed", "after");
+        assertEquals(List.of("before"), listener.received);
+        assertEquals(0, routeInvocations.get());
+    }
+
+    @Test
+    void connectionThatRunsTheConsumerHandlersIsServed() throws Exception {
+        securityConfiguration.setRoleToAssign("user");
+        context.getRouteController().startRoute("guarded");
+
+        getMockEndpoint("mock:guarded").expectedBodiesReceived("hello");
+        String credentials = 
Base64.getEncoder().encodeToString("guest:secret".getBytes(StandardCharsets.UTF_8));
+        WebSocket webSocket = connect("/guarded", "Basic " + credentials, new 
RecordingListener());
+        webSocket.sendText("hello", true).join();
+
+        MockEndpoint.assertIsSatisfied(context);
+        assertEquals(1, routeInvocations.get());
+        webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
+    }
+
+    private WebSocket connect(String path, String authorization, 
WebSocket.Listener listener) {
+        WebSocket.Builder builder = 
HttpClient.newHttpClient().newWebSocketBuilder();
+        if (authorization != null) {
+            builder.header("Authorization", authorization);
+        }
+        return builder.buildAsync(URI.create("ws://localhost:" + getPort() + 
path), listener)
+                .orTimeout(5, TimeUnit.SECONDS).join();
+    }
+
+    private static final class RecordingListener implements WebSocket.Listener 
{
+
+        private final List<String> received = new CopyOnWriteArrayList<>();
+        private final CompletableFuture<String> firstText = new 
CompletableFuture<>();
+        private final CompletableFuture<Integer> closeCode = new 
CompletableFuture<>();
+
+        @Override
+        public void onOpen(WebSocket webSocket) {
+            webSocket.request(1);
+        }
+
+        @Override
+        public CompletionStage<?> onText(WebSocket webSocket, CharSequence 
data, boolean last) {
+            received.add(data.toString());
+            firstText.complete(data.toString());
+            webSocket.request(1);
+            return null;
+        }
+
+        @Override
+        public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, 
String reason) {
+            closeCode.complete(statusCode);
+            return null;
+        }
+    }
+}
diff --git 
a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsOAuthProfileConsumerWindowTest.java
 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsOAuthProfileConsumerWindowTest.java
index 06a19be0e1bc..d71fa3a886ca 100644
--- 
a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsOAuthProfileConsumerWindowTest.java
+++ 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsOAuthProfileConsumerWindowTest.java
@@ -69,7 +69,9 @@ public class UndertowWsOAuthProfileConsumerWindowTest extends 
BaseUndertowTest {
         context.getRouteController().startRoute("secureWs");
         template.sendBody("direct:keepalive", "broadcast");
 
-        webSocket.sendText("hello", true).orTimeout(5, 
TimeUnit.SECONDS).join();
+        // the channel is closed when the secured consumer starts, so a send 
from it may already fail: best effort
+        webSocket.sendText("hello", true).whenComplete((result, error) -> {
+        });
 
         assertEquals(CloseMessage.MSG_VIOLATES_POLICY, 
listener.closeCode.orTimeout(10, TimeUnit.SECONDS).join());
         assertTrue(listener.received.isEmpty());
diff --git 
a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsSecurityWithoutProviderTest.java
 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsSecurityWithoutProviderTest.java
index 09f301add9ea..ce15e4e87b60 100644
--- 
a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsSecurityWithoutProviderTest.java
+++ 
b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsSecurityWithoutProviderTest.java
@@ -128,7 +128,11 @@ class UndertowWsSecurityWithoutProviderTest extends 
BaseUndertowTest {
         // the connection does not get what the producer sends
         template.sendBody("direct:late", "broadcast");
         // and its messages do not reach the route: it is closed instead
-        webSocket.sendText("hello", true).join();
+        // The consumer may already have closed this stale connection; sending 
is best effort.
+        webSocket.sendText("hello", true)
+                .orTimeout(5, TimeUnit.SECONDS)
+                .handle((result, error) -> null)
+                .join();
 
         assertEquals(CloseMessage.MSG_VIOLATES_POLICY, 
listener.closeCode.orTimeout(10, TimeUnit.SECONDS).join());
         assertTrue(listener.received.isEmpty());
@@ -147,7 +151,11 @@ class UndertowWsSecurityWithoutProviderTest extends 
BaseUndertowTest {
         // the connection does not get what the producer sends
         template.sendBody("direct:lateBasic", "broadcast");
         // and its messages do not reach the route: it is closed instead
-        webSocket.sendText("hello", true).join();
+        // The consumer may already have closed this stale connection; sending 
is best effort.
+        webSocket.sendText("hello", true)
+                .orTimeout(5, TimeUnit.SECONDS)
+                .handle((result, error) -> null)
+                .join();
 
         assertEquals(CloseMessage.MSG_VIOLATES_POLICY, 
listener.closeCode.orTimeout(10, TimeUnit.SECONDS).join());
         assertTrue(listener.received.isEmpty());
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 46379cf15944..29ad36b1652f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1515,6 +1515,19 @@ framing, are still allowed. Routes that put a 
percent-encoded control character
 `CamelHttpPath` or `CamelHttpQuery` header, which the producer decodes before 
building the request, are the ones most
 likely to be affected.
 
+=== camel-undertow - a WebSocket connection opened before a consumer with 
custom handlers started is no longer served
+
+On a WebSocket path whose producer applies a security provider or allowed 
roles, a connection could be accepted while
+only the producer served the path, before its consumer was started for the 
first time. Such a connection ran the
+producer checks but not the custom `handlers` the consumer configures, as 
those handlers run only for consumers. Once
+the consumer was started, the connection was still treated as having passed 
the consumer checks, so its messages
+reached the route and it kept receiving what the producer sent.
+
+Once a consumer that configures `handlers` is started on a path, a connection 
opened on that path before the consumer
+first ran is no longer served: its messages no longer reach the route and it 
no longer receives what the producer
+sends, and the connection is closed. Clients that open a connection in this 
window must reconnect after the consumer
+has started.
+
 === camel-dapr - the saveBulk and executeTransaction state operations are sent
 
 The `saveBulk` and `executeTransaction` operations of `dapr:state` built their 
request but never sent it to Dapr, so

Reply via email to