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

asf-gitbox-commits pushed a commit to branch 
backport/graphql-websocket-auth-3.0.x
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 6d55e73ad6f8bd2e9028ce9e29a609daecea950a
Author: Serge Huber <[email protected]>
AuthorDate: Fri Sep 4 12:13:48 2026 +0200

    Require authentication for GraphQL WebSocket upgrade (unomi-3.0.x)
    
    Port of the master fix (#843) to the 3.0.x line, re-cut from current 
unomi-3.0.x.
    
    A handshake that carries an Authorization header is authenticated before the
    upgrade is accepted, and a foreign-origin handshake is refused, since a
    WebSocket handshake is not subject to the same-origin policy. A handshake 
that
    carries no credential - which is all a browser can send - is upgraded but 
the
    resulting socket does nothing until it authenticates through the 
connection_init
    payload; every other message is refused and closes the socket, and a 
scheduled
    close ends any socket that has not authenticated within its deadline. The
    shipped GraphQL UI passes the Headers-tab Authorization to the WebSocket 
client
    as connectionParams so both transports use the same credential.
    
    Differences from master, because 3.0.x has no tenancy or security context:
    whether a socket is authenticated is a plain flag set from the remote user 
the
    validator records on a successful handshake login, and there is no execution
    context to bind around event delivery. The deadline scheduler is shut down 
from
    the servlet's destroy(), as the creator object has no Jetty lifecycle of its
    own. Close frames now carry valid codes (1000/1008) instead of 0, and the
    credential payload is not logged.
    
    Covered by integration tests only, as this line does not carry a unit-test
    stack for the GraphQL module. GraphQLServletSecurityIT is now registered in
    AllITs; it was never run on this line.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
 .../unomi/graphql/servlet/GraphQLServlet.java      | 111 +++++++++++--
 .../auth/GraphQLServletSecurityValidator.java      |  51 +++++-
 .../servlet/websocket/SubscriptionWebSocket.java   | 175 ++++++++++++++++++++-
 .../websocket/SubscriptionWebSocketFactory.java    |  37 ++++-
 .../src/main/resources/assets/js/index.jsx         |  26 ++-
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../unomi/itests/graphql/GraphQLWebSocketIT.java   | 156 +++++++++++++++++-
 .../graphql/socket/out/init-bad-credentials.json   |   6 +
 .../graphql/socket/out/init-with-credentials.json  |   6 +
 9 files changed, 540 insertions(+), 29 deletions(-)

diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
index c9bd2da58..b1e6fa23d 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
@@ -39,6 +39,7 @@ import javax.servlet.ServletResponse;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.HashMap;
 import java.util.Map;
@@ -71,37 +72,121 @@ public class GraphQLServlet extends WebSocketServlet {
     @Override
     public void init(ServletConfig config) throws ServletException {
         LOGGER.debug("GraphQLServlet initialized");
-        super.init(config);
+        // Must precede super.init(): WebSocketServlet.init() calls 
configure(), which captures this
+        // validator into the SubscriptionWebSocketFactory.
         this.validator = new GraphQLServletSecurityValidator();
+        super.init(config);
     }
 
     private WebSocketServletFactory factory;
 
+    private SubscriptionWebSocketFactory socketCreator;
+
+    @Override
+    public void destroy() {
+        try {
+            if (socketCreator != null) {
+                socketCreator.shutdown();
+            }
+        } finally {
+            super.destroy();
+        }
+    }
+
     @Override
     public void configure(WebSocketServletFactory factory) {
         LOGGER.debug("GraphQLServlet configured");
         this.factory = factory;
-        factory.setCreator(new 
SubscriptionWebSocketFactory(graphQLSchemaUpdater.getGraphQL(), 
serviceManager));
+        this.socketCreator = new 
SubscriptionWebSocketFactory(graphQLSchemaUpdater.getGraphQL(), serviceManager, 
validator);
+        factory.setCreator(socketCreator);
         factory.getPolicy().setMaxTextMessageBufferSize(1024 * 1024);
     }
 
     @Override
     protected void service(HttpServletRequest request, HttpServletResponse 
response) throws ServletException, IOException {
         LOGGER.debug("GraphQLServlet service called with request: {}", 
request.getRequestURI());
-        if (factory.isUpgradeRequest(request, response)) {
-            try {
-                final ServletUpgradeRequest upReq = new 
ServletUpgradeRequest(request);
-                for (String subProtocol : upReq.getSubProtocols()) {
-                    if (subProtocol.startsWith("graphql")) {
-                        response.addHeader("Sec-WebSocket-Protocol", 
subProtocol);
-                        break;
-                    }
+        // HTTP GraphQL (GET/POST/OPTIONS): auth is enforced in doGet/doPost 
via validator.validate(...).
+        if (!factory.isUpgradeRequest(request, response)) {
+            super.service(request, response);
+            return;
+        }
+        serviceWebSocketUpgrade(request, response);
+    }
+
+    /**
+     * WebSocket upgrade path. Order matters for security:
+     * <ol>
+     *   <li>Refuse foreign-origin handshakes, then authenticate any 
credential the handshake carries
+     *       BEFORE {@code acceptWebSocket}. A handshake that carries no 
credential is upgraded
+     *       unauthenticated - a browser cannot set headers on it - and must 
authenticate through
+     *       {@code connection_init} before the socket will do anything.</li>
+     *   <li>Call {@code acceptWebSocket} directly - do not call {@code 
WebSocketServlet.service()},
+     *       which can fall through to {@code doGet}/{@code doPost} when 
accept fails and the response
+     *       is not committed.</li>
+     * </ol>
+     */
+    private void serviceWebSocketUpgrade(HttpServletRequest request, 
HttpServletResponse response) throws IOException {
+        // A WebSocket handshake is not subject to the same-origin policy and 
triggers no CORS
+        // preflight, so any page on any origin can open one against this 
endpoint. Refuse handshakes
+        // that declare a foreign origin before doing anything else.
+        if (!isOriginAllowed(request)) {
+            LOGGER.warn("Refusing cross-origin WebSocket upgrade from origin 
{}", request.getHeader("Origin"));
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "Cross-origin 
WebSocket upgrade refused");
+            return;
+        }
+
+        // Credentials on the upgrade request are the strongest path: the 
socket is never created
+        // unauthenticated. They stay mandatory for any client that can set 
request headers.
+        if (request.getHeader("Authorization") != null && 
!validator.validateWebSocketUpgrade(request, response)) {
+            return;
+        }
+
+        try {
+            final ServletUpgradeRequest upReq = new 
ServletUpgradeRequest(request);
+            for (String subProtocol : upReq.getSubProtocols()) {
+                if (subProtocol.startsWith("graphql")) {
+                    response.addHeader("Sec-WebSocket-Protocol", subProtocol);
+                    break;
                 }
-            } catch (URISyntaxException e) {
-                throw new RuntimeException(e);
             }
+        } catch (URISyntaxException e) {
+            throw new RuntimeException(e);
+        }
+
+        if (!factory.acceptWebSocket(request, response) && 
!response.isCommitted()) {
+            // Upgrade was intended but rejected; never fall through to HTTP 
GraphQL.
+            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid 
WebSocket upgrade");
+        }
+    }
+
+    /**
+     * Accepts a handshake that declares no origin (a non-browser client, 
which cannot be driven into
+     * making the request by a hostile page) or one whose origin is this same 
host. Anything else is a
+     * page on another origin trying to open a socket here, which is refused.
+     */
+    static boolean isOriginAllowed(HttpServletRequest request) {
+        final String origin = request.getHeader("Origin");
+        if (origin == null || origin.trim().isEmpty()) {
+            return true;
+        }
+        try {
+            final URI originUri = new URI(origin);
+            final String originHost = originUri.getHost();
+            if (originHost == null) {
+                return false;
+            }
+            if (!originHost.equalsIgnoreCase(request.getServerName())) {
+                return false;
+            }
+            int originPort = originUri.getPort();
+            if (originPort == -1) {
+                originPort = "https".equalsIgnoreCase(originUri.getScheme()) ? 
443 : 80;
+            }
+            return originPort == request.getServerPort();
+        } catch (URISyntaxException e) {
+            LOGGER.debug("Refusing WebSocket upgrade with unparseable Origin", 
e);
+            return false;
         }
-        super.service(request, response);
     }
 
     @Override
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
index ca64228cd..ea0addd95 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -36,6 +36,7 @@ import javax.security.auth.login.LoginException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Base64;
 import java.util.List;
@@ -109,13 +110,55 @@ public class GraphQLServletSecurityValidator {
         return true;
     }
 
+    /**
+     * Authenticates a WebSocket upgrade that carries an {@code Authorization} 
header. Subscriptions are
+     * never public, so only Basic JAAS credentials are accepted.
+     *
+     * @return true when the caller is authenticated; false after a 401 has 
been sent
+     */
+    public boolean validateWebSocketUpgrade(HttpServletRequest req, 
HttpServletResponse res) throws IOException {
+        if (req.getHeader("Authorization") == null) {
+            res.addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
+            res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+            return false;
+        }
+        if (isAuthenticatedUser(req)) {
+            return true;
+        }
+        res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+        return false;
+    }
+
+    /**
+     * Authenticates a Basic credential that did not arrive on an HTTP 
request, such as the one a browser
+     * client sends in the WebSocket {@code connection_init} payload. Same 
JAAS check as the HTTP path.
+     */
+    public boolean authenticateBasicCredential(String authorizationValue) {
+        return authenticateBasic(authorizationValue, null);
+    }
+
     private boolean isAuthenticatedUser(HttpServletRequest req) {
         req.setAttribute(AUTHENTICATION_TYPE, HttpServletRequest.BASIC_AUTH);
+        return authenticateBasic(req.getHeader("Authorization"), req);
+    }
 
-        String authHeader = req.getHeader("Authorization");
-
-        String usernameAndPassword = new 
String(Base64.getDecoder().decode(authHeader.substring(6).getBytes()));
+    /**
+     * @param req the originating request, or {@code null} when the credential 
did not arrive on one
+     */
+    private boolean authenticateBasic(String authHeader, HttpServletRequest 
req) {
+        if (authHeader == null || !authHeader.startsWith("Basic ")) {
+            return false;
+        }
+        final String usernameAndPassword;
+        try {
+            usernameAndPassword = new 
String(Base64.getDecoder().decode(authHeader.substring(6).trim()), 
StandardCharsets.UTF_8);
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
         int userNameIndex = usernameAndPassword.indexOf(":");
+        if (userNameIndex <= 0) {
+            return false;
+        }
         String username = usernameAndPassword.substring(0, userNameIndex);
         String password = usernameAndPassword.substring(userNameIndex + 1);
 
@@ -135,7 +178,7 @@ public class GraphQLServletSecurityValidator {
             loginContext.login();
             Subject subject = loginContext.getSubject();
             boolean success = subject != null;
-            if (success) {
+            if (success && req != null) {
                 req.setAttribute(REMOTE_USER, username);
             }
             return success;
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
index 5e20d0305..165d7ca9e 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
@@ -21,6 +21,7 @@ import graphql.ExecutionInput;
 import graphql.ExecutionResult;
 import graphql.GraphQL;
 import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
 import org.eclipse.jetty.websocket.api.Session;
 import org.eclipse.jetty.websocket.api.WebSocketAdapter;
 import org.reactivestreams.Publisher;
@@ -31,43 +32,129 @@ import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
 
 public class SubscriptionWebSocket extends WebSocketAdapter {
+
     private static final Logger LOGGER = 
LoggerFactory.getLogger(SubscriptionWebSocket.class);
 
-    private GraphQL graphQL;
+    /** Closes a socket that has not authenticated within this window 
(milliseconds). */
+    private static final long AUTHENTICATION_DEADLINE_MS = 10_000L;
+
+    /** WebSocket close codes (RFC 6455). Code 0 is not valid and produces no 
client-visible close. */
+    private static final int CLOSE_NORMAL = 1000;
+    private static final int CLOSE_POLICY_VIOLATION = 1008;
+
+    private final GraphQL graphQL;
+
+    private final ServiceManager serviceManager;
+
+    private final GraphQLServletSecurityValidator validator;
+
+    /** No operation is executed on this socket until this is true. */
+    private volatile boolean authenticated;
 
-    private ServiceManager serviceManager;
+    /** Runs the unauthenticated-socket deadline; owned and stopped by the 
factory. */
+    private final ScheduledExecutorService deadlineScheduler;
+
+    /** Makes "authenticate" and "deadline expired" mutually exclusive 
outcomes. */
+    private final Object authenticationLock = new Object();
+
+    private volatile ScheduledFuture<?> deadlineTask;
+
+    private boolean deadlineExpired;
 
     private Map<String, ExecutionResultSubscriber> subscriptions = new 
HashMap<String, ExecutionResultSubscriber>();
 
-    public SubscriptionWebSocket(GraphQL graphQL, ServiceManager 
serviceManager) {
+    /**
+     * @param authenticated true when the upgrade request carried a valid 
credential; otherwise the
+     *                      socket starts unauthenticated and must present one 
through connection_init
+     */
+    public SubscriptionWebSocket(GraphQL graphQL, ServiceManager 
serviceManager, boolean authenticated,
+                                 GraphQLServletSecurityValidator validator,
+                                 ScheduledExecutorService deadlineScheduler) {
         this.graphQL = graphQL;
         this.serviceManager = serviceManager;
+        this.authenticated = authenticated;
+        this.validator = validator;
+        this.deadlineScheduler = Objects.requireNonNull(deadlineScheduler, 
"deadlineScheduler");
     }
 
     @Override
     public void onWebSocketConnect(Session sess) {
         LOGGER.info("Opening web socket");
         super.onWebSocketConnect(sess);
+        if (!authenticated) {
+            // Bound how long an unauthenticated socket may sit open. The idle 
timeout alone is not a
+            // deadline, since Jetty resets it on any received frame, so a 
scheduled task closes the
+            // socket at the deadline whatever the client sends.
+            sess.setIdleTimeout(AUTHENTICATION_DEADLINE_MS);
+            deadlineTask = 
deadlineScheduler.schedule(this::expireAuthenticationDeadline,
+                    AUTHENTICATION_DEADLINE_MS, TimeUnit.MILLISECONDS);
+        }
     }
 
     @Override
     public void onWebSocketClose(int statusCode, String reason) {
         LOGGER.info("Closing web socket");
+        cancelAuthenticationDeadline();
         super.onWebSocketClose(statusCode, reason);
     }
 
+    /**
+     * Runs on the deadline thread when an unauthenticated socket reaches its 
deadline. Closes it unless
+     * connection_init already won; the lock makes the two outcomes mutually 
exclusive.
+     */
+    private void expireAuthenticationDeadline() {
+        synchronized (authenticationLock) {
+            if (authenticated) {
+                return;
+            }
+            deadlineExpired = true;
+        }
+        LOGGER.warn("Closing GraphQL WebSocket that did not authenticate 
within {} ms", AUTHENTICATION_DEADLINE_MS);
+        final Session session = getSession();
+        if (session != null && session.isOpen()) {
+            session.close(CLOSE_POLICY_VIOLATION, "Authentication deadline 
expired");
+        }
+    }
+
+    private void cancelAuthenticationDeadline() {
+        final ScheduledFuture<?> task = deadlineTask;
+        if (task != null) {
+            task.cancel(false);
+        }
+    }
+
     @Override
     public void onWebSocketText(String textMessage) {
-        LOGGER.info("Got web socket messages {}", textMessage);
+        // Deliberately not logging the message: connection_init carries the 
client's credentials.
+        LOGGER.debug("Got web socket message of {} characters", textMessage == 
null ? 0 : textMessage.length());
         final GraphQLMessage message = GraphQLMessage.fromJson(textMessage);
         if (message == null) {
             return;
         }
 
+        // Until the socket has authenticated, connection_init is the only 
message that is acted on.
+        // Everything else - including any attempt to start an operation - 
closes the socket.
+        if (!authenticated && 
!GraphQLMessage.TYPE_CONNECTION_INIT.equals(message.getType())) {
+            LOGGER.warn("Refusing '{}' on an unauthenticated GraphQL 
WebSocket", message.getType());
+            sendMessage(GraphQLMessage.create(message.getId())
+                    .type(GraphQLMessage.TYPE_CONNECTION_ERROR)
+                    .errors(Collections.singletonList("Not authenticated"))
+                    .build());
+            closeConnection(message, CLOSE_POLICY_VIOLATION, "Not 
authenticated");
+            return;
+        }
+
         switch (message.getType()) {
             case GraphQLMessage.TYPE_CONNECTION_INIT:
+                if (!handleConnectionInit(message)) {
+                    return;
+                }
                 sendMessage(GraphQLMessage.connectionAck(message.getId()));
                 break;
             case GraphQLMessage.GQL_START:
@@ -82,9 +169,85 @@ public class SubscriptionWebSocket extends WebSocketAdapter 
{
         }
     }
 
+    /**
+     * Authenticates the socket from the {@code connection_init} payload, 
which is how a browser client
+     * presents credentials (it cannot set request headers on the handshake). 
A socket that already
+     * authenticated on the upgrade is left as it is.
+     *
+     * @return true when the socket may proceed, false when it has been closed
+     */
+    private boolean handleConnectionInit(GraphQLMessage message) {
+        if (authenticated) {
+            return true;
+        }
+        if (isDeadlineExpired()) {
+            return refuseAfterDeadline(message);
+        }
+
+        final String credential = basicCredentialFrom(message.getPayload());
+        if (credential == null || validator == null || 
!validator.authenticateBasicCredential(credential)) {
+            LOGGER.warn("Refusing GraphQL WebSocket connection_init without a 
valid credential");
+            sendMessage(GraphQLMessage.create(message.getId())
+                    .type(GraphQLMessage.TYPE_CONNECTION_ERROR)
+                    .errors(Collections.singletonList("Not authenticated"))
+                    .build());
+            closeConnection(message, CLOSE_POLICY_VIOLATION, "Not 
authenticated");
+            return false;
+        }
+
+        synchronized (authenticationLock) {
+            // The deadline may have fired while the credential was being 
checked.
+            if (deadlineExpired) {
+                return refuseAfterDeadline(message);
+            }
+            this.authenticated = true;
+        }
+        cancelAuthenticationDeadline();
+        final Session session = getSession();
+        if (session != null) {
+            // Authenticated: drop the short unauthenticated deadline.
+            session.setIdleTimeout(0);
+        }
+        return true;
+    }
+
+    private boolean isDeadlineExpired() {
+        synchronized (authenticationLock) {
+            return deadlineExpired;
+        }
+    }
+
+    /** The deadline task is already closing this socket; a late 
connection_init must not resurrect it. */
+    private boolean refuseAfterDeadline(GraphQLMessage message) {
+        LOGGER.warn("Refusing GraphQL WebSocket connection_init that arrived 
after the authentication deadline");
+        closeConnection(message, CLOSE_POLICY_VIOLATION, "Not authenticated");
+        return false;
+    }
+
+    /**
+     * Reads a {@code Basic} credential from a connection_init payload. The 
same credential format as the
+     * HTTP path, so both routes are verified identically.
+     */
+    private static String basicCredentialFrom(Map<String, Object> payload) {
+        if (payload == null) {
+            return null;
+        }
+        for (Map.Entry<String, Object> entry : payload.entrySet()) {
+            if (entry.getKey() != null && 
"authorization".equalsIgnoreCase(entry.getKey().trim())
+                    && entry.getValue() instanceof String) {
+                return (String) entry.getValue();
+            }
+        }
+        return null;
+    }
+
     private void closeConnection(GraphQLMessage message, String reason) {
+        closeConnection(message, CLOSE_NORMAL, reason);
+    }
+
+    private void closeConnection(GraphQLMessage message, int statusCode, 
String reason) {
         unsubscribe(message);
-        getSession().close(0, reason);
+        getSession().close(statusCode, reason);
     }
 
     private void sendMessage(GraphQLMessage message) {
@@ -114,6 +277,7 @@ public class SubscriptionWebSocket extends WebSocketAdapter 
{
                 .build();
 
         ExecutionResult executionResult = this.graphQL.execute(executionInput);
+
         if (executionResult.getErrors() != null && 
!executionResult.getErrors().isEmpty()) {
             sendMessage(GraphQLMessage.create(message.getId())
                     .errors(executionResult.getErrors())
@@ -132,7 +296,6 @@ public class SubscriptionWebSocket extends WebSocketAdapter 
{
         Publisher<ExecutionResult> publisher = executionResult.getData();
         ExecutionResultSubscriber subscriber = new 
ExecutionResultSubscriber(message.getId(), getRemote());
         publisher.subscribe(subscriber);
-
         subscriptions.put(message.getId(), subscriber);
     }
 }
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
index c9dde748f..7ab34fda0 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
@@ -19,23 +19,56 @@ package org.apache.unomi.graphql.servlet.websocket;
 
 import graphql.GraphQL;
 import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
 import org.eclipse.jetty.websocket.server.WebSocketServerFactory;
 import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
 import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse;
 
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
+import static org.osgi.service.http.HttpContext.REMOTE_USER;
+
 public class SubscriptionWebSocketFactory extends WebSocketServerFactory {
 
     private final GraphQL graphQL;
 
     private final ServiceManager serviceManager;
 
-    public SubscriptionWebSocketFactory(GraphQL graphQL, ServiceManager 
serviceManager) {
+    private final GraphQLServletSecurityValidator validator;
+
+    /**
+     * Closes sockets that do not authenticate within their deadline. One 
daemon thread for all sockets.
+     * This object is only ever Jetty's creator, never a started lifecycle, so 
the servlet shuts the
+     * scheduler down explicitly from {@code destroy()}.
+     */
+    private final ScheduledExecutorService authenticationDeadlineScheduler;
+
+    public SubscriptionWebSocketFactory(GraphQL graphQL, ServiceManager 
serviceManager,
+                                        GraphQLServletSecurityValidator 
validator) {
         this.graphQL = graphQL;
         this.serviceManager = serviceManager;
+        this.validator = validator;
+        this.authenticationDeadlineScheduler = 
Executors.newSingleThreadScheduledExecutor(runnable -> {
+            Thread thread = new Thread(runnable, 
"graphql-ws-authentication-deadline");
+            thread.setDaemon(true);
+            return thread;
+        });
     }
 
     @Override
     public Object createWebSocket(ServletUpgradeRequest req, 
ServletUpgradeResponse resp) {
-        return new SubscriptionWebSocket(graphQL, serviceManager);
+        // The validator records the remote user on the request when the 
upgrade carried a valid
+        // credential. Its absence is not an error: a browser cannot send 
credentials on the handshake,
+        // so the socket is created unauthenticated and must authenticate 
through connection_init
+        // before it can do anything.
+        boolean authenticatedOnUpgrade = 
req.getHttpServletRequest().getAttribute(REMOTE_USER) != null;
+        return new SubscriptionWebSocket(graphQL, serviceManager, 
authenticatedOnUpgrade, validator,
+                authenticationDeadlineScheduler);
+    }
+
+    /** Stops the deadline scheduler; called when the owning servlet is 
destroyed. */
+    public void shutdown() {
+        authenticationDeadlineScheduler.shutdownNow();
     }
 }
diff --git a/graphql/graphql-ui/src/main/resources/assets/js/index.jsx 
b/graphql/graphql-ui/src/main/resources/assets/js/index.jsx
index 4bbadb3f0..69183253d 100644
--- a/graphql/graphql-ui/src/main/resources/assets/js/index.jsx
+++ b/graphql/graphql-ui/src/main/resources/assets/js/index.jsx
@@ -21,14 +21,38 @@ import * as React from 'react';
 import * as ReactDOM from 'react-dom';
 import {createClient} from 'graphql-ws';
 
+// The browser WebSocket API cannot set request headers on the handshake, so 
the server authenticates a
+// subscription from the connection_init payload instead. GraphiQL hands the 
live "Headers" tab content
+// to the fetcher on every request, so capture it here and reuse its 
Authorization as the WebSocket
+// connection parameters: HTTP and WebSocket then use the same credential, and 
nothing is persisted.
+let latestHeaders = null;
+
+function authorizationHeader() {
+    if (!latestHeaders) {
+        return null;
+    }
+    const key = Object.keys(latestHeaders).find((name) => name.toLowerCase() 
=== 'authorization');
+    return key && latestHeaders[key] ? latestHeaders[key] : null;
+}
+
 function createFetcher() {
-    return createGraphiQLFetcher({
+    const fetcher = createGraphiQLFetcher({
         url: `http://localhost:8181/graphql`,
         wsClient: createClient(
             {
                 url: `ws://localhost:8181/graphql`,
+                // Evaluated on each (re)connect, and sent as the 
connection_init payload.
+                connectionParams: () => {
+                    const authorization = authorizationHeader();
+                    return authorization ? { Authorization: authorization } : 
{};
+                },
             }),
     });
+
+    return (graphQLParams, opts) => {
+        latestHeaders = (opts && opts.headers) || null;
+        return fetcher(graphQLParams, opts);
+    };
 }
 
 function QueryPlayground() {
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index be8f596a8..a316ae29a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -61,6 +61,7 @@ import org.junit.runners.Suite.SuiteClasses;
         GraphQLProfileIT.class,
         GraphQLProfilePropertiesIT.class,
         GraphQLSegmentIT.class,
+        GraphQLServletSecurityIT.class,
         GraphQLWebSocketIT.class,
         JSONSchemaIT.class,
         GraphQLProfileAliasesIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java 
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java
index bcef7ebc0..4a88b765d 100644
--- 
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java
+++ 
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java
@@ -23,6 +23,7 @@ import io.reactivex.ObservableEmitter;
 import io.reactivex.subscribers.DefaultSubscriber;
 import org.eclipse.jetty.websocket.api.RemoteEndpoint;
 import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.UpgradeException;
 import org.eclipse.jetty.websocket.api.WebSocketAdapter;
 import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
 import org.eclipse.jetty.websocket.client.WebSocketClient;
@@ -32,6 +33,10 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.ExecutionException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
@@ -52,9 +57,9 @@ public class GraphQLWebSocketIT extends BaseGraphQLIT {
 
             URI echoUri = new URI("ws://localhost:" + getHttpPort() + 
"/graphql");
             ClientUpgradeRequest request = new ClientUpgradeRequest();
-
+            request.setHeader("Authorization", 
basicAuthHeader(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
             Future<Session> onConnected = client.connect(socket, echoUri, 
request);
-            RemoteEndpoint remote = onConnected.get().getRemote();
+            RemoteEndpoint remote = onConnected.get(10, 
TimeUnit.SECONDS).getRemote();
 
             LOGGER.info("Connected, initializing... ");
 
@@ -75,7 +80,7 @@ public class GraphQLWebSocketIT extends BaseGraphQLIT {
             LOGGER.info("Waiting for socket to close...");
 
             CloseStatus status = socket.waitClose().get(10, TimeUnit.SECONDS);
-            // Assert.assertEquals(1000, (int) status.getStatus()); TODO skip 
for now
+            Assert.assertEquals(1000, (int) status.getStatus());
 
         } finally {
             client.stop();
@@ -83,6 +88,151 @@ public class GraphQLWebSocketIT extends BaseGraphQLIT {
         }
     }
 
+    /**
+     * A handshake carrying no credential is upgraded rather than refused, 
because a browser cannot set
+     * request headers on a WebSocket handshake. The socket that results can 
do nothing at all until it
+     * authenticates through connection_init, which the following tests pin 
down.
+     */
+    @Test
+    public void testWebSocketUpgrade_withoutAuth_upgradesButCannotOperate() 
throws Exception {
+        WebSocketClient client = new WebSocketClient();
+        Socket socket = new Socket();
+        try {
+            client.start();
+            Future<Session> onConnected = client.connect(socket, 
graphqlWebSocketUri(), new ClientUpgradeRequest());
+            RemoteEndpoint remote = onConnected.get(10, 
TimeUnit.SECONDS).getRemote();
+            // Subscribe for the server's refusal message before triggering 
it: the client harness
+            // blocks in onWebSocketText until a listener exists, which would 
otherwise stall the close.
+            Future<String> refusal = socket.waitMessage();
+            
remote.sendString(resourceAsString("graphql/socket/out/start.json"));
+            refusal.get(10, TimeUnit.SECONDS);
+            CloseStatus status = socket.waitClose().get(10, TimeUnit.SECONDS);
+            Assert.assertEquals(1008, (int) status.getStatus());
+        } finally {
+            client.stop();
+        }
+    }
+
+    /** connection_init carrying a valid credential is how a browser client 
authenticates. */
+    @Test
+    public void 
testWebSocketConnectionInit_withValidCredentials_authenticatesSocket() throws 
Exception {
+        WebSocketClient client = new WebSocketClient();
+        Socket socket = new Socket();
+        try {
+            client.start();
+            Future<Session> onConnected = client.connect(socket, 
graphqlWebSocketUri(), new ClientUpgradeRequest());
+            RemoteEndpoint remote = onConnected.get(10, 
TimeUnit.SECONDS).getRemote();
+            
remote.sendString(resourceAsString("graphql/socket/out/init-with-credentials.json")
+                    .replace("__AUTHORIZATION__", 
basicAuthHeader(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)));
+            String initResp = socket.waitMessage().get(10, TimeUnit.SECONDS);
+            
Assert.assertEquals(resourceAsString("graphql/socket/in/ack.json"), initResp);
+            
remote.sendString(resourceAsString("graphql/socket/out/term.json"));
+            CloseStatus status = socket.waitClose().get(10, TimeUnit.SECONDS);
+            Assert.assertEquals(1000, (int) status.getStatus());
+        } finally {
+            client.stop();
+        }
+    }
+
+    /** A wrong credential in connection_init must not authenticate the 
socket. */
+    @Test
+    public void testWebSocketConnectionInit_withBadCredentials_isRefused() 
throws Exception {
+        WebSocketClient client = new WebSocketClient();
+        Socket socket = new Socket();
+        try {
+            client.start();
+            Future<Session> onConnected = client.connect(socket, 
graphqlWebSocketUri(), new ClientUpgradeRequest());
+            RemoteEndpoint remote = onConnected.get(10, 
TimeUnit.SECONDS).getRemote();
+            Future<String> refusal = socket.waitMessage();
+            
remote.sendString(resourceAsString("graphql/socket/out/init-bad-credentials.json"));
+            refusal.get(10, TimeUnit.SECONDS);
+            CloseStatus status = socket.waitClose().get(10, TimeUnit.SECONDS);
+            Assert.assertEquals(1008, (int) status.getStatus());
+        } finally {
+            client.stop();
+        }
+    }
+
+    /**
+     * The unauthenticated-socket deadline is a scheduled close, not an idle 
timeout: keeping the
+     * connection busy with ping frames (which reset an idle timeout) must not 
extend it.
+     */
+    @Test
+    public void 
testWebSocketUpgrade_withoutAuth_isClosedAtDeadlineDespitePings() throws 
Exception {
+        WebSocketClient client = new WebSocketClient();
+        Socket socket = new Socket();
+        try {
+            client.start();
+            Session session = client.connect(socket, graphqlWebSocketUri(), 
new ClientUpgradeRequest()).get(10, TimeUnit.SECONDS);
+            Future<CloseStatus> close = socket.waitClose();
+            long start = System.currentTimeMillis();
+            while (!close.isDone() && System.currentTimeMillis() - start < 
25_000L) {
+                try {
+                    session.getRemote().sendPing(ByteBuffer.allocate(0));
+                } catch (Exception e) {
+                    break; // the server closed the socket under us, which is 
the expected outcome
+                }
+                Thread.sleep(1_000L);
+            }
+            CloseStatus status = close.get(10, TimeUnit.SECONDS);
+            long elapsed = System.currentTimeMillis() - start;
+            Assert.assertTrue("Unauthenticated socket should be closed at its 
deadline, took " + elapsed + " ms", elapsed < 25_000L);
+            Assert.assertEquals(1008, (int) status.getStatus());
+        } finally {
+            client.stop();
+        }
+    }
+
+    @Test
+    public void testWebSocketUpgrade_withWrongJaasPassword_returns401() throws 
Exception {
+        ClientUpgradeRequest request = new ClientUpgradeRequest();
+        request.setHeader("Authorization", 
basicAuthHeader(BASIC_AUTH_USER_NAME, "definitely-not-the-password"));
+        assertWebSocketUpgradeRejected(request, 401);
+    }
+
+    @Test
+    public void testWebSocketUpgrade_withMalformedBasic_returns401() throws 
Exception {
+        ClientUpgradeRequest request = new ClientUpgradeRequest();
+        request.setHeader("Authorization", "Basic !!!");
+        assertWebSocketUpgradeRejected(request, 401);
+    }
+
+    /** A WebSocket handshake bypasses CORS, so a foreign origin is refused 
before anything else. */
+    @Test
+    public void testWebSocketUpgrade_fromForeignOrigin_returns403() throws 
Exception {
+        ClientUpgradeRequest request = new ClientUpgradeRequest();
+        request.setHeader("Origin", "http://attacker.example";);
+        request.setHeader("Authorization", 
basicAuthHeader(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
+        assertWebSocketUpgradeRejected(request, 403);
+    }
+
+    private URI graphqlWebSocketUri() throws Exception {
+        return new URI("ws://localhost:" + getHttpPort() + "/graphql");
+    }
+
+    private void assertWebSocketUpgradeRejected(ClientUpgradeRequest request, 
int expectedStatus) throws Exception {
+        WebSocketClient client = new WebSocketClient();
+        Socket socket = new Socket();
+        try {
+            client.start();
+            Future<Session> onConnected = client.connect(socket, 
graphqlWebSocketUri(), request);
+            try {
+                onConnected.get(10, TimeUnit.SECONDS);
+                Assert.fail("GraphQL WebSocket upgrade should have been 
rejected with " + expectedStatus);
+            } catch (ExecutionException e) {
+                Throwable cause = e.getCause();
+                Assert.assertTrue("Expected UpgradeException, got: " + cause, 
cause instanceof UpgradeException);
+                Assert.assertEquals(expectedStatus, ((UpgradeException) 
cause).getResponseStatusCode());
+            }
+        } finally {
+            client.stop();
+        }
+    }
+
+    private static String basicAuthHeader(String user, String password) {
+        return "Basic " + Base64.getEncoder().encodeToString((user + ":" + 
password).getBytes(StandardCharsets.UTF_8));
+    }
+
     private class Socket extends WebSocketAdapter {
 
         private Flowable<String> publisher;
diff --git 
a/itests/src/test/resources/graphql/socket/out/init-bad-credentials.json 
b/itests/src/test/resources/graphql/socket/out/init-bad-credentials.json
new file mode 100644
index 000000000..01af07218
--- /dev/null
+++ b/itests/src/test/resources/graphql/socket/out/init-bad-credentials.json
@@ -0,0 +1,6 @@
+{
+  "type": "connection_init",
+  "payload": {
+    "Authorization": "Basic bm9ib2R5Ondyb25n"
+  }
+}
diff --git 
a/itests/src/test/resources/graphql/socket/out/init-with-credentials.json 
b/itests/src/test/resources/graphql/socket/out/init-with-credentials.json
new file mode 100644
index 000000000..67e2565b2
--- /dev/null
+++ b/itests/src/test/resources/graphql/socket/out/init-with-credentials.json
@@ -0,0 +1,6 @@
+{
+  "type": "connection_init",
+  "payload": {
+    "Authorization": "__AUTHORIZATION__"
+  }
+}

Reply via email to