0.3.0 β€’ Published 25 days ago

@graphile/depth-limit v0.3.0

Weekly downloads
-
License
MIT
Repository
-
Last release
25 days ago

@graphile/depth-limit

A highly capable depth limiting GraphQL.js validation rule to help protect your server from malicious operations.

  • Limits depth of field selections in an operation
  • Limits number of times a list may be nested in an operation
  • Limits number of times the same field coordinate may be (directly or indirectly) nested
  • Has separate limits for introspection queries versus regular operations
  • Can add limits for specific fields to say how many times they may be nested (e.g. allow "friends of friends" but deny "friends of friends of friends" via {'User.friends': 2})
  • Can be configured such that each fragment reference also increments the depth (not recommended)

FAQ

Can I trust this module?

You're right to check! This module is maintained by Benjie Gillam, a member of the GraphQL Technical Steering Committee.

The module has no dependencies (though it does have a "peer" dependency of graphql, of course!), so auditing should be straightforward.

The module is MIT licensed, so as it says in the license: THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Is this module Graphile-specific?

No! This rule should work for any server that allows customizing GraphQL.js validation rules. It's not specific to PostGraphile, Grafast, Grafserv or any other Graphile software.

Do I need this module?

GraphQL is a very powerful technology, but it must be handled carefully - it's relatively easy for a bad actor to write a small GraphQL query that causes the server to perform a disporportionaly large amount of work. For this reason, it's highly recommended that you only accept trusted documents to your GraphQL server - like Facebook do internally.

If your GraphQL API is intended to be consumed by untrusted (or not fully trusted) third parties then you cannot rely on the security benefits of the Trusted Documents pattern, and thus you need to protect yourself from malicious queries. This rule is one very easy to use piece of protection (though you should note that it is not sufficient on its own, you should still use all the common patterns of protecting your API: rate limiting, circuit breakers, execution timeouts, enforcing pagination limits, body size limits, etc etc).

Do I need this if I am using Trusted Documents (aka persisted queries)?

Even if you are using the Trusted Documents (persisted queries, stored operations, etc) pattern (which you should be if your API is only intended to be consumed by your own websites, mobile apps and other first-party software), this is still a useful validation rule.

Though Trusted Documents means that the concern of malicious operations is moot (every document is trusted, so none are expected to be malicious), this rule still helps to prevent your engineers from writing queries that are too complex and could potentially be too costly for your server. It's reasonable, when using Trusted Documents, to increase the limits as needed.

Usage

Install the module with your package manager of choice:

yarn add @graphile/depth-limit

How you use it then depends on which server software you're using.

graphql-http

Add depthLimit() to the validationRules list passed to the createHandler call. Here's an example based on the "With http" usage example in the graphql-http docs:

import http from "http";
import { createHandler } from "graphql-http/lib/use/http";
import { depthLimit } from "@graphile/depth-limit";
import { schema } from "./schema.js";

// Create the GraphQL over HTTP handler
export const handler = createHandler({
  schema,
  validationRules: [
    depthLimit({
      // Options here
    }),
  ],
});

Envelop

If you're using the excellent envelop plugin system for GraphQL you can use our handy useDepthLimit export to build an envelop plugin to add the validation rule:

import * as GraphQLJS from "graphql";
import { envelop, useEngine } from "@envelop/core";
import { useDepthLimit } from "@graphile/depth-limit";

const getEnveloped = envelop({
  plugins: [
    useEngine(GraphQLJS),
    useDepthLimit({
      // Options here
    }),
  ],
});

Yoga

With GraphQL Yoga, you'll follow a similar setup as with Envelop above; see the documentation on Yoga plugins.

import { createYoga } from "graphql-yoga";
import { useDepthLimit } from "@graphile/depth-limit";

const yoga = createYoga({
  plugins: [
    useDepthLimit({
      // Options here
    }),
  ],
});

Raw GraphQL.js

If you're using the graphql module from npm directly, import the depthLimit factory function and use its result alongside the specifiedRules (the rules from the GraphQL specification) when validating a GraphQL operation:

import { depthLimit } from "@graphile/depth-limit";
import { validate, specifiedRules, parse, execute } from "graphql";

const allValidationRules = [
  // ❗ Don't forget to use the built in GraphQL validation rules!
  ...specifiedRules,

  //πŸ‘‡πŸ‘‡πŸ‘‡πŸ‘‡
  depthLimit({
    // Options here
  }),
  //πŸ‘†πŸ‘†πŸ‘†πŸ‘†
];

