Keep parts of your GraphQL Introspection Query hidden

Keep parts of your GraphQL Introspection Query hidden

Using directives and plugins in Apollo Server 4 to customize the introspection query output

Β·

13 min read

Once you have created your first schema and your GraphQL server is up and running, it makes sense to implement precautions to prevent it from being compromised by bad actors. In the context of Hashnode, our GraphQL API serves our website and blogs. This article will explore excluding fields from an introspection request without disabling the server's discoverability feature by completely turning off the introspection queries.

The Result of Hiding Parts of the Schema on Introspection

The diagram below shows the final result of using a directive and an Apollo Server plugin to hide aspects from the incoming introspection request.

We have a schema defined where multiple fields are annotated with @hidden. In the returned resultset of the Introspection query, we can see that all those fields are missing.

Let's explore how we implemented it!

Taking a Look at GraphQL Schema Introspection

GraphQL is a strongly typed query language. The schema, exposed from a GQL API, acts as a contract between the frontend and backend and defines the available operations. The server knows about the schema, as it's the place of definition. But how does the frontend get to know about the schema?

This is where one of the key features of GraphQL comes in hand: Schema introspection. Offering the introspection of a schema allows the clients to explore and learn about what's possible in a given API in terms of queries, mutations, subscriptions, fields, and types.

There are a couple of keywords to request schema introspections. A double underscore precedes all of them: __. A simple introspection query for all available types could look like this:

query {
  __schema {
    types {
      name
    }
  }
}

Sounds great, right? It is, you get a kind of self-documentation for free. But what if you don't want to scream out your whole schema to the world? Due to the nature of GQL and the contract a schema poses, everything in your GraphQL schema will be visible to the world.

How can we hide certain aspects of our schema?

  • Shutting down the Introspection Query is a certain way to keep things private. This might be an option in a private API, but why would we hide the schema there? Turning off the introspection query in a public API would cause all our consumers to lose the ability to discover the schema, which is certainly not what we want. Additionally, automatic code generation is a common use case that relies on introspection.

  • Removing certain fields from the introspection is another approach that may be more complex to manage but does not break every third-party consumer.

Let's explore how we can solve this problem by using some native GQL features as well as a specialty of Apollo Server: directives and plugins 🎬

Directives in Graphql Can Apply Custom Logic

A directive in GraphQL is a decorator for parts of a schema or operation. An @ character always precedes it. The most common and build-in directive is the @depreacted decorator that indicates the deprecation for a field:

type ExampleType {
  oldField: String @deprecated(reason: "Use `newField`.")
  newField: String
}

This shows two things:

  1. A directive can take an argument, e.g., reason.

  2. A directive will always be placed after declaring a field or operation.

There are multiple valid locations for a directive. For example, a directive declared with ARGUMENT_DEFINITION can be placed on an argument but not somewhere else within the schema.

type ExampleType {
  oldField: String @argumentOnlyDirective # would throw an error 
  newField: String
}

Overall, directives are neet enhancements for GQL that allow the execution of custom logic as appropriate. Things like authentication or authorization can be built easily with a directive.

For further reading, check out the Apollo Server docs on directives.

Formalizing the @hidden Directive

In our case, we want a directive that can be used in different places within our schema to hide things from the Introspection output. The simplest form of our directive can look something like this:

  directive @hidden on OBJECT | FIELD_DEFINITION

We want to be able to hide any OBJECTor FIELD_DEFINITION. This allows removing of various things from the schema. This can be enhanced to your own needs by adding additional valid locations.

type Example {
 fieldA: String!
 fieldB: String! @hidden
} 

type Query {
 getExampleOne: Example!
 getExampleHidden: Example! @hidden
} 

type Mutation {
 mutateOnExampleHidden(input: String!): Example! @hidden
}

Considering the above schema definition, our @hidden directive should be able to transform the schema once an introspection is requested by removing all the fields from the resultset, which are declared with the directive. The visible schema for a requesting consumer should look like this:

type Example {
 fieldA: String!
} 

type Query {
 getExampleOne: Example!
}

Coding this out should be rather straightforward. If we find the annotation @hidden, we can remove the declared fields from the schema; otherwise, we return the original type.

const directive = getDirective(...);

if (directive) {
 return null;
} else {
 return type;
}

Why Does the Directive Alone Not Work?

Excellent, we now have a definition of our new directive and an Idea of how we want the output to be transformed. Once implemented, we can observe a weird behavior: It works on introspection but will also remove fields from the actual GraphQL results sets on incoming requests 🀯

Why is that?

Retake a look at how we defined the directive from a custom logic point of view and consider when directives are applied.

In Apollo Server, a directive is applied by transforming the schema. So, applying the directive on server startup with the above code would remove the annotated fields from inspection and the whole schema. This is not what we want. The context provided to the transform function does not indicate the query type. Furthermore, introspection queries are handled a little differently by the Apollo Server.

