Repository: mercurius-js/mercurius
Pull request #1050 (2023-11-08 22:05:13)

feat: support passing options to graphql validate and parse
Support passing options to GraphQL's `validate` and `parse` functions.

Reasoning to allow configuration of these functions topics is fine tuning to prevent DDOS attacks.

This allows developers to set:
1. `validate`'s options of `maxErrors` to something more/less than the 100 default. This prevents an attacker from specifying 100 invalid fields, requiring CPU time generating validation errors.
2. `parse`'s options of `maxTokens` to a value. It has no default. This prevent an attacker from producing a overly high complexity query that requires a high amount of CPU time.

markrzen: @mcollina Can we get this on a reviewers queue? I wouldn't usually poke maintainers like this, but previous PRs seem to be getting reviewed within a few days. I wanted to make sure this didn't fall through the cracks.
markrzen: @simoneb Any chance this is something you can review?

Patch:
diff --git a/docs/api/options.md b/docs/api/options.md
index c30f948..5bc4d45 100644
--- a/docs/api/options.md
+++ b/docs/api/options.md
@@ -34,6 +34,9 @@
 - `loaders`: Object. See [defineLoaders](#appgraphqlextendschemaschema-appgraphqldefineresolversresolvers-and-appgraphqldefineloadersloaders) for more
   details.
 - `schemaTransforms`: Array of schema-transformation functions. Accept a schema as an argument and return a schema.
+- `graphql`: Object. Override options for graphql function that Mercurius utilizes.
+  - `parseOptions`: Object. [GraphQL's parse function options](https://github.com/graphql/graphql-js/blob/main/src/language/parser.ts)
+  - `validateOptions`: Object. [GraphQL's validate function options](https://github.com/graphql/graphql-js/blob/main/src/validation/validate.ts)
 - `graphiql`: boolean | string | Object. Serve
   [GraphiQL](https://www.npmjs.com/package/graphiql) on `/graphiql` if `true` or `'graphiql'`. Leave empty or `false` to disable.
   _only applies if `onlyPersisted` option is not `true`_
diff --git a/index.d.ts b/index.d.ts
index 2be6c4d..0826a4a 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -13,6 +13,7 @@ import {
   GraphQLScalarType,
   ValidationRule,
   FormattedExecutionResult,
+  ParseOptions,
 } from "graphql";
 import { SocketStream } from "@fastify/websocket"
 import { IncomingMessage, IncomingHttpHeaders, OutgoingHttpHeaders } from "http";
@@ -356,6 +357,15 @@ export interface MercuriusGraphiQLOptions {
   }>
 }
 
+export interface GrapQLValidateOptions {
+  maxErrors?: number;
+}
+
+export interface MercuriusGraphQLOptions {
+  parseOptions?: ParseOptions,
+  validateOptions?: GrapQLValidateOptions
+}
+
 export interface MercuriusCommonOptions {
   /**
    * Serve GraphiQL on /graphiql if true or 'graphiql' and if routes is true
@@ -413,6 +423,10 @@ export interface MercuriusCommonOptions {
    * The maximum depth allowed for a single query.
    */
   queryDepth?: number;
+  /**
+   * GraphQL function optional option overrides
+   */
+  graphql?: MercuriusGraphQLOptions,
   context?: (
     request: FastifyRequest,
     reply: FastifyReply
diff --git a/index.js b/index.js
index 7788fb1..00ea743 100644
--- a/index.js
+++ b/index.js
@@ -78,6 +78,10 @@ const plugin = fp(async function (app, opts) {
   const queryDepthLimit = opts.queryDepth
   const errorFormatter = typeof opts.errorFormatter === 'function' ? opts.errorFormatter : defaultErrorFormatter
 
+  opts.graphql = opts.graphql || {}
+  const gqlParseOpts = opts.graphql.parseOptions || {}
+  const gqlValidateOpts = opts.graphql.validateOptions || {}
+
   if (opts.persistedQueries) {
     if (opts.onlyPersisted) {
       opts.persistedQueryProvider = persistedQueryDefaults.preparedOnly(opts.persistedQueries)
@@ -246,7 +250,7 @@ const plugin = fp(async function (app, opts) {
 
   fastifyGraphQl.extendSchema = fastifyGraphQl.extendSchema || function (s) {
     if (typeof s === 'string') {
-      s = parse(s)
+      s = parse(s, gqlParseOpts)
     } else if (!s || typeof s !== 'object') {
       throw new MER_ERR_INVALID_OPTS('Must provide valid Document AST')
     }
@@ -428,9 +432,14 @@ const plugin = fp(async function (app, opts) {
       }
 
       try {
-        document = parse(source)
+        document = parse(source, gqlParseOpts)
       } catch (syntaxError) {
         try {
+          // Do not try to JSON.parse maxToken exceeded validation errors
+          if (gqlParseOpts.maxTokens && syntaxError.message === `Syntax Error: Document contains more that ${gqlParseOpts.maxTokens} tokens. Parsing aborted.`) {
+            throw syntaxError
+          }
+
           // Try to parse the source as ast
           document = JSON.parse(source)
         } catch {
@@ -454,7 +463,7 @@ const plugin = fp(async function (app, opts) {
           validationRules = opts.validationRules({ source, variables, operationName })
         }
       }
-      const validationErrors = validate(fastifyGraphQl.schema, document, [...specifiedRules, ...validationRules])
+      const validationErrors = validate(fastifyGraphQl.schema, document, [...specifiedRules, ...validationRules], gqlValidateOpts)
 
       if (validationErrors.length > 0) {
         if (lruErrors) {


Test patch:
diff --git a/test/graphql-option-override.js b/test/graphql-option-override.js
new file mode 100644
index 0000000..2923a55
--- /dev/null
+++ b/test/graphql-option-override.js
@@ -0,0 +1,142 @@
+'use strict'
+
+const { test } = require('tap')
+const Fastify = require('fastify')
+const mercurius = require('..')
+
+const schema = `
+type User {
+  name: String!
+  password: String!
+}
+
+type Query {
+  read: [User]
+}
+`
+
+const resolvers = {
+  Query: {
+    read: async (_, obj) => {
+      return [
+        {
+          name: 'foo',
+          password: 'bar'
+        }
+      ]
+    }
+  }
+}
+
+const query = `{
+  read {
+    name
+    password
+  }
+}`
+
+const query2 = `{
+  read {
+    intentionallyUnknownField1
+    intentionallyUnknownField2
+    intentionallyUnknownField3
+  }
+}`
+
+test('do not override graphql function options', async t => {
+  const app = Fastify()
+  t.teardown(() => app.close())
+
+  await app.register(mercurius, {
+    schema,
+    resolvers
+  })
+
+  await app.ready()
+
+  const res = await app.graphql(query)
+
+  const expectedResult = {
+    data: {
+      read: [{
+        name: 'foo',
+        password: 'bar'
+      }]
+    }
+  }
+
+  t.same(res, expectedResult)
+})
+
+test('override graphql.parse options', async t => {
+  const app = Fastify()
+  t.teardown(() => app.close())
+
+  await app.register(mercurius, {
+    schema,
+    resolvers,
+    graphql: {
+      parseOptions: {
+        maxTokens: 1
+      }
+    }
+  })
+
+  await app.ready()
+
+  const expectedErr = {
+    errors: [{
+      message: 'Syntax Error: Document contains more that 1 tokens. Parsing aborted.'
+    }]
+  }
+
+  await t.rejects(app.graphql(query), expectedErr)
+})
+
+test('do not override graphql.validate options', async t => {
+  const app = Fastify()
+  t.teardown(() => app.close())
+
+  await app.register(mercurius, {
+    schema,
+    resolvers
+  })
+
+  await app.ready()
+
+  const expectedErr = {
+    errors: [
+      { message: 'Cannot query field "intentionallyUnknownField1" on type "User".' },
+      { message: 'Cannot query field "intentionallyUnknownField2" on type "User".' },
+      { message: 'Cannot query field "intentionallyUnknownField3" on type "User".' }
+    ]
+  }
+
+  await t.rejects(app.graphql(query2), expectedErr)
+})
+
+test('override graphql.validate options', async t => {
+  const app = Fastify()
+  t.teardown(() => app.close())
+
+  await app.register(mercurius, {
+    schema,
+    resolvers,
+    graphql: {
+      validateOptions: {
+        maxErrors: 1
+      }
+    }
+  })
+
+  await app.ready()
+
+  const expectedErr = {
+    errors: [
+      { message: 'Cannot query field "intentionallyUnknownField1" on type "User".' },
+      { message: 'Too many validation errors, error limit reached. Validation aborted.' }
+    ]
+  }
+
+  await t.rejects(app.graphql(query2), expectedErr)
+})