VYPR
Low severityNVD Advisory· Published Mar 6, 2026· Updated Mar 9, 2026

Mercurius: queryDepth limit bypassed for WebSocket subscriptions

CVE-2026-30241

Description

Mercurius is a GraphQL adapter for Fastify. Prior to version 16.8.0, Mercurius fails to enforce the configured queryDepth limit on GraphQL subscription queries received over WebSocket connections. The depth check is correctly applied to HTTP queries and mutations, but subscription queries are parsed and executed without invoking the depth validation. This allows a remote client to submit arbitrarily deeply nested subscription queries over WebSocket, bypassing the intended depth restriction. On schemas with recursive types, this can lead to denial of service through exponential data resolution on each subscription event. This issue has been patched in version 16.8.0.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Mercurius 16.7.x and earlier fails to enforce the queryDepth limit on WebSocket subscriptions, allowing denial of service via deeply nested queries.

Root

Cause

Mercurius, a GraphQL adapter for Fastify, enforces a configurable queryDepth limit on HTTP queries and mutations to prevent excessively nested queries. However, in versions prior to 16.8.0, this depth validation is not applied to subscription queries received over WebSocket connections [1][4]. The subscription handling code path lacks the same depth-checking logic, as confirmed by the patched in commit 5b56f60f4b0d60780b0ff499a479bd830bdd6986 [3].

Attack

Surface

A remote attacker can establish a WebSocket connection to a Mercurius server and send a subscription query with arbitrarily deep nesting. No authentication is required beyond the normal WebSocket handshake; the vulnerability is in the server-side validation skip [1][4]. If the GraphQL schema contains recursive types, each level of nesting can trigger exponential data resolution.

Impact

By sending deeply nested subscription queries, an attacker can cause a denial of service (DoS) condition on each subscription event. The server spends excessive resources resolving the deeply nested data, potentially overwhelming the service. This affects both standalone servers and gateways using federation subscriptions [2].

Mitigation

The issue is fixed in Mercurius version 16.8.0 [1][4]. Upgrading is the recommended remediation. As a workaround, operators can disable subscriptions entirely or block WebSocket queries until the patch is applied [4].

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
mercuriusnpm
< 16.8.016.8.0

Affected products

2

Patches

1
5b56f60f4b0d

Merge commit from fork

