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