Require authentication for GraphQL WebSocket upgrade (master) - #843
Conversation
Validate credentials before accepting the GraphQL WebSocket upgrade, attach the authenticated subject to the subscription socket, and clear context after subscribe. Add unit and integration coverage.
There was a problem hiding this comment.
Pull request overview
This PR tightens GraphQL subscriptions security by requiring authentication during the WebSocket upgrade, carrying the authenticated Subject into the subscription socket, and clearing thread-local security/execution context after subscription setup. It also expands unit + integration test coverage around the new upgrade-auth behavior.
Changes:
- Enforce authentication during GraphQL WebSocket upgrade and reject unauthenticated upgrades with HTTP 401.
- Bind the authenticated
Subject(andExecutionContext) to the createdSubscriptionWebSocket, and clear thread-local context after handlingGQL_START. - Add unit tests for
validateWebSocketUpgrade(...)and IT coverage for unauthenticated/invalid credential upgrade attempts and private-key success.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java | Adds IT coverage for rejected/accepted WebSocket upgrades and updates existing test to include auth. |
| graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java | Adds unit tests for WebSocket upgrade authentication behavior. |
| graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java | Ensures a WebSocket is only created when a subject is present and passes security context into the socket. |
| graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java | Sets/clears security + execution context around subscription execution and handles missing variables map. |
| graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java | Wires WebSocket upgrade validation before accepting upgrades and passes dependencies into the WebSocket factory. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java:84
- A malformed Basic value (for example
Authorization: Basic !!!) makes the Base64 decoder inisAuthenticatedUserthrowIllegalArgumentException, so this upgrade returns a server error instead of the promised 401 for invalid credentials. Treat decoding failures as authentication failures.
if (isAuthenticatedUser(req)) {
return true;
}
res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
return false;
graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java:130
- The added upgrade tests only send
connection_initandconnection_terminate, so they never exercise this new subject/context binding or its cleanup. Add a start/subscription test whose resolver requires the authenticated tenant (and verify the thread-local context is cleared afterward), otherwise the core authorization behavior can regress while all new tests still pass.
securityService.setCurrentSubject(subject);
executionContextManager.setCurrentContext(executionContext);
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Authenticate before acceptWebSocket, avoid HTTP fallthrough on failed accept, treat malformed Basic as 401, and cover the reported upgrade and subscription scenarios with unit and IT tests.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java:185
- This negotiates protocols the socket does not implement.
graphql-transport-wsis accepted by this prefix check (and explicitly expected by the new test), butSubscriptionWebSocketonly handles the legacystart/stop/datamessages; transport-ws clients sendsubscribe/nextinstead. Such a connection upgrades successfully and then silently ignores subscriptions. Advertise onlygraphql-ws, or implement the transport-ws message state machine and align the UI/tests.
for (String part : headerValue.split(",")) {
String subProtocol = part.trim();
if (subProtocol.startsWith("graphql")) {
response.addHeader("Sec-WebSocket-Protocol", subProtocol);
itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java:179
- This assertion can pass before the server has processed
start, so it does not prove that subscription setup succeeded or that the captured security context works. It also never triggers an event, which is when GraphQL resolves the subscription selection set. Replace the sleep with a deterministic event emission and await/assert the resulting WebSocket data message before stopping the subscription.
remote.sendString(resourceAsString("graphql/socket/out/start.json"));
// Successful subscribe() registers a publisher and does not emit until events arrive.
// Give the server a moment; an auth/context failure would close the socket with an error.
Thread.sleep(500);
Assert.assertFalse("Subscription start should not close the socket", closeFuture.isDone());
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java:179
- This can pass before the server has processed the
startframe: after a fixed 500 ms delay, an open socket proves neither thatsubscribe()ran nor that the captured subject/context was accepted. That leaves the core authenticated-subscription regression untested and makes the result timing-dependent. Trigger a matching event and await/assert its subscription data (or add another deterministic server acknowledgement) before stopping the subscription.
remote.sendString(resourceAsString("graphql/socket/out/start.json"));
// Successful subscribe() registers a publisher and does not emit until events arrive.
// Give the server a moment; an auth/context failure would close the socket with an error.
Thread.sleep(500);
Assert.assertFalse("Subscription start should not close the socket", closeFuture.isDone());
…ivery Two gaps remained on the WebSocket path. A browser cannot set request headers on a WebSocket handshake, so requiring an Authorization header there made subscriptions unreachable from the shipped GraphQL UI. A handshake that carries no credential is now upgraded in an unauthenticated state instead of being refused, and the socket acts on nothing until it authenticates: connection_init is the only message it will process, any other message closes the socket, an unauthenticated socket is closed if credentials do not arrive promptly, and the credential is verified through the same path and in the same format as the header route. Credentials on the handshake remain the preferred route and are still mandatory for clients able to send them. A WebSocket handshake is subject to neither the same-origin policy nor CORS preflight, so handshakes declaring a foreign origin are now refused, and the message log no longer records payloads. Separately, a subscription's selection set is executed once per emitted event, on the thread that produced the event, after the registering thread's identity has been unbound. It therefore ran under whatever identity that shared producer thread happened to hold. The subscription's own identity is now captured when the stream is created and bound around each delivery, then unbound so it cannot be inherited by later work on that thread. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A handshake without credentials is now upgraded rather than refused, so the two tests that asserted a 401 there assert the property that replaced it: such a socket is closed when it tries to start an operation before authenticating. Adds coverage for authenticating through connection_init and for a bad credential being refused. Also registers GraphQLServletSecurityIT with the suites. It was listed in none, so despite existing it had never run; note that this makes its existing tests execute for the first time. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two defects the integration tests caught that the unit tests could not. The security validator was constructed after super.init(), but WebSocketServlet.init() calls configure() - which captures the validator into the socket factory - during super.init(). Every socket therefore held a null validator, so a connection_init carrying a valid credential was rejected as unauthenticated. Construct the validator before super.init(). Refused sockets were closed with WebSocket status code 0, which is not a valid code and produces no client-visible close frame, so a refused client saw the connection linger until the idle timeout instead of closing promptly. Close with valid codes: 1008 (policy violation) for an authentication refusal, 1000 for normal termination. The socket unit tests asserted close(anyInt(), ...), which is what let the invalid code pass; they now require the specific valid code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client test helper blocks in onWebSocketText until a test has registered a message listener, so when the server sends its refusal message before the test subscribes, the following close frame is not processed and the wait for close times out. The pre-authentication refusal responds immediately, which loses that race. Subscribe for the refusal message before triggering it, then wait for the message and the close. This is test-only; the server already closed the socket correctly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shipped GraphiQL playground opened its subscription WebSocket with only a URL, so it presented no credential over a handshake that authenticates from the connection_init payload, leaving subscriptions unusable from the browser. The browser WebSocket API cannot set request headers on the upgrade, so reuse the Authorization the operator enters in GraphiQL's Headers tab instead: GraphiQL hands that editor's live content to the fetcher on every request, so capture it there and return it as the graphql-ws connection parameters, which are sent in the connection_init payload. HTTP and WebSocket then authenticate with the same credential, and it stays in memory rather than being written to browser storage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5ef9ce2 to
5ca094d
Compare
The unauthenticated-socket deadline was implemented as a Jetty idle timeout, which is reset by any received frame, including ping/pong control frames that never reach the message handler, so it only held for a silent client. Schedule an explicit close at the deadline instead, cancelled when the socket authenticates or closes. A single-thread scheduler owned by the factory runs it and is stopped with the factory on undeploy. Authentication and expiry are made mutually exclusive under a lock, so a connection_init that arrives after the deadline cannot resurrect an expired socket. The upgrade Javadoc now describes both the header-authenticated and the connection_init-authenticated paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Summary
Test plan
GraphQLServletSecurityValidatorTestWebSocket upgrade casesGraphQLWebSocketITno-auth / public-key / wrong-password / private-key cases