This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch backport/CAMEL-25375-camel-4.18.x in repository https://gitbox.apache.org/repos/asf/camel.git
commit 3218b4ff4186af4c46cdbe496aa1cd795edbf497 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Oct 7 13:38:04 2026 +0200 [backport camel-4.18.x] CAMEL-25375: camel-undertow - Apply securityProvider, allowedRoles and handlers to WebSocket endpoints WebSocket (ws/wss) endpoints of camel-undertow now apply securityProvider, allowedRoles, handlers and accessLog to the upgrade request, as HTTP endpoints do. A connection that did not pass these checks is not served. Port of #27435 (3a75bfbf1b20) and #27443 (13a78504f2c5) to camel-4.18.x. camel-4.18.x does not have the oauthProfile option of the WebSocket consumer (CAMEL-23723), on which the main commits build, so the port leaves out the OAuth parts: - CamelWebSocketHandler: no OAuth validation in the upgrade or on connect; the security checks are the security provider, allowed roles and handlers. - UndertowConsumer: adds the fail-closed check of the WebSocket channel to sendMessage and sendEventNotification, which on main came with CAMEL-23723, and has no OAuth handler. - UndertowWsSecurityWithoutProviderTest: without the oauthProfile case; UndertowWsOAuthProfileConsumerWindowTest is not ported. - docs: the "WebSocket endpoints" section of undertow-component.adoc and the note in the 4.18 upgrade guide (4.18.5 section) are the same text as on main. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../security/SpringSecurityWebSocketTest.java | 90 +++++++ .../src/main/docs/undertow-component.adoc | 20 +- .../component/undertow/DefaultUndertowHost.java | 104 +++++--- .../component/undertow/UndertowComponent.java | 24 +- .../camel/component/undertow/UndertowConsumer.java | 100 +++++--- .../camel/component/undertow/UndertowEndpoint.java | 40 +++ .../camel/component/undertow/UndertowHost.java | 19 ++ .../camel/component/undertow/UndertowProducer.java | 6 +- .../undertow/handlers/CamelWebSocketHandler.java | 276 ++++++++++++++++++++- .../CamelWebSocketHandlerSecuritySettingsTest.java | 87 +++++++ .../ProviderWithServletWebSocketEndpointTest.java | 91 +++++++ .../spi/ProviderWithServletWebSocketTest.java | 66 +++++ ...ityProviderRolesFromComponentWebSocketTest.java | 78 ++++++ .../spi/SecurityProviderWebSocketTest.java | 160 ++++++++++++ .../spi/SecurityProviderWebSocketWrapTest.java | 89 +++++++ .../ws/UndertowWsSecurityWithoutProviderTest.java | 206 +++++++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_18.adoc | 17 ++ 17 files changed, 1393 insertions(+), 80 deletions(-) diff --git a/components/camel-spring-parent/camel-undertow-spring-security/src/test/java/org/apache/camel/component/spring/security/SpringSecurityWebSocketTest.java b/components/camel-spring-parent/camel-undertow-spring-security/src/test/java/org/apache/camel/component/spring/security/SpringSecurityWebSocketTest.java new file mode 100644 index 000000000000..d05dedb8d0b0 --- /dev/null +++ b/components/camel-spring-parent/camel-undertow-spring-security/src/test/java/org/apache/camel/component/spring/security/SpringSecurityWebSocketTest.java @@ -0,0 +1,90 @@ +/* + * 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.spring.security; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.net.http.WebSocketHandshakeException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +import io.undertow.util.StatusCodes; +import org.apache.camel.builder.RouteBuilder; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The Spring Security provider applies to the upgrade request of WebSocket endpoints. + */ +class SpringSecurityWebSocketTest extends AbstractSpringSecurityBearerTokenTest { + + @Test + void allowedRoleConnects() throws Exception { + getMockFilter().setJwt(createToken("Alice", "user")); + + CompletableFuture<String> reply = new CompletableFuture<>(); + WebSocket webSocket = connect(new WebSocket.Listener() { + @Override + public void onOpen(WebSocket webSocket) { + webSocket.request(1); + } + + @Override + public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) { + reply.complete(data.toString()); + return null; + } + }); + webSocket.sendText("hi", true).join(); + + assertEquals("Hello Alice!", reply.get(10, TimeUnit.SECONDS)); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join(); + } + + @Test + void otherRoleIsRefused() { + getMockFilter().setJwt(createToken("Tom", "wrongUser")); + + CompletionException thrown = assertThrows(CompletionException.class, () -> connect(new WebSocket.Listener() { + })); + WebSocketHandshakeException handshake = assertInstanceOf(WebSocketHandshakeException.class, thrown.getCause()); + assertEquals(StatusCodes.FORBIDDEN, handshake.getResponse().statusCode()); + } + + private WebSocket connect(WebSocket.Listener listener) { + return HttpClient.newHttpClient().newWebSocketBuilder() + .buildAsync(URI.create("ws://localhost:" + getPort() + "/myws"), listener) + .orTimeout(5, TimeUnit.SECONDS).join(); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("undertow:ws://localhost:{{port}}/myws?allowedRoles=user") + .transform(simple("Hello ${in.header." + SpringSecurityProvider.PRINCIPAL_NAME_HEADER + "}!")) + .to("undertow:ws://localhost:{{port}}/myws"); + } + }; + } +} diff --git a/components/camel-undertow/src/main/docs/undertow-component.adoc b/components/camel-undertow/src/main/docs/undertow-component.adoc index 7c9daea911e7..a2799078795f 100644 --- a/components/camel-undertow/src/main/docs/undertow-component.adoc +++ b/components/camel-undertow/src/main/docs/undertow-component.adoc @@ -134,10 +134,26 @@ If there is an object passed to the component as parameter `securityConfiguratio Provider will be used for authentication of all requests. -Property `requireServletContext` of security providers forces the Undertow server to start -with servlet context. There will be no servlet actually handled. This feature is meant only +Property `requireServletContext` of security providers forces the Undertow server to run +with servlet context, as soon as an endpoint that uses such a provider, configured on the endpoint +or on the component, is started. There will be no servlet actually handled. This feature is meant only for use with servlet filters, which needs servlet context for their functionality. +==== WebSocket endpoints + +On WebSocket endpoints (`ws://` and `wss://`), the security provider and the `allowedRoles` option apply to the +upgrade request that opens a connection. A request that the provider rejects, or a request to an endpoint that has +`allowedRoles` but no security provider, is answered with the same status code as on an HTTP endpoint, and no +connection is opened. The headers that the provider adds are set on every exchange of the connection. + +All the endpoints of a WebSocket path share its connections, including the producers that send to its peers. The +security settings of the consumer apply to the whole path, and keep applying while the consumer is stopped. On a path +that only has producers, the settings of the producers apply. A connection that was opened before the current +settings applied, for example while the path only had producers without security settings, does not receive the +messages of the producers, and it is closed when it sends a message. + +The `handlers` and `accessLog` options apply to WebSocket consumers as well, before the upgrade. + == Examples === HTTP Producer Example diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHost.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHost.java index 5d629933397c..11b2b45c28cc 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHost.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/DefaultUndertowHost.java @@ -49,6 +49,10 @@ public class DefaultUndertowHost implements UndertowHost { private Undertow undertow; private String hostString; private DeploymentManager deploymentManager; + // the rest or the root handler, depending on the first endpoint registered on the server + private HttpHandler serverHandler; + // the server handler, or the servlet deployment around it once an endpoint needs a servlet context + private volatile HttpHandler entryHandler; public DefaultUndertowHost(UndertowHostKey key) { this(key, null); @@ -70,6 +74,13 @@ public class DefaultUndertowHost implements UndertowHost { @Override public HttpHandler registerHandler( UndertowConsumer consumer, HttpHandlerRegistrationInfo registrationInfo, HttpHandler handler) { + return registerHandler(consumer != null ? consumer.getEndpoint() : null, consumer, registrationInfo, handler); + } + + @Override + public HttpHandler registerHandler( + UndertowEndpoint endpoint, UndertowConsumer consumer, HttpHandlerRegistrationInfo registrationInfo, + HttpHandler handler) { lock.lock(); try { if (undertow == null) { @@ -98,12 +109,14 @@ public class DefaultUndertowHost implements UndertowHost { } } - if (consumer != null && consumer.isRest()) { - // use the rest handler as its a rest consumer - undertow = registerHandler(consumer, builder, restHandler); - } else { - undertow = registerHandler(consumer, builder, rootHandler); + // use the rest handler as its a rest consumer + serverHandler = consumer != null && consumer.isRest() ? restHandler : rootHandler; + entryHandler = serverHandler; + if (requiresServletContext(endpoint)) { + // deploy before the server starts, so that a failure leaves no server running + deployServletContext(); } + undertow = builder.setHandler(exchange -> entryHandler.handleRequest(exchange)).build(); LOG.info("Starting Undertow server on {}://{}:{}", key.getSslContext() != null ? "https" : "http", key.getHost(), key.getPort()); @@ -123,10 +136,18 @@ public class DefaultUndertowHost implements UndertowHost { // initialization again. undertow.stop(); undertow = null; + if (deploymentManager != null) { + deploymentManager.undeploy(); + deploymentManager = null; + } throw e; } } + if (deploymentManager == null && requiresServletContext(endpoint)) { + // a later endpoint needs a servlet context: wrap the handler of the running server + deployServletContext(); + } if (consumer != null && consumer.isRest()) { restHandler.addConsumer(consumer); return restHandler; @@ -139,36 +160,44 @@ public class DefaultUndertowHost implements UndertowHost { } } - private Undertow registerHandler(UndertowConsumer consumer, Undertow.Builder builder, HttpHandler handler) { - UndertowSecurityProvider securityProvider = consumer == null - ? null - : consumer.getEndpoint().getComponent().getSecurityProvider() != null - ? consumer.getEndpoint().getComponent().getSecurityProvider() - : consumer.getEndpoint().getSecurityProvider(); - //if security provider needs servlet context, start empty servlet - if (securityProvider != null && securityProvider.requireServletContext()) { - DeploymentInfo deployment = Servlets.deployment() - .setContextPath("") - .setDisplayName("application") - .setDeploymentName("camel-undertow") - .setClassLoader(getClass().getClassLoader()) - //httpHandler for servlet is ignored, camel handler is used instead of it - .addOuterHandlerChainWrapper(h -> handler); - - deploymentManager = Servlets.newContainer().addDeployment(deployment); - deploymentManager.deploy(); - try { - return builder.setHandler(deploymentManager.start()).build(); - } catch (ServletException e) { - LOG.warn("Failed to start Undertow server on {}://{}:{}, reason: {}", - key.getSslContext() != null ? "https" : "http", key.getHost(), key.getPort(), e.getMessage()); - - throw new RuntimeException(e); - } - + /** + * Whether the security provider of the endpoint, or of its component, needs a servlet context, for example to run + * servlet filters. A producer endpoint can register first on a server, such as a WebSocket producer. + */ + private static boolean requiresServletContext(UndertowEndpoint endpoint) { + if (endpoint == null) { + return false; } + UndertowSecurityProvider endpointProvider = endpoint.getSecurityProvider(); + UndertowSecurityProvider componentProvider = endpoint.getComponent().getSecurityProvider(); + return endpointProvider != null && endpointProvider.requireServletContext() + || componentProvider != null && componentProvider.requireServletContext(); + } - return builder.setHandler(handler).build(); + /** + * Starts an empty servlet deployment around the handler of the server, so that every request has a servlet context. + */ + private void deployServletContext() { + HttpHandler handler = serverHandler; + DeploymentInfo deployment = Servlets.deployment() + .setContextPath("") + .setDisplayName("application") + .setDeploymentName("camel-undertow") + .setClassLoader(getClass().getClassLoader()) + //httpHandler for servlet is ignored, camel handler is used instead of it + .addOuterHandlerChainWrapper(h -> handler); + + DeploymentManager manager = Servlets.newContainer().addDeployment(deployment); + manager.deploy(); + try { + entryHandler = manager.start(); + } catch (ServletException e) { + LOG.warn("Failed to start the servlet context of the Undertow server on {}://{}:{}, reason: {}", + key.getSslContext() != null ? "https" : "http", key.getHost(), key.getPort(), e.getMessage()); + manager.undeploy(); + throw new RuntimeException(e); + } + deploymentManager = manager; } @Override @@ -188,11 +217,12 @@ public class DefaultUndertowHost implements UndertowHost { registrationInfo.isMatchOnUriPrefix()); stop = rootHandler.isEmpty(); } - if (deploymentManager != null) { - deploymentManager.undeploy(); - } - if (stop) { + // the servlet deployment serves every endpoint of the server, so it can only go with the server + if (deploymentManager != null) { + deploymentManager.undeploy(); + deploymentManager = null; + } LOG.info("Stopping Undertow server on {}://{}:{}", key.getSslContext() != null ? "https" : "http", key.getHost(), key.getPort()); diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowComponent.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowComponent.java index 67bf2ca62113..96c4d325f705 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowComponent.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowComponent.java @@ -37,6 +37,7 @@ import org.apache.camel.Endpoint; import org.apache.camel.Processor; import org.apache.camel.Producer; import org.apache.camel.SSLContextParametersAware; +import org.apache.camel.component.undertow.handlers.CamelWebSocketHandler; import org.apache.camel.component.undertow.spi.UndertowSecurityProvider; import org.apache.camel.spi.Metadata; import org.apache.camel.spi.RestApiConsumerFactory; @@ -364,6 +365,18 @@ public class UndertowComponent extends DefaultComponent public HttpHandler registerEndpoint( UndertowConsumer consumer, HttpHandlerRegistrationInfo registrationInfo, SSLContext sslContext, HttpHandler handler) throws Exception { + return registerEndpoint(consumer != null ? consumer.getEndpoint() : null, consumer, registrationInfo, sslContext, + handler); + } + + /** + * Registers a handler on behalf of the given endpoint: the endpoint of the consumer, or a producer endpoint that + * registers a handler, such as a WebSocket producer, when {@code consumer} is {@code null}. + */ + public HttpHandler registerEndpoint( + UndertowEndpoint endpoint, UndertowConsumer consumer, HttpHandlerRegistrationInfo registrationInfo, + SSLContext sslContext, HttpHandler handler) + throws Exception { final URI uri = registrationInfo.getUri(); final UndertowHostKey key = new UndertowHostKey(uri.getHost(), uri.getPort(), sslContext); final UndertowHost host = undertowRegistry.computeIfAbsent(key, this::createUndertowHost); @@ -372,11 +385,18 @@ public class UndertowComponent extends DefaultComponent handlers.add(registrationInfo); HttpHandler handlerWrapped = handler; - if (this.securityProvider != null) { + if (handler instanceof CamelWebSocketHandler webSocketHandler) { + // the WebSocket handler of a path is shared by its consumer and producers, so it must stay registered as is. + // It is wrapped before the registration, so that it cannot receive a request unwrapped; when the path + // already has a handler, the registration keeps that one and this instance is not used + if (this.securityProvider != null) { + webSocketHandler.wrapWith(this.securityProvider); + } + } else if (this.securityProvider != null) { handlerWrapped = this.securityProvider.wrapHttpHandler(handler); } - return host.registerHandler(consumer, registrationInfo, handlerWrapped); + return host.registerHandler(endpoint, consumer, registrationInfo, handlerWrapped); } public void unregisterEndpoint( diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowConsumer.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowConsumer.java index 8140a0099537..80ee328854d7 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowConsumer.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowConsumer.java @@ -21,7 +21,6 @@ import java.io.InputStream; import java.io.OutputStream; import java.net.URI; import java.nio.ByteBuffer; -import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.StringJoiner; @@ -38,7 +37,9 @@ import io.undertow.util.HttpString; import io.undertow.util.Methods; import io.undertow.util.MimeMappings; import io.undertow.util.StatusCodes; +import io.undertow.websockets.core.CloseMessage; import io.undertow.websockets.core.WebSocketChannel; +import io.undertow.websockets.core.WebSockets; import io.undertow.websockets.spi.WebSocketHttpExchange; import org.apache.camel.AsyncCallback; import org.apache.camel.Exchange; @@ -92,11 +93,7 @@ public class UndertowConsumer extends DefaultConsumer implements HttpHandler, Su } public List<String> computeAllowedRoles() { - String allowedRolesString = getEndpoint().getAllowedRoles(); - if (allowedRolesString == null) { - allowedRolesString = getEndpoint().getComponent().getAllowedRoles(); - } - return allowedRolesString == null ? null : Arrays.asList(allowedRolesString.split("\\s*,\\s*")); + return getEndpoint().computeAllowedRoles(); } @Override @@ -111,26 +108,13 @@ public class UndertowConsumer extends DefaultConsumer implements HttpHandler, Su */ this.webSocketHandler = (CamelWebSocketHandler) endpoint.getComponent().registerEndpoint(this, endpoint.getHttpHandlerRegistrationInfo(), endpoint.getSslContext(), new CamelWebSocketHandler()); - this.webSocketHandler.setConsumer(this); + // the access log and the custom handlers run before the upgrade, as they run before an HTTP request + this.webSocketHandler.setConsumer(this, + wrapWithAccessLogAndHandlers(this.webSocketHandler.getUpgradeHandler(), endpoint)); } else { // allow for HTTP 1.1 continue HttpHandler httpHandler = new EagerFormParsingHandler().setNext(UndertowConsumer.this); - if (endpoint.getAccessLog()) { - AccessLogReceiver accessLogReceiver; - if (endpoint.getAccessLogReceiver() != null) { - accessLogReceiver = endpoint.getAccessLogReceiver(); - } else { - accessLogReceiver = new JBossLoggingAccessLogReceiver(); - } - httpHandler = new AccessLogHandler( - httpHandler, - accessLogReceiver, - "common", - AccessLogHandler.class.getClassLoader()); - } - if (endpoint.getHandlers() != null) { - httpHandler = this.wrapHandler(httpHandler, endpoint); - } + httpHandler = wrapWithAccessLogAndHandlers(httpHandler, endpoint); endpoint.getComponent().registerEndpoint(this, endpoint.getHttpHandlerRegistrationInfo(), endpoint.getSslContext(), Handlers.httpContinueRead( // wrap with EagerFormParsingHandler to enable undertow form parsers @@ -138,6 +122,27 @@ public class UndertowConsumer extends DefaultConsumer implements HttpHandler, Su } } + private HttpHandler wrapWithAccessLogAndHandlers(HttpHandler handler, UndertowEndpoint endpoint) { + HttpHandler httpHandler = handler; + if (endpoint.getAccessLog()) { + AccessLogReceiver accessLogReceiver; + if (endpoint.getAccessLogReceiver() != null) { + accessLogReceiver = endpoint.getAccessLogReceiver(); + } else { + accessLogReceiver = new JBossLoggingAccessLogReceiver(); + } + httpHandler = new AccessLogHandler( + httpHandler, + accessLogReceiver, + "common", + AccessLogHandler.class.getClassLoader()); + } + if (endpoint.getHandlers() != null) { + httpHandler = this.wrapHandler(httpHandler, endpoint); + } + return httpHandler; + } + @Override protected void doStop() throws Exception { this.suspended = false; @@ -193,19 +198,9 @@ public class UndertowConsumer extends DefaultConsumer implements HttpHandler, Su return; } - if (getEndpoint().getSecurityProvider() != null) { - //security provider decides, whether endpoint is accessible - int statusCode = getEndpoint().getSecurityProvider().authenticate(httpExchange, computeAllowedRoles()); - if (statusCode != StatusCodes.OK) { - httpExchange.setStatusCode(statusCode); - httpExchange.endExchange(); - return; - } - } else if (computeAllowedRoles() != null && !computeAllowedRoles().isEmpty()) { - //this case could happen due to bad configuration - //if allowedRoles are present but securityProvider is not, access has to be denied in this case - LOG.warn("Illegal state caused by missing securitProvider but existing allowed roles!"); - httpExchange.setStatusCode(StatusCodes.FORBIDDEN); + int statusCode = getEndpoint().authenticate(httpExchange); + if (statusCode != StatusCodes.OK) { + httpExchange.setStatusCode(statusCode); httpExchange.endExchange(); return; } @@ -292,10 +287,14 @@ public class UndertowConsumer extends DefaultConsumer implements HttpHandler, Su * @param message the message received via the {@link WebSocketChannel} */ public void sendMessage(final String connectionKey, WebSocketChannel channel, final Object message) { + if (rejectUnauthenticatedWebSocketChannel(connectionKey, channel)) { + return; + } final Exchange exchange = createExchange(true); // set header and body + setSecurityProviderHeaders(exchange.getIn(), channel); exchange.getIn().setHeader(UndertowConstants.CONNECTION_KEY, connectionKey); if (channel != null) { exchange.getIn().setHeader(UndertowConstants.CHANNEL, channel); @@ -317,9 +316,13 @@ public class UndertowConsumer extends DefaultConsumer implements HttpHandler, Su */ public void sendEventNotification( String connectionKey, WebSocketHttpExchange transportExchange, WebSocketChannel channel, EventType eventType) { + if (rejectUnauthenticatedWebSocketChannel(connectionKey, channel)) { + return; + } final Exchange exchange = createExchange(true); final Message in = exchange.getIn(); + setSecurityProviderHeaders(in, channel); in.setHeader(UndertowConstants.CONNECTION_KEY, connectionKey); in.setHeader(UndertowConstants.EVENT_TYPE, eventType.getCode()); in.setHeader(UndertowConstants.EVENT_TYPE_ENUM, eventType); @@ -371,4 +374,29 @@ public class UndertowConsumer extends DefaultConsumer implements HttpHandler, Su return exchange; } + /** + * Fails closed for WebSocket channels whose handshake did not pass the security checks of this consumer's endpoint + * (security provider, allowed roles and custom handlers). Such channels can exist when the shared + * {@link CamelWebSocketHandler} accepted a handshake before these checks applied to its path, for example while the + * path was only used by producers. Returns {@code true} when the event must not be delivered to the route; the + * channel is closed if still open. + */ + private boolean rejectUnauthenticatedWebSocketChannel(String connectionKey, WebSocketChannel channel) { + if (CamelWebSocketHandler.isAuthenticated(channel, getEndpoint())) { + return false; + } + LOG.warn("Rejecting WebSocket event from connection {} whose handshake did not pass the security checks", + connectionKey); + if (channel != null && channel.isOpen()) { + WebSockets.sendClose(CloseMessage.MSG_VIOLATES_POLICY, "Authentication required", channel, null); + } + return true; + } + + private static void setSecurityProviderHeaders(Message in, WebSocketChannel channel) { + if (channel != null) { + CamelWebSocketHandler.getSecurityProviderHeaders(channel).forEach(in::setHeader); + } + } + } diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowEndpoint.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowEndpoint.java index 3cd6cc3ff99e..3143e8f22321 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowEndpoint.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowEndpoint.java @@ -17,6 +17,7 @@ package org.apache.camel.component.undertow; import java.net.URI; +import java.util.Arrays; import java.util.Iterator; import java.util.LinkedList; import java.util.List; @@ -26,7 +27,9 @@ import java.util.ServiceLoader; import javax.net.ssl.SSLContext; +import io.undertow.server.HttpServerExchange; import io.undertow.server.handlers.accesslog.AccessLogReceiver; +import io.undertow.util.StatusCodes; import org.apache.camel.AsyncEndpoint; import org.apache.camel.Category; import org.apache.camel.Consumer; @@ -478,6 +481,43 @@ public class UndertowEndpoint extends DefaultEndpoint this.allowedRoles = allowedRoles; } + /** + * The allowed roles of this endpoint, or of the component when the endpoint does not configure any. + */ + public List<String> computeAllowedRoles() { + String allowedRolesString = allowedRoles != null ? allowedRoles : getComponent().getAllowedRoles(); + return allowedRolesString == null ? null : Arrays.asList(allowedRolesString.split("\\s*,\\s*")); + } + + /** + * Whether requests to this endpoint are checked by the {@link UndertowSecurityProvider} or restricted to the + * allowed roles. + */ + public boolean requiresAuthentication() { + List<String> roles = computeAllowedRoles(); + return securityProvider != null || roles != null && !roles.isEmpty(); + } + + /** + * Applies the {@link UndertowSecurityProvider} and the allowed roles of this endpoint to a request. + * + * @return {@link StatusCodes#OK} if the request is allowed, otherwise the status code to reject it with + */ + public int authenticate(HttpServerExchange httpExchange) throws Exception { + List<String> roles = computeAllowedRoles(); + if (securityProvider != null) { + // security provider decides, whether endpoint is accessible + return securityProvider.authenticate(httpExchange, roles); + } + if (roles != null && !roles.isEmpty()) { + // this case could happen due to bad configuration + // if allowedRoles are present but securityProvider is not, access has to be denied in this case + LOG.warn("Illegal state caused by missing securityProvider but existing allowed roles!"); + return StatusCodes.FORBIDDEN; + } + return StatusCodes.OK; + } + @Override protected void doInit() throws Exception { super.doInit(); diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHost.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHost.java index 7559d1750b68..c9fa22883e24 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHost.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowHost.java @@ -46,6 +46,25 @@ public interface UndertowHost { */ HttpHandler registerHandler(UndertowConsumer consumer, HttpHandlerRegistrationInfo registrationInfo, HttpHandler handler); + /** + * Register a handler on behalf of the given endpoint, as + * {@link #registerHandler(UndertowConsumer, HttpHandlerRegistrationInfo, HttpHandler)} does. The endpoint is the + * endpoint of the consumer, or a producer endpoint that registers a handler, such as a WebSocket producer, when + * {@code consumer} is {@code null}. + * + * @param endpoint the endpoint that registers the handler + * @param consumer the consumer that registers the handler, or {@code null} for a producer + * @param registrationInfo the {@link HttpHandlerRegistrationInfo} related to {@code handler} + * @param handler the {@link HttpHandler} to register + * @return the given {@code handler} or a different {@link HttpHandler} that has been registered + * with the given {@link HttpHandlerRegistrationInfo} earlier. + */ + default HttpHandler registerHandler( + UndertowEndpoint endpoint, UndertowConsumer consumer, HttpHandlerRegistrationInfo registrationInfo, + HttpHandler handler) { + return registerHandler(consumer, registrationInfo, handler); + } + /** * Unregister a handler with the given {@link HttpHandlerRegistrationInfo}. Note that if * {@link #registerHandler(UndertowConsumer, HttpHandlerRegistrationInfo, HttpHandler)} was successfully invoked diff --git a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java index 0d053cffb241..06a90683f806 100644 --- a/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java +++ b/components/camel-undertow/src/main/java/org/apache/camel/component/undertow/UndertowProducer.java @@ -249,8 +249,9 @@ public class UndertowProducer extends DefaultAsyncProducer { client = UndertowClient.getInstance(); if (endpoint.isWebSocket()) { - this.webSocketHandler = (CamelWebSocketHandler) endpoint.getComponent().registerEndpoint(null, + this.webSocketHandler = (CamelWebSocketHandler) endpoint.getComponent().registerEndpoint(endpoint, null, endpoint.getHttpHandlerRegistrationInfo(), endpoint.getSslContext(), new CamelWebSocketHandler()); + this.webSocketHandler.addProducer(endpoint); } LOG.debug("Created worker: {} with options: {}", worker, options); @@ -261,6 +262,9 @@ public class UndertowProducer extends DefaultAsyncProducer { super.doStop(); if (endpoint.isWebSocket()) { + if (webSocketHandler != null) { + webSocketHandler.removeProducer(endpoint); + } endpoint.getComponent().unregisterEndpoint(null, endpoint.getHttpHandlerRegistrationInfo(), endpoint.getSslContext()); } 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 00feeac6a644..39e8b8e41739 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 @@ -23,13 +23,17 @@ import java.io.Reader; import java.io.StringReader; import java.nio.ByteBuffer; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; @@ -38,11 +42,15 @@ import java.util.stream.Collectors; import io.undertow.Handlers; import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; +import io.undertow.server.handlers.ResponseCodeHandler; +import io.undertow.util.AttachmentKey; +import io.undertow.util.StatusCodes; import io.undertow.websockets.WebSocketConnectionCallback; import io.undertow.websockets.WebSocketProtocolHandshakeHandler; import io.undertow.websockets.core.AbstractReceiveListener; import io.undertow.websockets.core.BufferedBinaryMessage; import io.undertow.websockets.core.BufferedTextMessage; +import io.undertow.websockets.core.CloseMessage; import io.undertow.websockets.core.WebSocketChannel; import io.undertow.websockets.core.WebSockets; import io.undertow.websockets.spi.WebSocketHttpExchange; @@ -53,7 +61,9 @@ import org.apache.camel.RuntimeCamelException; import org.apache.camel.component.undertow.UndertowConstants; import org.apache.camel.component.undertow.UndertowConstants.EventType; import org.apache.camel.component.undertow.UndertowConsumer; +import org.apache.camel.component.undertow.UndertowEndpoint; import org.apache.camel.component.undertow.UndertowProducer; +import org.apache.camel.component.undertow.spi.UndertowSecurityProvider; import org.apache.camel.converter.IOConverter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -66,11 +76,24 @@ import org.xnio.Pooled; */ public class CamelWebSocketHandler implements HttpHandler { private static final Logger LOG = LoggerFactory.getLogger(CamelWebSocketHandler.class); + private static final AttachmentKey<HandshakeResult> HANDSHAKE_RESULT_ATTACHMENT + = AttachmentKey.create(HandshakeResult.class); + private static final String HANDSHAKE_RESULT = CamelWebSocketHandler.class.getName() + ".handshakeResult"; + private static final AttachmentKey<String> CONSUMER_HANDLERS_ATTACHMENT = AttachmentKey.create(String.class); private final UndertowWebSocketConnectionCallback callback; private UndertowConsumer consumer; + /** + * The endpoint of the last consumer set on this handler, whose security settings apply to the path. + */ + private UndertowEndpoint consumerEndpoint; + + private final List<UndertowEndpoint> producerEndpoints = new CopyOnWriteArrayList<>(); + + private final Set<String> warnedProducerEndpoints = ConcurrentHashMap.newKeySet(); + private final Lock consumerLock = new ReentrantLock(); private final WebSocketProtocolHandshakeHandler delegate; @@ -79,6 +102,17 @@ public class CamelWebSocketHandler implements HttpHandler { private final UndertowReceiveListener receiveListener; + private final HttpHandler upgradeHandler = this::upgrade; + + private final HttpHandler consumerRequestHandler = this::handleConsumerRequest; + + /** + * The handlers of the consumer, such as its access log, followed by {@link #upgradeHandler}. + */ + private volatile ConsumerHandlers consumerHandlers = new ConsumerHandlers(null, upgradeHandler); + + private volatile HttpHandler entryHandler = consumerRequestHandler; + public CamelWebSocketHandler() { this.receiveListener = new UndertowReceiveListener(); this.callback = new UndertowWebSocketConnectionCallback(); @@ -130,9 +164,131 @@ public class CamelWebSocketHandler implements HttpHandler { */ @Override public void handleRequest(HttpServerExchange exchange) throws Exception { + entryHandler.handleRequest(exchange); + } + + /** + * The handler that applies the security settings of the path to an upgrade request and then performs the WebSocket + * handshake. A consumer can run its own handlers, such as its access log, before it. + */ + public HttpHandler getUpgradeHandler() { + return upgradeHandler; + } + + /** + * Lets the given security provider wrap this handler, as it wraps the handlers of HTTP endpoints. The handler stays + * registered as is, so that it remains shared by the consumer and the producers of the path. + */ + public void wrapWith(UndertowSecurityProvider securityProvider) throws Exception { + HttpHandler wrapped = securityProvider.wrapHttpHandler(consumerRequestHandler); + // a provider that returns no handler disables the path, as it does for HTTP endpoints + this.entryHandler = wrapped != null ? wrapped : ResponseCodeHandler.HANDLE_405; + } + + private void handleConsumerRequest(HttpServerExchange exchange) throws Exception { + ConsumerHandlers handlers = consumerHandlers; + if (handlers.endpoint != null) { + // the request only reaches the upgrade if the handlers of this consumer let it through + exchange.putAttachment(CONSUMER_HANDLERS_ATTACHMENT, handlers.endpoint.getEndpointUri()); + } + handlers.handler.handleRequest(exchange); + } + + private void upgrade(HttpServerExchange exchange) throws Exception { + PathSecurity path = pathSecurity(); + if (path.endpoints.stream().noneMatch(endpoint -> requiresHandshakeResult(endpoint, path.consumer))) { + this.delegate.handleRequest(exchange); + return; + } + if (exchange.isInIoThread()) { + exchange.dispatch(upgradeHandler); + return; + } + Set<String> authenticatedEndpoints = new HashSet<>(); + Map<String, Object> headers = new HashMap<>(); + for (UndertowEndpoint endpoint : path.endpoints) { + if (endpoint.requiresAuthentication()) { + int statusCode = endpoint.authenticate(exchange); + if (statusCode != StatusCodes.OK) { + exchange.setStatusCode(statusCode); + exchange.endExchange(); + return; + } + if (endpoint.getSecurityProvider() != null) { + endpoint.getSecurityProvider().addHeader(headers::put, exchange); + } + } + if (requiresHandshakeResult(endpoint, path.consumer) && (!path.consumer || endpoint.getHandlers() == null + || endpoint.getEndpointUri().equals(exchange.getAttachment(CONSUMER_HANDLERS_ATTACHMENT)))) { + authenticatedEndpoints.add(endpoint.getEndpointUri()); + } + } + if (!authenticatedEndpoints.isEmpty()) { + exchange.putAttachment(HANDSHAKE_RESULT_ATTACHMENT, + new HandshakeResult(authenticatedEndpoints, headers)); + } this.delegate.handleRequest(exchange); } + /** + * The endpoints whose security settings apply to the path: the consumer's, also while it is stopped, otherwise the + * producers'. All the Camel endpoints of a path share its WebSocket connections. + */ + private PathSecurity pathSecurity() { + consumerLock.lock(); + try { + if (consumerEndpoint != null) { + return new PathSecurity(List.of(consumerEndpoint), true); + } + } finally { + consumerLock.unlock(); + } + return new PathSecurity(producerEndpoints.stream().distinct().toList(), false); + } + + /** + * Whether a channel must have passed the security provider, the allowed roles or, for the endpoint of a consumer, + * the custom handlers of the endpoint during its handshake. Only consumers run their custom handlers. + */ + private static boolean requiresHandshakeResult(UndertowEndpoint endpoint, boolean consumer) { + return endpoint.requiresAuthentication() || consumer && endpoint.getHandlers() != null; + } + + private static boolean isAuthenticated(WebSocketChannel channel, PathSecurity path) { + for (UndertowEndpoint endpoint : path.endpoints) { + if (!isAuthenticated(channel, endpoint, path.consumer)) { + return false; + } + } + return true; + } + + /** + * Whether the handshake of the given channel passed the security checks of the given endpoint of a consumer: its + * security provider, its allowed roles and its custom handlers. Always {@code true} for an endpoint without such + * checks. + */ + public static boolean isAuthenticated(WebSocketChannel channel, UndertowEndpoint endpoint) { + return isAuthenticated(channel, endpoint, true); + } + + private static boolean isAuthenticated(WebSocketChannel channel, UndertowEndpoint endpoint, boolean consumer) { + if (requiresHandshakeResult(endpoint, consumer)) { + return channel != null + && channel.getAttribute(HANDSHAKE_RESULT) instanceof HandshakeResult result + && result.endpointUris.contains(endpoint.getEndpointUri()); + } + return true; + } + + /** + * The headers that the security providers added during the handshake of the given channel. + */ + public static Map<String, Object> getSecurityProviderHeaders(WebSocketChannel channel) { + return channel.getAttribute(HANDSHAKE_RESULT) instanceof HandshakeResult result + ? result.headers : Collections.emptyMap(); + } + /** * Send the given {@code message} to one or more channels selected using the given {@code peerFilter} within the * given {@code timeout} and report the outcome to the given {@code camelExchange} and {@code camelCallback}. @@ -150,8 +306,12 @@ public class CamelWebSocketHandler implements HttpHandler { Predicate<WebSocketChannel> peerFilter, Object message, final int timeout, final Exchange camelExchange, final AsyncCallback camelCallback) throws IOException { - List<WebSocketChannel> targetPeers - = delegate.getPeerConnections().stream().filter(peerFilter).collect(Collectors.toList()); + // only the peers whose handshake passed the security checks of the path receive messages + PathSecurity path = pathSecurity(); + List<WebSocketChannel> targetPeers = delegate.getPeerConnections().stream() + .filter(peer -> isAuthenticated(peer, path)) + .filter(peerFilter) + .collect(Collectors.toList()); if (targetPeers.isEmpty()) { camelCallback.done(true); return true; @@ -169,6 +329,15 @@ public class CamelWebSocketHandler implements HttpHandler { * @param consumer the {@link UndertowConsumer} to set */ public void setConsumer(UndertowConsumer consumer) { + setConsumer(consumer, null); + } + + /** + * @param consumer the {@link UndertowConsumer} to set + * @param consumerHandler the handler that runs before {@link #getUpgradeHandler()} for this consumer, or + * {@code null} + */ + public void setConsumer(UndertowConsumer consumer, HttpHandler consumerHandler) { consumerLock.lock(); try { if (consumer != null && this.consumer != null) { @@ -177,11 +346,63 @@ public class CamelWebSocketHandler implements HttpHandler { + ".setConsumer(UndertowConsumer) with a non-null consumer before unsetting it via setConsumer(null)"); } this.consumer = consumer; + if (consumer != null) { + // both are kept when the consumer is unset, so that the path stays guarded while the consumer is stopped + this.consumerEndpoint = consumer.getEndpoint(); + this.consumerHandlers + = new ConsumerHandlers( + consumer.getEndpoint(), consumerHandler != null ? consumerHandler : upgradeHandler); + producerEndpoints.forEach(this::warnIfProducerSettingsUnused); + } } finally { consumerLock.unlock(); } } + /** + * 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. + */ + public void addProducer(UndertowEndpoint endpoint) { + producerEndpoints.add(endpoint); + warnIfProducerSettingsUnused(endpoint); + } + + private void warnIfProducerSettingsUnused(UndertowEndpoint producerEndpoint) { + UndertowEndpoint pathConsumerEndpoint; + consumerLock.lock(); + try { + pathConsumerEndpoint = consumerEndpoint; + } finally { + consumerLock.unlock(); + } + if (pathConsumerEndpoint != null && hasUnusedSecuritySettings(pathConsumerEndpoint, producerEndpoint) + && warnedProducerEndpoints.add(producerEndpoint.getEndpointUri())) { + LOG.warn("The security settings of {} are not used: the settings of the consumer {} apply to its WebSocket path", + producerEndpoint, pathConsumerEndpoint); + } + } + + /** + * Whether the producer endpoint has security settings of its own that differ from the ones of the consumer + * endpoint, which apply to the path instead. + */ + static boolean hasUnusedSecuritySettings(UndertowEndpoint consumerEndpoint, UndertowEndpoint producerEndpoint) { + boolean ownSettings = producerEndpoint.getAllowedRoles() != null + || producerEndpoint.getSecurityConfiguration() != null + || producerEndpoint.getSecurityProvider() != producerEndpoint.getComponent().getSecurityProvider(); + // endpoints that configure the same security configuration each get a provider of their own, which are alike + boolean sameProvider = producerEndpoint.getSecurityProvider() == consumerEndpoint.getSecurityProvider() + || producerEndpoint.getSecurityConfiguration() != null + && producerEndpoint.getSecurityConfiguration() == consumerEndpoint.getSecurityConfiguration(); + return ownSettings && (!sameProvider + || !Objects.equals(producerEndpoint.computeAllowedRoles(), consumerEndpoint.computeAllowedRoles())); + } + + public void removeProducer(UndertowEndpoint endpoint) { + producerEndpoints.remove(endpoint); + } + void sendEventNotificationIfNeeded( String connectionKey, WebSocketHttpExchange transportExchange, WebSocketChannel channel, EventType eventType) { consumerLock.lock(); @@ -364,6 +585,46 @@ public class CamelWebSocketHandler implements HttpHandler { } + /** + * The endpoints whose security settings apply to a path, and whether that is the endpoint of its consumer. + */ + private static final class PathSecurity { + private final List<UndertowEndpoint> endpoints; + private final boolean consumer; + + private PathSecurity(List<UndertowEndpoint> endpoints, boolean consumer) { + this.endpoints = endpoints; + this.consumer = consumer; + } + } + + /** + * The handlers of a consumer, which end with {@link #upgradeHandler}, and the endpoint of that consumer. + */ + private static final class ConsumerHandlers { + private final UndertowEndpoint endpoint; + private final HttpHandler handler; + + private ConsumerHandlers(UndertowEndpoint endpoint, HttpHandler handler) { + this.endpoint = endpoint; + this.handler = handler; + } + } + + /** + * The endpoints whose security provider, allowed roles and custom handlers a handshake passed, and the headers that + * the providers added. + */ + private static final class HandshakeResult { + private final Set<String> endpointUris; + private final Map<String, Object> headers; + + private HandshakeResult(Set<String> endpointUris, Map<String, Object> headers) { + this.endpointUris = Collections.unmodifiableSet(endpointUris); + this.headers = Collections.unmodifiableMap(headers); + } + } + /** * Sets the {@link UndertowReceiveListener} to the given channel on connect. */ @@ -375,6 +636,17 @@ public class CamelWebSocketHandler implements HttpHandler { @Override public void onConnect(WebSocketHttpExchange exchange, WebSocketChannel channel) { LOG.trace("onConnect {}", exchange); + HandshakeResult handshakeResult = exchange.getAttachment(HANDSHAKE_RESULT_ATTACHMENT); + if (handshakeResult != null) { + channel.setAttribute(HANDSHAKE_RESULT, handshakeResult); + } + if (!isAuthenticated(channel, pathSecurity())) { + // the handshake did not pass the security checks that now apply to the path, for example because it + // completed before a consumer requiring them was set on this handler: fail closed + LOG.warn("Closing WebSocket channel whose handshake did not pass the security checks"); + WebSockets.sendClose(CloseMessage.MSG_VIOLATES_POLICY, "Authentication required", channel, null); + return; + } final String connectionKey = UUID.randomUUID().toString(); channel.setAttribute(UndertowConstants.CONNECTION_KEY, connectionKey); channel.getReceiveSetter().set(receiveListener); diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/handlers/CamelWebSocketHandlerSecuritySettingsTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/handlers/CamelWebSocketHandlerSecuritySettingsTest.java new file mode 100644 index 000000000000..69c6187ea203 --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/handlers/CamelWebSocketHandlerSecuritySettingsTest.java @@ -0,0 +1,87 @@ +/* + * 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.handlers; + +import org.apache.camel.CamelContext; +import org.apache.camel.component.undertow.UndertowEndpoint; +import org.apache.camel.component.undertow.spi.AbstractSecurityProviderTest; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The security settings that a WebSocket producer configures for itself are not used when a consumer is on the path, + * which is reported when they differ from the settings of the consumer. + */ +class CamelWebSocketHandlerSecuritySettingsTest { + + @Test + void producerWithoutItsOwnSettings() throws Exception { + assertFalse(hasUnusedSecuritySettings("allowedRoles=user", "sendToAll=true")); + } + + @Test + void producerWithTheSameSettingsAsTheConsumer() throws Exception { + assertFalse(hasUnusedSecuritySettings("allowedRoles=admin", "allowedRoles=admin")); + } + + @Test + void producerWithOtherRolesThanTheConsumer() throws Exception { + assertTrue(hasUnusedSecuritySettings("allowedRoles=user", "allowedRoles=admin")); + } + + @Test + void producerWithRolesOnAPathWhoseConsumerHasNone() throws Exception { + assertTrue(hasUnusedSecuritySettings("fireWebSocketChannelEvents=true", "allowedRoles=admin")); + } + + @Test + void producerWithTheSameSecurityConfigurationAsTheConsumer() throws Exception { + // each endpoint that configures a security configuration gets a provider instance of its own + Object configuration = new Object(); + try (CamelContext context = new DefaultCamelContext()) { + context.start(); + UndertowEndpoint consumerEndpoint = endpoint(context, "allowedRoles=user", configuration); + UndertowEndpoint producerEndpoint = endpoint(context, "allowedRoles=user&sendToAll=true", configuration); + assertFalse(CamelWebSocketHandler.hasUnusedSecuritySettings(consumerEndpoint, producerEndpoint)); + + UndertowEndpoint otherProducerEndpoint = endpoint(context, "allowedRoles=user&sendToAll=false", new Object()); + assertTrue(CamelWebSocketHandler.hasUnusedSecuritySettings(consumerEndpoint, otherProducerEndpoint)); + } + } + + private static UndertowEndpoint endpoint(CamelContext context, String options, Object securityConfiguration) { + UndertowEndpoint endpoint + = context.getEndpoint("undertow:ws://localhost:8080/path?" + options, UndertowEndpoint.class); + endpoint.setSecurityConfiguration(securityConfiguration); + endpoint.setSecurityProvider(new AbstractSecurityProviderTest.MockSecurityProvider()); + return endpoint; + } + + private static boolean hasUnusedSecuritySettings(String consumerOptions, String producerOptions) throws Exception { + try (CamelContext context = new DefaultCamelContext()) { + context.start(); + UndertowEndpoint consumerEndpoint + = context.getEndpoint("undertow:ws://localhost:8080/path?" + consumerOptions, UndertowEndpoint.class); + UndertowEndpoint producerEndpoint + = context.getEndpoint("undertow:ws://localhost:8080/path?" + producerOptions, UndertowEndpoint.class); + return CamelWebSocketHandler.hasUnusedSecuritySettings(consumerEndpoint, producerEndpoint); + } + } +} diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/ProviderWithServletWebSocketEndpointTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/ProviderWithServletWebSocketEndpointTest.java new file mode 100644 index 000000000000..2cdffab50254 --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/ProviderWithServletWebSocketEndpointTest.java @@ -0,0 +1,91 @@ +/* + * 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.util.concurrent.TimeUnit; + +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.component.undertow.BaseUndertowTest; +import org.apache.camel.test.infra.common.http.WebsocketTestClient; +import org.junit.jupiter.api.Test; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A security provider configured on a WebSocket endpoint that requires the servlet context gets it, whichever endpoint + * registers first on the port. + */ +class ProviderWithServletWebSocketEndpointTest extends BaseUndertowTest { + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + context.getRegistry().bind("servletProvider", new ProviderWithServletTest.MockSecurityProvider()); + return context; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + // a path only used by a producer, which configures the provider + from("direct:feed") + .to("undertow:ws://localhost:{{port}}/feed?securityProvider=#servletProvider&sendToAll=true"); + + // the producer of the route registers first on the port, without a provider + from("undertow:ws://localhost:{{port2}}/chat?securityProvider=#servletProvider") + .to("mock:chat") + .transform(simple("${in.header." + AbstractSecurityProviderTest.PRINCIPAL_PARAMETER + "}")) + .to("undertow:ws://localhost:{{port2}}/chat"); + } + }; + } + + @Test + void producerOnlyPathWithItsOwnProvider() { + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/feed"); + client.connect(); + // the server registers the connection shortly after the client has completed the upgrade + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + template.sendBody("direct:feed", "update"); + assertFalse(client.getReceived().isEmpty()); + }); + + assertEquals("update", client.getReceived(String.class).get(0)); + client.close(); + } + + @Test + void consumerWithItsOwnProviderOnAPortWhereAProducerRegisteredFirst() throws Exception { + getMockEndpoint("mock:chat").expectedBodiesReceived("hello"); + + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort2() + "/chat", 1); + client.connect(); + client.sendTextMessage("hello"); + + MockEndpoint.assertIsSatisfied(context); + assertTrue(client.await(10)); + assertEquals("user", client.getReceived(String.class).get(0)); + client.close(); + } +} diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/ProviderWithServletWebSocketTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/ProviderWithServletWebSocketTest.java new file mode 100644 index 000000000000..b04f8f783fed --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/ProviderWithServletWebSocketTest.java @@ -0,0 +1,66 @@ +/* + * 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 org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.test.infra.common.http.WebsocketTestClient; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A security provider that requires the servlet context gets it for WebSocket upgrade requests too, also when the first + * endpoint registered on the port is a WebSocket producer. + */ +class ProviderWithServletWebSocketTest extends AbstractProviderServletTest { + + @BeforeAll + static void initProvider() throws Exception { + createSecurtyProviderConfigurationFile(ProviderWithServletTest.MockSecurityProvider.class); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + // the producer of the route is started, and registered on the port, before its consumer + from("undertow:ws://localhost:{{port}}/foo?allowedRoles=user") + .to("mock:input") + .transform(simple("${in.header." + AbstractSecurityProviderTest.PRINCIPAL_PARAMETER + "}")) + .to("undertow:ws://localhost:{{port}}/foo"); + } + }; + } + + @Test + void upgradeRequestHasTheServletContext() throws Exception { + getMockEndpoint("mock:input").expectedBodiesReceived("hello"); + + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/foo", 1); + client.connect(); + client.sendTextMessage("hello"); + + MockEndpoint.assertIsSatisfied(context); + assertTrue(client.await(10)); + assertEquals("user", client.getReceived(String.class).get(0)); + client.close(); + } +} diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderRolesFromComponentWebSocketTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderRolesFromComponentWebSocketTest.java new file mode 100644 index 000000000000..c46ec37b0852 --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderRolesFromComponentWebSocketTest.java @@ -0,0 +1,78 @@ +/* + * 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.http.WebSocketHandshakeException; +import java.util.concurrent.CompletionException; + +import io.undertow.util.StatusCodes; +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.component.undertow.UndertowComponent; +import org.apache.camel.test.infra.common.http.WebsocketTestClient; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * The allowed roles of the component apply to the WebSocket endpoints that do not configure their own. + */ +class SecurityProviderRolesFromComponentWebSocketTest extends AbstractSecurityProviderTest { + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext camelContext = super.createCamelContext(); + camelContext.getComponent("undertow", UndertowComponent.class).setAllowedRoles("user"); + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("undertow:ws://localhost:{{port}}/roles").to("mock:input"); + } + }; + } + + @Test + void roleOfTheComponentIsAllowed() throws Exception { + securityConfiguration.setRoleToAssign("user"); + getMockEndpoint("mock:input").expectedBodiesReceived("hello"); + + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/roles"); + client.connect(); + client.sendTextMessage("hello"); + + MockEndpoint.assertIsSatisfied(context); + client.close(); + } + + @Test + void otherRoleIsRefused() { + securityConfiguration.setRoleToAssign("admin"); + + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/roles"); + CompletionException thrown = assertThrows(CompletionException.class, client::connect); + WebSocketHandshakeException handshake = assertInstanceOf(WebSocketHandshakeException.class, thrown.getCause()); + assertEquals(StatusCodes.FORBIDDEN, handshake.getResponse().statusCode()); + } +} diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketTest.java new file mode 100644 index 000000000000..ad96dd162392 --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketTest.java @@ -0,0 +1,160 @@ +/* + * 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.http.WebSocketHandshakeException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +import io.undertow.util.StatusCodes; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.test.infra.common.http.WebsocketTestClient; +import org.junit.jupiter.api.Test; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The security provider and the allowed roles apply to the upgrade request of WebSocket endpoints: on a path with a + * consumer, on a path only used by producers, and on a path whose consumer is stopped. + */ +class SecurityProviderWebSocketTest extends AbstractSecurityProviderTest { + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("undertow:ws://localhost:{{port}}/wssecure?allowedRoles=user") + .to("mock:input") + .transform(simple("echo:${body}")) + .to("undertow:ws://localhost:{{port}}/wssecure?sendToAll=true"); + + from("direct:feed") + .to("undertow:ws://localhost:{{port}}/feed?sendToAll=true&allowedRoles=user"); + + from("direct:stopped") + .to("undertow:ws://localhost:{{port}}/stopped?sendToAll=true"); + from("undertow:ws://localhost:{{port}}/stopped?allowedRoles=user").routeId("stopped") + .to("mock:stopped"); + + from("undertow:ws://localhost:{{port}}/prefix?matchOnUriPrefix=true&allowedRoles=user") + .to("mock:prefix"); + } + }; + } + + @Test + void subPathOfAPrefixPathIsGuarded() throws Exception { + securityConfiguration.setRoleToAssign("admin"); + assertRefused("/prefix/sub"); + + securityConfiguration.setRoleToAssign("user"); + MockEndpoint prefix = getMockEndpoint("mock:prefix"); + prefix.expectedBodiesReceived("hello"); + + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/prefix/sub"); + client.connect(); + client.sendTextMessage("hello"); + + prefix.assertIsSatisfied(); + client.close(); + } + + @Test + void matchingRoleConnectsAndExchangesMessages() throws Exception { + securityConfiguration.setRoleToAssign("user"); + MockEndpoint input = getMockEndpoint("mock:input"); + input.expectedBodiesReceived("ping"); + // the header added by the security provider during the upgrade + input.expectedHeaderReceived(PRINCIPAL_PARAMETER, "user"); + + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/wssecure", 1); + client.connect(); + client.sendTextMessage("ping"); + + input.assertIsSatisfied(); + assertTrue(client.await(10)); + assertEquals("echo:ping", client.getReceived(String.class).get(0)); + client.close(); + } + + @Test + void mismatchedRoleIsRefused() { + securityConfiguration.setRoleToAssign("admin"); + + assertRefused("/wssecure"); + } + + @Test + void missingRoleIsRefused() { + securityConfiguration.setRoleToAssign(null); + + assertRefused("/wssecure"); + } + + @Test + void producerOnlyPathAppliesTheProducerSettings() throws Exception { + securityConfiguration.setRoleToAssign("admin"); + assertRefused("/feed"); + + securityConfiguration.setRoleToAssign("user"); + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/feed"); + client.connect(); + // the server registers the connection shortly after the client has completed the upgrade + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + template.sendBody("direct:feed", "update"); + assertFalse(client.getReceived().isEmpty()); + }); + + assertEquals("update", client.getReceived(String.class).get(0)); + client.close(); + } + + @Test + void pathStaysGuardedWhileTheConsumerIsStopped() throws Exception { + context.getRouteController().stopRoute("stopped"); + + securityConfiguration.setRoleToAssign("admin"); + assertRefused("/stopped"); + + securityConfiguration.setRoleToAssign("user"); + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + "/stopped"); + client.connect(); + context.getRouteController().startRoute("stopped"); + + MockEndpoint stopped = getMockEndpoint("mock:stopped"); + stopped.expectedBodiesReceived("hello"); + client.sendTextMessage("hello"); + + stopped.assertIsSatisfied(); + client.close(); + } + + private void assertRefused(String path) { + WebsocketTestClient client = new WebsocketTestClient("ws://localhost:" + getPort() + path); + + CompletionException thrown = assertThrows(CompletionException.class, client::connect); + WebSocketHandshakeException handshake = assertInstanceOf(WebSocketHandshakeException.class, thrown.getCause()); + assertEquals(StatusCodes.FORBIDDEN, handshake.getResponse().statusCode()); + } +} diff --git a/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketWrapTest.java b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketWrapTest.java new file mode 100644 index 000000000000..7c7709a68b92 --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/spi/SecurityProviderWebSocketWrapTest.java @@ -0,0 +1,89 @@ +/* + * 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.net.http.WebSocketHandshakeException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +import io.undertow.util.StatusCodes; +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * A security provider that wraps the HTTP handlers wraps the WebSocket endpoints as well. + */ +class SecurityProviderWebSocketWrapTest extends AbstractSecurityProviderTest { + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext camelContext = super.createCamelContext(); + securityConfiguration.setWrapHttpHandler(next -> exchange -> { + if ("yes".equals(exchange.getRequestHeaders().getFirst("X-Allow"))) { + next.handleRequest(exchange); + } else { + exchange.setStatusCode(StatusCodes.UNAUTHORIZED); + exchange.endExchange(); + } + }); + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("undertow:ws://localhost:{{port}}/wrapped?allowedRoles=user").to("mock:wrapped"); + } + }; + } + + @Test + void wrapperAppliesToTheUpgrade() throws Exception { + securityConfiguration.setRoleToAssign("user"); + + CompletionException thrown = assertThrows(CompletionException.class, () -> connect(null)); + WebSocketHandshakeException handshake = assertInstanceOf(WebSocketHandshakeException.class, thrown.getCause()); + assertEquals(StatusCodes.UNAUTHORIZED, handshake.getResponse().statusCode()); + + getMockEndpoint("mock:wrapped").expectedBodiesReceived("hello"); + WebSocket webSocket = connect("yes"); + webSocket.sendText("hello", true).join(); + + MockEndpoint.assertIsSatisfied(context); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join(); + } + + private WebSocket connect(String allow) { + WebSocket.Builder builder = HttpClient.newHttpClient().newWebSocketBuilder(); + if (allow != null) { + builder.header("X-Allow", allow); + } + return builder.buildAsync(URI.create("ws://localhost:" + getPort() + "/wrapped"), new WebSocket.Listener() { + }).orTimeout(5, TimeUnit.SECONDS).join(); + } +} 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 new file mode 100644 index 000000000000..9f7ee35ec259 --- /dev/null +++ b/components/camel-undertow/src/test/java/org/apache/camel/component/undertow/ws/UndertowWsSecurityWithoutProviderTest.java @@ -0,0 +1,206 @@ +/* + * 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.ws; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.net.http.WebSocketHandshakeException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.undertow.util.StatusCodes; +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.BaseUndertowTest; +import org.apache.camel.component.undertow.UndertowBasicAuthHandler; +import org.junit.jupiter.api.Test; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * WebSocket endpoints apply the allowedRoles and handlers options without a security provider, as HTTP endpoints do. + */ +class UndertowWsSecurityWithoutProviderTest extends BaseUndertowTest { + + private final AtomicInteger lateRouteInvocations = new AtomicInteger(); + private final AtomicInteger lateBasicRouteInvocations = new AtomicInteger(); + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = super.createCamelContext(); + context.getRegistry().bind("basicAuth", new UndertowBasicAuthHandler()); + context.getRegistry().bind("lateBasicAuth", new UndertowBasicAuthHandler()); + context.getRegistry().bind("producerBasicAuth", new UndertowBasicAuthHandler()); + return context; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("undertow:ws://localhost:{{port}}/roles?allowedRoles=user").to("mock:roles"); + + from("undertow:ws://localhost:{{port}}/basic?handlers=#basicAuth").to("mock:basic"); + + from("direct:late").to("undertow:ws://localhost:{{port}}/late?sendToAll=true"); + from("undertow:ws://localhost:{{port}}/late?allowedRoles=user").routeId("late").autoStartup(false) + .process(exchange -> lateRouteInvocations.incrementAndGet()); + + // handlers is a consumer option: a producer does not run it + from("direct:producerHandlers") + .to("undertow:ws://localhost:{{port}}/producerHandlers?handlers=#producerBasicAuth&sendToAll=true"); + + from("direct:lateBasic").to("undertow:ws://localhost:{{port}}/lateBasic?sendToAll=true"); + from("undertow:ws://localhost:{{port}}/lateBasic?handlers=#lateBasicAuth").routeId("lateBasic") + .autoStartup(false) + .process(exchange -> lateBasicRouteInvocations.incrementAndGet()); + } + }; + } + + @Test + void allowedRolesWithoutProviderRefusesTheUpgrade() { + assertRefused("/roles", null, StatusCodes.FORBIDDEN); + } + + @Test + void handlersRunBeforeTheUpgrade() throws Exception { + assertRefused("/basic", null, StatusCodes.UNAUTHORIZED); + + getMockEndpoint("mock:basic").expectedBodiesReceived("hello"); + String credentials = Base64.getEncoder().encodeToString("guest:secret".getBytes(StandardCharsets.UTF_8)); + WebSocket webSocket = connect("/basic", "Basic " + credentials, new RecordingListener()); + webSocket.sendText("hello", true).join(); + + MockEndpoint.assertIsSatisfied(context); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join(); + } + + @Test + void connectionOpenedBeforeTheConsumerStartedIsNotServed() throws Exception { + // only the producer uses the path, and it has no security settings, so the upgrade is not checked + RecordingListener listener = new RecordingListener(); + WebSocket webSocket = connect("/late", null, listener); + + context.getRouteController().startRoute("late"); + + // 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(); + + assertEquals(CloseMessage.MSG_VIOLATES_POLICY, listener.closeCode.orTimeout(10, TimeUnit.SECONDS).join()); + assertTrue(listener.received.isEmpty()); + assertEquals(0, lateRouteInvocations.get()); + } + + @Test + void connectionOpenedBeforeAConsumerWithHandlersStartedIsNotServed() throws Exception { + // only the producer uses the path, and it has no security settings, so the upgrade does not go through the + // handlers of the consumer + RecordingListener listener = new RecordingListener(); + WebSocket webSocket = connect("/lateBasic", null, listener); + + context.getRouteController().startRoute("lateBasic"); + + // 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(); + + assertEquals(CloseMessage.MSG_VIOLATES_POLICY, listener.closeCode.orTimeout(10, TimeUnit.SECONDS).join()); + assertTrue(listener.received.isEmpty()); + assertEquals(0, lateBasicRouteInvocations.get()); + + // a connection that goes through the handlers is served + String credentials = Base64.getEncoder().encodeToString("guest:secret".getBytes(StandardCharsets.UTF_8)); + WebSocket authenticated = connect("/lateBasic", "Basic " + credentials, new RecordingListener()); + authenticated.sendText("hello", true).join(); + await().atMost(10, TimeUnit.SECONDS).until(() -> lateBasicRouteInvocations.get() == 1); + authenticated.sendClose(WebSocket.NORMAL_CLOSURE, "done").join(); + } + + @Test + void handlersOfAProducerDoNotGuardItsPath() { + RecordingListener listener = new RecordingListener(); + WebSocket webSocket = connect("/producerHandlers", null, listener); + + // the server registers the connection shortly after the client has completed the upgrade + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + template.sendBody("direct:producerHandlers", "update"); + assertFalse(listener.received.isEmpty()); + }); + assertEquals("update", listener.received.get(0)); + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join(); + } + + private void assertRefused(String path, String authorization, int statusCode) { + CompletionException thrown + = assertThrows(CompletionException.class, () -> connect(path, authorization, new RecordingListener())); + WebSocketHandshakeException handshake = assertInstanceOf(WebSocketHandshakeException.class, thrown.getCause()); + assertEquals(statusCode, handshake.getResponse().statusCode()); + } + + 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<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()); + webSocket.request(1); + return null; + } + + @Override + public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) { + closeCode.complete(statusCode); + return null; + } + } +} diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc index 86e21b7a320a..cf37b379c914 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc @@ -89,6 +89,23 @@ level by default. Note that JGroups deserializes the payload inside its own receive path, so this check is a defense-in-depth allow-list on the resulting body type. The JVM-wide `jdk.serialFilter`, together with a channel secured with `AUTH` and encryption, remain the primary mitigations. +=== camel-undertow - WebSocket endpoints apply the security provider, allowed roles and handlers + +WebSocket endpoints (`ws://` and `wss://`) now apply the `UndertowSecurityProvider` (the `securityConfiguration` or +`securityProvider` option), the `allowedRoles` option, and the `handlers` and `accessLog` options, as HTTP endpoints +do. Previously these options had no effect on WebSocket endpoints. They apply to the upgrade request: a client that +the security provider rejects, or that connects to an endpoint with `allowedRoles` but no security provider, now gets +the same status code as on an HTTP endpoint (for example `403`) and no connection is opened. The headers that the +security provider adds are set on the exchanges of the connection. + +All the endpoints of a WebSocket path share its connections. The settings of the consumer apply to the path, +including the messages sent by producers on that path, and keep applying while the consumer is stopped; a path that +only has producers applies their settings. A connection that was opened before these settings applied does not +receive the messages of the producers, and it is closed when it sends a message. + +A WebSocket client that connected without satisfying the configured security provider or allowed roles is now +rejected: it must authenticate, or the option must be removed from the WebSocket endpoint. + == Upgrading from 4.18.3 to 4.18.4 === camel-core - Multicast EIP honors UseOriginalAggregationStrategy