How can we still achieve our goal of hiding things from the introspecting only?
➑️ Apollo plugins to the rescue πŸš€

Plugins in Apollo Server Allow You to Hook Into the Request Life Cycle

Apollo allows you to implement server plugins to perform custom operations in response to specific events. A plugin is a JavaScript object that implements one or more functions responding to events. Within a plugin are two major event categories: server lifecycle events and request lifecycle events. In our case, we are interested in Request lifecycle events, as we want to respond to a certain event within the execution of an operation, e.g. a Request to our API.

Taking the above flow of the request lifecycle into account, we can see an option to hook into the returned result before the actual execution starts, in that we write a plugin that will provide its result within the responseForOperation hook.

Taking a look into the definition of the hook, we can also confirm this above observation

The responseForOperation event is fired immediately before GraphQL execution would take place. If its return value resolves to a non-null GraphQLResponse, that result is used instead of executing the query. Hooks from different plugins are invoked in series, and the first non-null response is used.

In this hook, we have various information at our disposal to decide if we want the normal resolver flow to be invoked or if we want to do a kind of early exit without resolving the given fields:

responseForOperation?(
 requestContext:WithRequired<GraphQLRequestContext<TContext>,
 'source' | 'queryHash' | 'document' | 'operationName' |'operation'
): Promise<GraphQLResponse | null>;

Within the requestContxt we find the information about which query was sent to the server and the operation name, and we get our hands on the whole schema that will be used for executing the request.

Let's use this to combine our custom directive with a custom plugin that will hide aspects of our schema from introspection requests.

An Implementation Tells More Than 1000 Words!

Overall, we have now discovered a couple of things we can make use of:

  • Our directive will transform a schema and remove everything annotated with @hidden

  • We can hook into the request execution lifecycle by providing a custom plugin

  • responseForOperation that allows us to provide a different result and skip the normal resolver execution for a request

  • Within the requestContext we have all the information, which is also available for the normal execution of the request, to generate our result for a request

The main idea is to hook into the request execution and check if we are receiving an introspection of the schema. With the information provided by responseForOperation and the option to directly provided a result here, we can skip the actual execution and provide a different result. This is where we can apply our hidden directive to transform the schema and remove everything annotated with @hidden. We will now use this updated schema only if we receive an introspection for further execution of the request.

Let's take a look at the implementation of the plugin and directive!

The @hidden Directive

The directive consists of the code snippets from above and some more checks. The result of the function we are defining here is a transformer that will change the schema. It takes a GraphQL schema as input and will return one.

import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import { gql } from 'graphql-tag';
import { GraphQLSchema } from 'graphql';

type DirectiveTransformer = (schema: GraphQLSchema) => GraphQLSchema;


export const hiddenDirectiveTypeDefs = gql`
  directive @hidden on OBJECT | FIELD_DEFINITION
`;
/**
 * This directive transformer removes all types and fields that are marked with the @hidden directive from introspection queries only
 */
export const hiddenDirectiveTransformer: DirectiveTransformer = (schema) => {
  const directiveName = 'hidden';

  return mapSchema(schema, {
    [MapperKind.TYPE]: (type) => {
      const directive = getDirective(schema, type, directiveName)?.[0];
      if (directive) {
        return null;
      } else {
        return type;
      }
    },
    [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
      const directive = getDirective(schema, fieldConfig, directiveName)?.[0];
      if (directive) {
        return null;
      } else {
        return fieldConfig;
      }
    }
  });
};

The above code will take the schema and find all occurrences of hidden. Then, we check if it's defined in the right place and apply our custom logic to it. The logic will either remove the fieldConfig or the type from the schema if the directive was found. Otherwise, it will return the original definition and not change the schema.

The Apollo Server Plugin

Our server plugin will now use requestDidStart lifecycle and the responseForOperation event. We need a way to identify if we are receiving an introspection query. For this, let's add a simple regex to check if the query contains at least one of the introspection keywords.

const REGEX_INTROSPECTION_QUERY = /\b(__schema|__type|__typename)\b/;

We can now identify if we receive an introspection request to our schema or if it's a normal operation without any introspection parts.

So, let's bring everything together and write our custom plugin to apply our directive once we receive a schema introspection:

First, we want to check if the plugin is being executed in the production environment in developmentwe want full access to the schema and the playground. By returning nullwe can tell Apollo that we don't want to provide our own result for the specific query, and the execution flow should be run normally.

// only on production
if (!isProd) return null;

Secondly, we check if the query is an actual introspection request by checking the operation name (playgrounds often send a named query called IntrospectionQuery to the sever) or if it includes any reserved keywords that are part of the schema introspection.