// This is just an example (and assumes the schema has already been validated).
// The `validate()` call is the part that would differ from what you would
// normally have; in particular the optional third argument is specified.
export function executeGraphQLRequest(
  schema: GraphQLSchema,
  source: string,
  variableValues?: Record<string, any>,
) {
  let document;
  try {
    document = parse(source);
  } catch (syntaxError) {
    return {
      errors: [syntaxError],
    };
  }

  //                                        πŸ‘‡πŸ‘‡πŸ‘‡πŸ‘‡πŸ‘‡πŸ‘‡πŸ‘‡πŸ‘‡πŸ‘‡
  const errors = validate(schema, document, allValidationRules);
  //                                        πŸ‘†πŸ‘†πŸ‘†πŸ‘†πŸ‘†πŸ‘†πŸ‘†πŸ‘†πŸ‘†

  if (errors.length > 0) {
    return { errors };
  }

  return execute({ schema, document, variableValues });
}

Options

No matter how you load this validation rule, it accepts the same options:

export type Options = {
  /**
   * How many selection sets deep may the user query?
   *
   * @defaultValue `12`
   */
  maxDepth?: number;

  /**
   * How many nested lists deep may the user query?
   *
   * @defaultValue `4`
   */
  maxListDepth?: number;

  /**
   * How many times may a field reference itself recursively by default. This
   * is to try and block the most common forms of attack automatically, if you
   * have legitimate cases where a field should be referenced recursively then
   * you may specifically override those via `maxDepthByFieldCoordinates`.
   *
   * @defaultValue `2`
   */
  maxSelfReferentialDepth?: number;

  /**
   * How many selection sets deep may the user query in introspection?
   *
   * @defaultValue `12`
   */
  maxIntrospectionDepth?: number;

  /**
   * How many nested lists deep may the user query in introspection?
   *
   * @defaultValue `3`
   */
  maxIntrospectionListDepth?: number;

  /**
   * How many times may an introspection field reference itself recursively by
   * default. This is to try and block the most common forms of attack
   * automatically, if you have legitimate cases where a field should be
   * referenced recursively then you may specifically override those via
   * `maxDepthByFieldCoordinates`.
   *
   * @defaultValue `2`
   */
  maxIntrospectionSelfReferentialDepth?: number;

  /**
   * Set `true` if you want fragment spreads to add to the depth. Not
   * recommended; fragments are essential to using GraphQL correctly so using
   * them should not have a penalty.
   *
   * Whether this setting is true or false, the fields referenced by the
   * fragment will still of course weigh into the depth calculations.
   *
   * @defaultValue `false`
   */
  fragmentsAddToDepth?: boolean;

  /**
   * Limits the number of times a particular field coordinate can be nested
   * inside itself; for example:
   *
   * ```
   * maxDepthByFieldCoordinates: {
   *   'User.friends': 2,
   * }
   * ```
   *
   * Would allow you to load a users friends, or their friends of friends, but
   * not their friends of friends of friends.
   */
  maxDepthByFieldCoordinates?: Record<string, number>;

  /**
   * If true, informs the user what the issues are. Setting this to true could
   * have security ramifications as it will make it easier for an attacker to
   * determine your limits (however, this could be derived with minimal effort
   * also, so is potentially not a big deal).
   *
   * @defaultValue `false`
   */
  revealDetails?: boolean;
};

How to choose the right options for you

Firstly, we've set the introspection defaults to allow the most common introspection queries to pass validation, so you shouldn't need to customize those unless you're doing something fancy.

The main options you should look at customizing are maxListDepth, maxDepth and maxSelfReferentialDepth (in that order).

If you're starting from scratch you should set your settings low, and work your way up as you need to. If you have an existing server then it may make sense to track your queries for a while and figure out the higest values used (see the countOperationDepths function below), and set your limits to that to prevent more complex queries.

Ultimately, tuning these parameters is more of an art than a science, and this is one reason why this validation rule isn't a built in feature of the graphql module or, indeed, the specification.

maxListDepth

maxListDepth you should set to the lowest value you can tolerate; something like 2, 3 or 4. It's unlikely that your users are successfully paginating through collections that are deeper than this. Note: maxListDepth only counts selection sets inside of lists, so scalar lists are ignored and do not add to the count, this is by design.

maxDepth

maxDepth should also be set to a suitably low value, although it's much more acceptable to grow this one. One of the common introspection queries requires a depth of 12 (primarily due to ofType { ofType { ofType { ... } } } for determining the type of a field or argument), so this is the default we use even for non-introspection queries; lower would be better, but higher is also fine if you need it. Note that if your schema uses the Relay Cursor Connection pattern it may end up requiring deeper limits than you might realise.

maxSelfReferentialDepth

