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__"
+ }
+}