https://github.com/mercurius-js/mercuriusMatteo CollinaMar 6, 2026via ghsa
5 files changed · +111 3
  • index.js+1 0 modified
    @@ -234,6 +234,7 @@ const mercurius = fp(async function (app, opts) {
           fullWsTransport,
           wsDefaultSubprotocol,
           queueHighWaterMark,
    +      queryDepthLimit,
           additionalRouteOptions: opts.additionalRouteOptions,
           csrfConfig
         })
    
  • lib/routes.js+2 0 modified
    @@ -205,6 +205,7 @@ module.exports = async function (app, opts) {
         fullWsTransport,
         wsDefaultSubprotocol,
         queueHighWaterMark,
    +    queryDepthLimit,
         additionalRouteOptions
       } = opts
     
    @@ -351,6 +352,7 @@ module.exports = async function (app, opts) {
           fullWsTransport,
           wsDefaultSubprotocol,
           queueHighWaterMark,
    +      queryDepthLimit,
           errorFormatter
         })
       } else {
    
  • lib/subscription-connection.js+14 1 modified
    @@ -5,11 +5,12 @@ const { on } = require('events')
     const { subscribe, parse, print, getOperationAST } = require('graphql')
     const { SubscriptionContext } = require('./subscriber')
     const sJSON = require('secure-json-parse')
    -const { MER_ERR_GQL_SUBSCRIPTION_FORBIDDEN, MER_ERR_GQL_SUBSCRIPTION_UNKNOWN_EXTENSION, MER_ERR_GQL_SUBSCRIPTION_INVALID_OPERATION } = require('./errors')
    +const { MER_ERR_GQL_VALIDATION, MER_ERR_GQL_SUBSCRIPTION_FORBIDDEN, MER_ERR_GQL_SUBSCRIPTION_UNKNOWN_EXTENSION, MER_ERR_GQL_SUBSCRIPTION_INVALID_OPERATION } = require('./errors')
     const { preSubscriptionParsingHandler, onSubscriptionResolutionHandler, preSubscriptionExecutionHandler, onSubscriptionEndHandler, onSubscriptionConnectionCloseHandler, onSubscriptionConnectionErrorHandler } = require('./handlers')
     const { kSubscriptionFactory, kLoaders } = require('./symbols')
     const { getProtocolByName } = require('./subscription-protocol')
     const { toGraphQLError } = require('./errors')
    +const queryDepth = require('./queryDepth')
     
     module.exports = class SubscriptionConnection {
       constructor (socket, {
    @@ -24,6 +25,7 @@ module.exports = class SubscriptionConnection {
         fullWsTransport,
         wsDefaultSubprotocol,
         queueHighWaterMark,
    +    queryDepthLimit,
         errorFormatter
       }) {
         this.fastify = fastify
    @@ -41,6 +43,7 @@ module.exports = class SubscriptionConnection {
         this.fullWsTransport = fullWsTransport
         this.wsDefaultSubprotocol = wsDefaultSubprotocol
         this.queueHighWaterMark = queueHighWaterMark
    +    this.queryDepthLimit = queryDepthLimit
         this.errorFormatter = errorFormatter
         this.headers = {}
         this.resolverContext = null
    @@ -279,6 +282,16 @@ module.exports = class SubscriptionConnection {
           await preSubscriptionParsingHandler({ schema, source: query, context, id })
         }
     
    +    if (this.queryDepthLimit) {
    +      const queryDepthErrors = queryDepth(document.definitions, this.queryDepthLimit)
    +
    +      if (queryDepthErrors.length > 0) {
    +        const err = new MER_ERR_GQL_VALIDATION()
    +        err.errors = queryDepthErrors
    +        throw err
    +      }
    +    }
    +
         this.subscriptionContexts.set(id, sc)
     
         // Trigger preSubscriptionExecution hook
    
  • lib/subscription.js+4 2 modified
    @@ -8,7 +8,7 @@ const { isValidClientProtocol } = require('./subscription-protocol')
     
     function createConnectionHandler ({
       subscriber, fastify, onConnect, onDisconnect, entityResolversFactory, subscriptionContextFn, keepAlive,
    -  fullWsTransport, wsDefaultSubprotocol, queueHighWaterMark, errorFormatter
    +  fullWsTransport, wsDefaultSubprotocol, queueHighWaterMark, queryDepthLimit, errorFormatter
     }) {
       return async (socket, request) => {
         if (!isValidClientProtocol(socket.protocol, wsDefaultSubprotocol)) {
    @@ -53,13 +53,14 @@ function createConnectionHandler ({
           fullWsTransport,
           wsDefaultSubprotocol,
           queueHighWaterMark,
    +      queryDepthLimit,
           errorFormatter
         })
       }
     }
     
     module.exports = async function (fastify, opts) {
    -  const { getOptions, subscriber, verifyClient, onConnect, onDisconnect, entityResolversFactory, subscriptionContextFn, keepAlive, fullWsTransport, wsDefaultSubprotocol, queueHighWaterMark, errorFormatter } = opts
    +  const { getOptions, subscriber, verifyClient, onConnect, onDisconnect, entityResolversFactory, subscriptionContextFn, keepAlive, fullWsTransport, wsDefaultSubprotocol, queueHighWaterMark, queryDepthLimit, errorFormatter } = opts
     
       // If `fastify.websocketServer` exists, it means `@fastify/websocket` already registered.
       // Without this check, @fastify/websocket will be registered multiple times and raises FST_ERR_DEC_ALREADY_PRESENT.
    @@ -85,6 +86,7 @@ module.exports = async function (fastify, opts) {
           fullWsTransport,
           wsDefaultSubprotocol,
           queueHighWaterMark,
    +      queryDepthLimit,
           errorFormatter
         })
       })
    
  • test/query-depth.test.js+90 0 modified
    @@ -2,6 +2,8 @@
     
     const { test } = require('node:test')
     const Fastify = require('fastify')
    +const WebSocket = require('ws')
    +const { once } = require('events')
     const GQL = require('..')
     const { MER_ERR_GQL_VALIDATION, MER_ERR_GQL_QUERY_DEPTH } = require('../lib/errors')
     
    @@ -428,3 +430,91 @@ test('queryDepth - ensure query depth is correctly calculated', async (t) => {
         }
       })
     })
    +
    +test('queryDepth - enforce depth limit for subscriptions over websocket', async (t) => {
    +  const app = Fastify()
    +  t.after(() => app.close())
    +
    +  const schema = `
    +    type Human {
    +      name: String!
    +      pet: Dog
    +    }
    +
    +    type Dog {
    +      name: String!
    +      owner: Human
    +    }
    +
    +    type Query {
    +      ping: String
    +    }
    +
    +    type Subscription {
    +      dogUpdated: Dog
    +    }
    +  `
    +
    +  const resolvers = {
    +    Query: {
    +      ping: () => 'pong'
    +    },
    +    Subscription: {
    +      dogUpdated: {
    +        subscribe: async (root, args, ctx) => ctx.pubsub.subscribe('DOG_UPDATED')
    +      }
    +    }
    +  }
    +
    +  app.register(GQL, {
    +    schema,
    +    resolvers,
    +    subscription: true,
    +    queryDepth: 5
    +  })
    +
    +  await app.listen({ port: 0 })
    +
    +  const ws = new WebSocket(`ws://localhost:${app.server.address().port}/graphql`, 'graphql-ws')
    +  t.after(() => ws.close())
    +
    +  await once(ws, 'open')
    +
    +  const waitForMessageType = async (expectedType) => {
    +    while (true) {
    +      const [data] = await once(ws, 'message')
    +      const message = JSON.parse(data.toString())
    +      if (message.type === expectedType) {
    +        return message
    +      }
    +    }
    +  }
    +
    +  ws.send(JSON.stringify({ type: 'connection_init' }))
    +  await waitForMessageType('connection_ack')
    +
    +  ws.send(JSON.stringify({
    +    id: '1',
    +    type: 'start',
    +    payload: {
    +      query: `subscription {
    +        dogUpdated {
    +          owner {
    +            pet {
    +              owner {
    +                pet {
    +                  name
    +                }
    +              }
    +            }
    +          }
    +        }
    +      }`
    +    }
    +  }))
    +
    +  const errorMessage = await waitForMessageType('error')
    +
    +  t.assert.strictEqual(errorMessage.id, '1')
    +  t.assert.match(errorMessage.payload[0].message, /Graphql validation error/)
    +})
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.