maxSelfReferentialDepth defaults to just 2 and prevents attackers from exploiting cycles in your graph (e.g. { friends { friends { friends { ... } } } }). We recommend that you keep this limit low, and then add specific overrides via maxDepthByFieldCoordinates if a particular field is expected to commonly be used in a nested fashion.

maxDepthByFieldCoordinates

If you have a particular cycle in your schema (for example you might have User.friends which returns [User], and thus you could query it as { currentUser { friends { friends { friends { friends { ... } } } } } }) and you would like to prevent people nesting it too many times (or, alternatively, would like to allow them to nest it more than maxSelfReferentialDepth times) then you can set specific limits on the number of times a particular field may be referenced recursively by specifying a numeric limit for its schema coordinate, for example:

import { depthLimit } from "@graphile/depth-limit";

export const rule = depthLimit({
  maxSelfReferentialDepth: 2,
  maxDepthByFieldCoordinates: {
    // Allow `{ currentUser { friends { friends { friends { name }  } } } }`
    // But forbid `{ currentUser { friends { friends { friends { friends { name } } } } } }`
    "User.friends": 3,
  },
});

Community-funded open-source software

I love GraphQL (hence the name: Graphile!) and I am a firm believer in the benefits of open source software; but I don't have time to write awesome software like this and go out and earn a days wages. If you use this validation rule, please consider sponsoring me* so I can keep maintaining and building this kind of critical software, and maybe I'll even get a chance to work through my mountain of GraphQL spec PRs. Thanks for considering sponsoring me, there's no way this project would exist without the support of my sponsors.

* Other sponsorship methods are available, if you're interested drop me an email on my GitHub handle at graphile.com.

countOperationDepths

This function returns the depths seen in a given document to help you to figure out what good values to set for your options are. Most options are ignored here, the main (only?) one that impacts the result of depths is fragmentsAddToDepth.

Here you can provide both the schema SDL (or schema object) and the document source text. If you have already parsed the document then you may wish to use countDepths instead as it is more performant.

import { countOperationDepths } from "@graphile/depth-limit";

const schema = /* GraphQL */ `
  type Query {
    currentUser: User
  }
  type User {
    name: String
    friends: [User!]!
  }
  type Mutation {
    addFriend(id: ID!): AddFriendPayload
  }
  type AddFriendPayload {
    query: Query
  }
`;

const source = /* GraphQL */ `
  query FriendsOfFriends {
    ...FoF
  }
  mutation AddFriend($id: ID!) {
    addFriend(id: $id) {
      query {
        ...FoF
      }
    }
  }
  fragment FoF on Query {
    currentUser {
      name
      friends {
        name
        friends {
          name
        }
      }
    }
  }
`;

const depthsByOperationName = countOperationDepths(schema, source);

import assert from "assert";
assert.deepEqual(depthsByOperationName, {
  FriendsOfFriends: {
    $$depth: 3,
    $$listDepth: 2,
    "Query.currentUser": 1,
    "User.name": 1,
    "User.friends": 2,
  },
  AddFriend: {
    $$depth: 5,
    $$listDepth: 2,
    "Mutation.addFriend": 1,
    "AddFriendPayload.query": 1,
    "Query.currentUser": 1,
    "User.name": 1,
    "User.friends": 2,
  },
});

countDepth

This function returns the depths seen in a given operation to help you to figure out what good values to set for your options are. Most options are ignored here, the main (only?) one that impacts the result of depths is fragmentsAddToDepth.

import assert from "assert";
import { countDepth } from "@graphile/depth-limit";
import { parse, Kind, getOperationAST } from "graphql";
import { schema } from "./schema.js";

const query = /* GraphQL */ `
  query FriendsOfFriends {
    currentUser {
      name
      friends {
        name
        friends {
          name
        }
      }
    }
  }
`;

const document = parse(query);
const operationName = undefined;
const fragments = document.definitions.filter(
  /** @type {(d: any) => d is import('graphql').FragmentDefinitionNode} */
  (d) => d.kind === Kind.FRAGMENT_DEFINITION,
);
const operation = getOperationAST(document, operationName);
const { depths, resolvedOptions } = countDepth(schema, operation, fragments, {
  // Options here
});
assert.deepEqual(depths, {
  $$depth: 3,
  $$listDepth: 2,
  "Query.currentUser": 1,
  "User.name": 1,
  "User.friends": 2,
});

Caveats

We count fields on their current named type whilst traversing the document; this type could be an object, interface, or union type. Therefore, a field that exists on an interface would be counted as a different coordinate depending on if the user accesses it via the interface directly or one of the implementations.

See also

This isn't the only depth limiting validation rule, others to check out are: