Track vulnerabilities like this one. Sign up free to get alerted when software you use is affected.
3.7

CVE-2026-107727: Strawberry GraphQL-WS may block new subscriptions

CVE-2026-107727 · published 3 days ago
Summary

If you use Strawberry's older GraphQL‑WS protocol with a limit on how many subscriptions can run on one WebSocket connection, the system can incorrectly count completed subscriptions as still active. This can cause legitimate new subscriptions to be rejected with a "subscription limit reached" message. To avoid the problem, either switch to the newer protocol or remove the subscription limit setting until the issue is fixed.

What to do
  • Update strawberry-graphql to version 0.327.2.
Affected software
Ecosystem VendorProductAffected versions
– strawberry-graphql strawberry >= 0.312.3, < 0.327.2
pip – strawberry-graphql >= 0.312.3, < 0.327.2
Fix: upgrade to 0.327.2
Original advisory text
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 `max_subscriptions_per_connection`, 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 `max_subscriptions_per_connection` 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/graphql_ws/handlers.py](https://github.com/strawberry-graphql/strawberry/blob/c3caabd1188acda62045e7fd9ba9e37ac430cf96/strawberry/subscriptions/protocols/graphql_ws/handlers.py)`](https://github.com/strawberry-graphql/strawberry/blob/c3caabd1188acda62045e7fd9ba9e37ac430cf96/strawberry/subscriptions/protocols/graphql_ws/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.max_subscriptions_per_connection is not None
and len(self.tasks) >= self.max_subscriptions_per_connection
):
await self.send_message(
ErrorMessage(
type="error",
id=operation_id,
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, `handle_async_results()` sends a completion message:

```python
async for result in result_source:
await self.send_data_message(result, operation_id)

await self.send_message(
CompleteMessage(type="complete", id=operation_id)
)
```

The natural completion path does not call `cleanup_operation()` before returning. The deletion of the stored operation state is implemented separately:

```python
async def cleanup_operation(self, operation_id: str) -> None:
if operation_id in self.subscriptions:
await self.subscriptions[operation_id].aclose()
del self.subscriptions[operation_id]

self.tasks[operation_id].cancel()
await self.tasks[operation_id]
del self.tasks[operation_id]
```

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 `max_subscriptions_per_connection` 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(
SECRET_KEY="local-validation-only",
CHANNEL_LAYERS={
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
}
},
)

import strawberry
from channels.testing import WebsocketCommunicator

from strawberry.channels.handlers.ws_handler import GraphQLWSConsumer
from strawberry.schema import Schema
from strawberry.subscriptions import GRAPHQL_WS_PROTOCOL


@strawberry.type
class Query:
@strawberry.field
def ping(self) -> str:
return "pong"


@strawberry.type
class Subscription:
@strawberry.subscription
async def one_shot(self) -> str:
yield "marker"


schema = Schema(query=Query, subscription=Subscription)

application = GraphQLWSConsumer.as_asgi(
schema=schema,
subscription_protocols=(GRAPHQL_WS_PROTOCOL,),
max_subscriptions_per_connection=2,
)


async def receive_until_complete(communicator, operation_id):
messages = []
for _ in range(3):
message = await asyncio.wait_for(
communicator.receive_json_from(), timeout=2
)
messages.append(message)
if (
message.get("type") == "complete"
and message.get("id") == operation_id
):
return messages
raise AssertionError(
f"no complete message for {operation_id}: {messages}"
)


async def main() -> None:
communicator = WebsocketCommunicator(
application,
"/graphql",
subprotocols=[GRAPHQL_WS_PROTOCOL],
)
connected, accepted_protocol = await communicator.connect()
assert connected
assert accepted_protocol == GRAPHQL_WS_PROTOCOL

try:
await communicator.send_json_to({"type": "connection_init"})
ack = await communicator.receive_json_from()
assert ack["type"] == "connection_ack"

query = "subscription { oneShot }"

await communicator.send_json_to(
{
"type": "start",
"id": "one",
"payload": {"query": query},
}
)
first_messages = await receive_until_complete(
communicator, "one"
)

await communicator.send_json_to(
{
"type": "start",
"id": "two",
"payload": {"query": query},
}
)
second_messages = await receive_until_complete(
communicator, "two"
)

# Allow completed handler tasks to finish their sends before the
# third operation is started.
await asyncio.sleep(0)

await communicator.send_json_to(
{
"type": "start",
"id": "three",
"payload": {"query": query},
}
)
third_message = await asyncio.wait_for(
communicator.receive_json_from(), timeout=2
)

print(
{
"first": first_messages,
"second": second_messages,
"third": third_message,
}
)
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 `max_subscriptions_per_connection` 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 `max_subscriptions_per_connection`;
- 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 `max_subscriptions_per_connection` 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.
Fix within
Internet-facing 60 days
Internal At next upgrade
  • Not known to be exploited
  • Needs hands-on effort to exploit
  • Gives an attacker partial control
Severity
3.7 Low
Exploitation
<1% chance of attack within 30 days
Type
CWE-400Uncontrolled Resource Consumption
Timeline
Published15 Oct 2026
Updated15 Oct 2026
First seen8 Oct 2026
Track software like this
Free during beta