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

sergehuber pushed a commit to branch unomi-3.0.x
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to refs/heads/unomi-3.0.x by this push:
     new 8a4658108 Require authentication for GraphQL WebSocket upgrade 
(unomi-3.0.x) (#844)
8a4658108 is described below

commit 8a46581084f9a2a90ba6672c83b8cd59187c8566
Author: Serge Huber <[email protected]>
AuthorDate: Wed Sep 30 10:45:29 2026 +0200

    Require authentication for GraphQL WebSocket upgrade (unomi-3.0.x) (#844)
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../unomi/graphql/servlet/GraphQLServlet.java      | 111 +++++++++++--
 .../auth/GraphQLServletSecurityValidator.java      |  54 +++++-
 .../servlet/websocket/SubscriptionWebSocket.java   | 179 +++++++++++++++++++-
 .../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   | 181 ++++++++++++++++++++-
 .../graphql/socket/out/init-bad-credentials.json   |   6 +
 .../graphql/socket/out/init-with-credentials.json  |   6 +
 9 files changed, 572 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..717bf4cb7 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,58 @@ 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;
+        }
+        // A 401 carries a challenge whether the header was missing or its 
credential was refused.
+        res.addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
+        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) {
+        // The scheme token is case-insensitive (RFC 7235).
+        if (authHeader == null || !authHeader.regionMatches(true, 0, "Basic ", 
0, 6)) {
+            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 +181,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..84697cb1d 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,133 @@ 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;
+
+    /** The session's configured idle timeout, shortened while unauthenticated 
and restored on authentication. */
+    private volatile long configuredIdleTimeout;
+
+    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) {
+            configuredIdleTimeout = sess.getIdleTimeout();
+            // 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 +173,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: back to the idle timeout the session was 
configured with, not "none".
+            session.setIdleTimeout(configuredIdleTimeout);
+        }
+        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 +281,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 +300,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 454680c8d..6c3d68e81 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -63,6 +63,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..a08e13c10 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,176 @@ 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();
+        }
+    }
+
+    /** The HTTP authentication scheme token is case-insensitive. */
+    @Test
+    public void testWebSocketUpgrade_withLowercaseBasicScheme_succeeds() 
throws Exception {
+        WebSocketClient client = new WebSocketClient();
+        Socket socket = new Socket();
+        try {
+            client.start();
+            ClientUpgradeRequest request = new ClientUpgradeRequest();
+            request.setHeader("Authorization", "basic " + 
Base64.getEncoder().encodeToString(
+                    (BASIC_AUTH_USER_NAME + ":" + 
BASIC_AUTH_PASSWORD).getBytes(StandardCharsets.UTF_8)));
+            RemoteEndpoint remote = client.connect(socket, 
graphqlWebSocketUri(), request).get(10, TimeUnit.SECONDS).getRemote();
+            
remote.sendString(resourceAsString("graphql/socket/out/init.json"));
+            
Assert.assertEquals(resourceAsString("graphql/socket/in/ack.json"), 
socket.waitMessage().get(10, TimeUnit.SECONDS));
+            
remote.sendString(resourceAsString("graphql/socket/out/term.json"));
+            Assert.assertEquals(1000, (int) socket.waitClose().get(10, 
TimeUnit.SECONDS).getStatus());
+        } finally {
+            client.stop();
+        }
+    }
+
+    @Test
+    public void testWebSocketUpgrade_withWrongJaasPassword_returns401() throws 
Exception {
+        assertWebSocketUpgradeRejected(basicAuthHeader(BASIC_AUTH_USER_NAME, 
"definitely-not-the-password"), null, 401);
+    }
+
+    @Test
+    public void testWebSocketUpgrade_withMalformedBasic_returns401() throws 
Exception {
+        assertWebSocketUpgradeRejected("Basic !!!", null, 401);
+    }
+
+    /** A WebSocket handshake bypasses CORS, so a foreign origin is refused 
before anything else. */
+    @Test
+    public void testWebSocketUpgrade_fromForeignOrigin_returns403() throws 
Exception {
+        assertWebSocketUpgradeRejected(basicAuthHeader(BASIC_AUTH_USER_NAME, 
BASIC_AUTH_PASSWORD), "http://attacker.example";, 403);
+    }
+
+    private URI graphqlWebSocketUri() throws Exception {
+        return new URI("ws://localhost:" + getHttpPort() + "/graphql");
+    }
+
+    /**
+     * Jetty's websocket-client types are kept out of method signatures on 
purpose: JUnit resolves
+     * signature types when it scans the class, before {@code @Before} has 
waited for the container,
+     * and that bundle is not necessarily wired yet at that point.
+     */
+    private void assertWebSocketUpgradeRejected(String authorization, String 
origin, int expectedStatus) throws Exception {
+        WebSocketClient client = new WebSocketClient();
+        Socket socket = new Socket();
+        try {
+            client.start();
+            ClientUpgradeRequest request = new ClientUpgradeRequest();
+            if (authorization != null) {
+                request.setHeader("Authorization", authorization);
+            }
+            if (origin != null) {
+                request.setHeader("Origin", origin);
+            }
+            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