const isIntrospectionQuery =
 ctx.request.operationName === 'IntrospectionQuery' ||
 ctx.request.query?.includes('IntrospectionQuery');

const hasIntrospectionParts =
 isIntrospectionQuery ||
 REGEX_INTROSPECTION_QUERY.test(ctx.request.query || '');

If we determine this to be true, we take the original schema and pass it to the hiddenDirectiveTransformer, which will clean our schema from everything we don't want to include within the introspection result.

const schema = hiddenDirectiveTransformer(ctx?.schema);
const result = await execute({
 schema: schema,
 document,
 contextValue: ctx.contextValue,
 variableValues: request.variables,
 operationName: request.operationName
});

The last step is executing the actual introspection request by calling the execute function provided by the graphql package.

Now we can create the response to the request by copying everything from the initial request and overriding the body:

const response: GraphQLResponse = {
 ...ctx.response,
 body: {
  kind: 'single',
  singleResult: {
   ...result
  }
 }
};

And that's it. Piercing every part together, our plugin now looks like this:

type PluginDefinition = ApolloServerPlugin<Context>;

export const introspectionPlugin: (isProd: boolean) => PluginDefinition = (
  isProd
) => ({
  async requestDidStart() {
    return {
      // This event is fired immediately before GraphQL execution 
      //takes place
      async responseForOperation(ctx) {
        // only on production
        if (!isProd) return null;

        const isIntrospectionQuery =
          ctx.request.operationName === 'IntrospectionQuery' ||
          ctx.request.query?.includes('IntrospectionQuery');

        const hasIntrospectionParts =
          isIntrospectionQuery ||
          REGEX_INTROSPECTION_QUERY.test(ctx.request.query || '');
        // If it's an introspection query, we need to apply the hidden 
        // directive transformer ourself
        // otherwise, let Apollo handle the request by returning null
        if (hasIntrospectionParts) {
          const { request, document } = ctx;

          // APPLY @hidden
          const schema = hiddenDirectiveTransformer(ctx?.schema);
          // Executing the request 
          const result = await execute({
            schema: schema,
            document,
            contextValue: ctx.contextValue,
            variableValues: request.variables,
            operationName: request.operationName
          });

          // Setting the result
          const response: GraphQLResponse = {
            ...ctx.response,
            body: {
              kind: 'single',
              singleResult: {
                ...result
              }
            }
          };
          return response;
        }
        return null;
      }
    };
  }
});

Now we can apply our directive type definitions to our base schema and add our plugin to the Apollo Server - let's see how it works out πŸ‘€

Let’s Test It Out πŸš€

To check if everything works as expected, we will again use our simple schema to verify our implementation.

type Example {
 fieldA: String!
 fieldB: String! @hidden
} 

type Query {
 getExampleOne: Example!
 getExampleHidden: Example! @hidden
} 

type Mutation {
 mutateOnExampleHidden(input: String!): Example! @hidden
}

Let's request a simple introspection query that checks for all fields on query and mutation as well as specifically requesting the Example type from our schema.

query {
  __schema {
    queryType {
      name
      fields {
        name
      }
    }
    mutationType {
      name
      fields {
        name
      }
    }
  }
  __type(name: "Example") {
    fields {
      name
    }
  }
}

Running this in our development environment will return the following result:

All the fields are there, even though some are annotated with @hidden as we are not running in production.

Now repeat this in our production environment, and we see the following result:

Yeah πŸŽ‰ We have successfully omitted the fields we don't want to be part of a public introspection request to our server πŸͺ„

Takeaways

Now let's revisit what we implemented here and what implications we have.

First, this is, by no means, a safeguard for your server. Even if we omit certain fields and types from the introspection response, they are still part of the schema and will be available normally. Rather see it as making it a little harder and not as obvious for your consumers that some private things exist in your schema. Overall, the best way of thinking is always that everybody can see everything in GraphQL, and you should design your schema and security following this thought.

Secondly, you must ensure that any reference to other definitions you have made must also be hidden. Take a look at the following code snippet:

type ThisIsHidden @hidden {
fieldOther: String!
}

type Example {
 fieldA: String!
 fieldB: String!
 fieldC: ThisIsHidden!
} 

type Query {
 getExampleOne: Example!
 getExampleHidden: Example! @hidden
} 

type Mutation {
 mutateOnExampleHidden(input: String!): Example! @hidden
}

If we now make an introspection query, this will fail, as we have removed the definition of ThisIsHidden from our schema but still referring to it in the Example type. Some third-party tools may break due to the missing reference. An easy fix is to add: fieldC: ThisIsHidden! @hidden. Still, you need to take care of this yourself.

Always double-check if you are not breaking other tools that are relying on your schema by carelessly using the hidden directive πŸ™Œ

Alright, that's it about hiding things from Introspection queries to your Server - I hope you enjoyed the read πŸ‘‹