Error messages in GraphQL are rarely considered as a security matter... yet they 100% should!
The default error messages are way too verbose. They could disclose information about your internal infrastructure such as what database you use, or worse, leak your secret keys.
Error messages give direct feedback for hackers to run a fuzzy search (finding patterns through trial and error) on your API until they find vulnerabilities.
For example, if the error message contains information about the database schema and the query used to retrieve user information, a hacker could figure out a way to perform a SQL injection and get the information of EVERY user in your database, bypassing any access control 🤯
Handling error messages properly is one of the most essential step of building a secure GraphQL API (see 9 GraphQL security best practices).
Let's see how GraphQL errors work, how to mask verbose messages but also how to create your own custom errors by leveraging the flexible GraphQL spec.
This was originally posted on blog.escape.tech
How errors work in GraphQL
GraphQL errors are fundamentally different from REST errors. You no longer rely on status codes and status texts.
According to the latest spec, the response of a GraphQL endpoint should always contain either the data
field or the errors
field, and in some cases, both:
As you can see above, an error object has the following fields:
- message: the error message - if you throw an error in the resolver, its message will appear here
- locations: contains the line and column (starting from 1) of the syntax element that is concerned with the error (in the example above, this is the "a" of author. The column is 5 for the indent but would be 1 if we remove the indent in the query)
- path: the path of the response field which experienced the error (firstPost → author).
Additionally, you can add the extensions
field which is a map containing any custom field you see fit:
Note: it is recommended to use the extensions field for custom fields even though no error will be thrown when doing otherwise.
The code field of the extensions is usually implemented by the framework. For example, here are the default codes in Apollo:
-
GRAPHQL_PARSE_FAILED
(SyntaxError) -
GRAPHQL_VALIDATION_FAILED
(ValidationError) -
BAD_USER_INPUT
(UserInputError) -
UNAUTHENTICATED
(AuthenticationError) -
FORBIDDEN
(ForbiddenError) -
PERSISTED_QUERY_NOT_FOUND
(PersistedQueryNotFoundError) -
PERSISTED_QUERY_NOT_SUPPORTED
(PersistedQueryNotSupportedError) -
INTERNAL_SERVER_ERROR
(None)
We’ll see below how you can define your own custom error at the end.
How to mask sensitive information and stack traces
Back to the initial problem: not giving up sensitive information to consumers of your API.
The errors in GraphQL are very comprehensive: they tell you what broke, where it broke both in the query and in your code. That is great for developer experience, but clearly not intended for the end-user.
Information like the stacktrace - the path tracing all the way back to the breaking point in your code - are usually found in the logs, not in the error message.
The solution to catch these default errors before they get sent to the end-user is to use a custom formatError
function in your GraphQL server (framework agnostic), to:
- log the error internally for debugging,
- update the error that you don’t want to expose to clients in production,
- send a user-friendly error.
// api.js
formatError: (err) => {
// 1. Log the error for internal debugging
logger(err, req, Date.now())
// 2. Update the error message
if (err.message.includes('Database Error')) {
err.message = 'Internal server error'
}
// 3. Return the error message
return {
...err
stack: process.env.NODE_ENV === 'production' ? undefined : err.stack
}
}
Cool, now our API is not going to betray us.
But we can do more.
Let’s see how we can leverage the GraphQL spec we saw above to create our own custom error.
How to write custom errors
There are three key components we can play with to build custom errors:
- the message: giving more context to the user, eg. "password should have at least 6 characters",
-
the extensions code: an optional field, but a good practice in API error design to easily classify the different types of errors in the frontend (eg. with
⚠️ WARNING
,❌ ERROR
orℹ️ INFO
callouts) - any other extensions field: this is where you can get creative and add any extra context, eg. the field name that cause an error in the case of a form submission.
Let's run through an example with real code!
The first step is to extend the GraphQLError
class to create a custom error:
import {GraphQLError, GraphQLErrorExtensions} from 'graphql'
class InputValidationError extends GraphQLError {
extensions: GraphQLErrorExtensions;
constructor(message: string, field: string) {
super(message);
this.extensions = {
statusCode: 500,
code: "INPUT_VALIDATION_FAILED",
field,
};
}
}
⚠️ The latest spec highly recommend putting any custom fields under extensions
.
Now you can throw the error where necessary:
post: (_parent:null, {id}: QueryPostArgs, context: Context) => {
if(id >= context.db.posts.length) {
throw new InputValidationError("Index out of range", "id")
}
// Fetch and return the corresponding post
}
Finally, make sure to return the extensions
field if you use a custom formatError
function:
formatError: (err) => {
return {
message: err.message,
extensions: err.extensions, // this is already returned by default
stack: process.env.NODE_ENV === 'production' ? undefined : err.stack
}
}
And voila!
Alternatively, most GraphQL frameworks have a GraphQLError wrapper that lets you throw custom errors on the fly without writing new ones.
import {ApolloError} from 'apollo-server-errors
throw new ApolloError('Index out of range', 'INPUT_VALIDATION_FAILED', {field: 'id'})
GraphQL Security
Error messages are just one of many GraphQL vulnerabilities. If you need an in-depth scan running dozens of security tests on your GraphQL endpoints, you should try out Escape!
Top comments (0)