Secure APIs with GraphQL Armor

15 Aug 2022

Security is a popular topic when it comes to building GraphQL APIs. Quite often you'll be faced with questions around securing it from an authentication side, but because of the way GraphQL works, there are a bunch of new issues you've probably not faced before.

GraphQL Armor aims to help combat some of the lesser-known areas of GraphQL security. These include:

  • Disabling field suggestions
  • Query depth limiting
  • Query cost limits
  • Alias limits
  • Character limits
  • Directive limits
  • Batched and stacktraces

GraphQL Armor works with Apollo Server, GraphQL Yoga, Envelop, as well as a bunch of other engines.

Here we have a GraphQL Yoga server that returns some posts, and their related posts.

import { createServer } from "@graphql-yoga/node";

const posts = [
  {
    id: "graphql",
    title: "Learn GraphQL with GraphQL.wtf",
  },
];

const server = createServer({
  schema: {
    typeDefs: /* GraphQL */ `
      type Query {
        posts: [Post]
        post(id: ID!): Post
      }
      type Post {
        id: ID!
        title: String!
        related: [Post]
      }
    `,
    resolvers: {
      Query: {
        posts: () => posts,
        post: (_, args) => posts.find((post) => post.id === args.id),
      },
      Post: {
        related: () => posts,
      },
    },
  },
  plugins: [],
  maskedErrors: false,
});

server.start();

Let's now install GraphQL Armor, and secure our API from malicious requests.

npm install -E @escape.tech/graphql-armor

Since Yoga is the best way to use the Envelop plugin system, we can use EnvelopArmor from GraphQL Armor, and pass it Yoga.

Let's import, and instantiate a new EnvelopArmor:

import { EnvelopArmor } from "@escape.tech/graphql-armor";

const armor = new EnvelopArmor({
  // Configuration
});

We'll then call .protect() on our armor instance, and fetch plugins from it:

const { plugins } = armor.protect();

Now all that's left to do is pass this to the Yoga server as plugins to enable Armor:

const server = createServer({
  // ...
  plugins,
});

That's it! You now have a GraphQL Yoga server running with some sensible secure defaults thanks to Armor.

But before we call it a day, let's explore what some of this means, and how it helps protect against malicious operations.

Block field suggestions

It can seem quite harmless but if you're like me, and disable introspection in production, GraphQL will still leak your schema by suggesting the field it thought you meant to fetch.

You've probably seen an error like this before:

{
  "data": null,
  "errors": [
    {
      "message": "Cannot query field \"titl\" on type \"Post\". Did you mean \"title\"?",
      "locations": [
        {
          "line": 3,
          "column": 5
        }
      ],
      "extensions": {}
    }
  ]
}

GraphQL Armor out of the box will disable these suggestions. You'll get the following error with Armor:

{
  "data": null,
  "errors": [
    {
      "message": "Cannot query field \"titl\" on type \"Post\". [Suggestion message hidden by GraphQLArmor]?",
      "locations": [
        {
          "line": 3,
          "column": 5
        }
      ],
      "extensions": {}
    }
  ]
}

You can disable this feature by passing the configuration to EnvelopArmor:

const armor = new EnvelopArmor({
  blockFieldSuggestion: {
    enabled: false,
  },
});

Query depth limiting

We've covered this before in episode 14 but GraphQL Armor enables this by default.

You can of course configure this just like we did with field suggestions and pass a custom maxDepth number:

const armor = new EnvelopArmor({
  maxDepth: {
    enabled: true,
    n: 5,
  },
});

Now if you try to make a query deep enough... You'll get an error!

{
  "data": null,
  "errors": [
    {
      "message": "Request is too deep."
    }
  ]
}

Query cost limits

GraphQL Armor by default will apply a simple cost analysis algorith to your GraphQL operation. This is to prevent expensive requests that could overload your server.

You can configure everything from the max cost per operation to the individual costs per object, scalar, or query depth.

const armor = new EnvelopArmor({
  costLimit: {
    enabled: true,
    maxCost: 5000,
    objectCost: 2,
    scalarCost: 1,
    depthCostFactor: 1.5,
    ignoreIntrospection: true,
  },
});

If you break the rules, you'll get an error:

{
  "data": null,
  "errors": [
    {
      "message": "Query is too complex."
    }
  ]
}

Alias limits

Another way you can overload your server is by aliasing operations to your GraphQL server.

You can limit the number of aliases that can be used with Armor:

const armor = new EnvelopArmor({
  maxAliases: {
    enabled: true,
    n: 5,
  },
});

If you break the rules, you'll get an error:

{
  "data": null,
  "errors": [
    {
      "message": "Too many aliases."
    }
  ]
}

Character limits

Another great way to prevent malicious requests is by limiting the number of characters that can be sent to your server:

const armor = new EnvelopArmor({
  characterLimit: {
    enabled: true,
    maxLength: 1000,
  },
});

If you break the rules and send anything over 1000 characters, you'll get an error:

{
  "data": null,
  "errors": [
    {
      "message": "Query is too large."
    }
  ]
}

Directive limits

Some APIs provide @directives to skip, limit, stream, defer, or even make calls to other APIs. These directives can be expensive and cause strain on your server.

You can limit the number of directives that can be used with Armor:

const armor = new EnvelopArmor({
  maxDirectives: {
    enabled: true,
    n: 1,
  },
});

If you break the rules, you'll get an error:

{
  "data": null,
  "errors": [
    {
      "message": "Too many directives."
    }
  ]
}

Batched requests, and stacktraces

If you're using Apollo Server you can protect against batched queries, and stacktraces. These are enabled by default.

To opt-out of these, you'll need to tell Apollo Server explicitly to use these features:

import { ApolloArmor } from "@escape.tech/graphql-armor";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  ...armor.protect(),
  debug: true, // Ignore Armor's recommendation
  allowBatchedHttpRequests: true, // Ignore Armor's recommendation
});

So there we have it! A first look at protecting our GraphQL APIs with some sensible defaults provided by GraphQL Armor.