arrow
Back to learn
Development

How to survive a Penetration Test as a GraphQL developer

by Henry Kirkness10 March 2021 6 Min Read

Some of our favorite clients are startups — working directly with founders who want to build beautiful, user-centric apps on tight timelines and often tighter budgets. Development in this environment can mean that time on features is prioritized over protection which is great for an MVP launch but doesn’t hold up as products begin to scale and attract attention. We experienced this first hand at a previous startup that we worked at, and have carried that lesson with us into our current client work. We‘ve been evolving our process to ensure we secure our backends over the past couple of years, whilst not impacting our quality or speed for clients. Recently we were lucky enough to have that put to the test by one of our clients. The company was acquired by a larger organization who naturally wanted to perform an enterprise level Penetration Test on the app before dishing out their cash.

In this post, I outline a few GraphQL specific security practices that we can now say have been battle-tested. It is by no means an exhaustive list but includes some helpful recipes for securing your API. For a more in-depth reference, you should refer to OWASP

Although most of these points are relevant to any tech stack (or should be translatable), the tech-stack in question and the one we most commonly use is:

  • NodeJS

  • GraphQL

  • Postgres

  • React/React Native

This post assumes knowledge of directives in GraphQL. However, if you’ve not seen them before it should hopefully still be readable!

Apollo is a good place to learn about them.

Access Control

Access Control, also known as Authorization, is how your application permits access to resources in your app. Security 101, right, but in anything bigger than a tiny hobby app, it's easy to leave holes in the bucket.

GraphQL makes it simple and elegant to manage access to resources. Suppose the following:

1
type Query {
2
getMyData: Data @authenticate somePublicData: String
3
}
4
5
type Mutation {
6
updateAnItem(id: ID!, name: String!): Item @authenticate
7
}

We authenticate each private field with a custom directive and leave the public fields directive-free. The danger is that we stop here, call the system ‘secure’ and release. The likelihood is that, without being security conscious, you’ve gone an implemented the updateAnItem resolver like this:

1
const updateAnItem = (root, args) => {
2
return Item.update({ name: args.name }).where({ id: args.id });
3
};

Although you have authenticated generic access to updateAnItem, you haven’t checked that this user should be allowed to update this item. Granular authentication must happen at the application logic level, which makes it very hard to keep track of as your application and team grows. The corrected resolver could look like:

1
const updateAnItem = (root, args, ctx) => {
2
return Item.update({ name: args.name }).where({ id: args.id, userId: ctx.userId });
3
};

Keep an eye out, educate your team to check for them during code-reviews and write automated tests to help guide best practice and prevent regressions.

Anti-Automation

Most applications are under threat from brute-force attacks on passwords, verification codes or any string that provides some-sort of authorized access to your system. All it takes is a hacker to execute a script that iterates through likely combinations until it strikes gold.

The first step to at least partially cover you would be to add error tracking and alerts to your system. We use Sentry, but there are no doubt others that do a similar job. Log every error that is thrown and set an alert for when it occurs at a frequent rate.

However, if you don’t want to be woken to a trillion alerts from Sentry, then I’d also recommend adding rate-limiting to your vulnerable fields. We’ve built a small directive library for controlling this, it’s as easy as:

1
import { createRateLimitDirective } from 'graphql-rate-limit';
2
3
const server = new ApolloServer({
4
typeDefs: gql`
5
type Mutation {
6
# Limit to 10 per minute
7
login(email: String!, password: String!): String! @rateLimit(max: 10, window: 60000)
8
}
9
`,
10
resolvers: {
11
Mutation: {
12
login: () => { /* ... */ }
13
}
14
},
15
schemaDirectives: {
16
rateLimit: createRateLimitDirective({
17
identifyContext: ctx => ctx.req.ip
18
})
19
}
20
});

More info here: https://github.com/teamplanes/graphql-rate-limit

Server-Side Validation Checks

It can be easy to forget that your UI is not the only interface to your system with the ability to make updates and interact with your business logic, 🤦🏻‍♂️. A side-effect of this could be that validation checks are baked into the client, and not the server. An example being email verification, perhaps the user should be able to log in whilst their email is not verified yet, but they shouldn’t be able to update their profile or create some data.

Validation checks should be built into your Access-Control, not your UI. So perhaps you can abstract it out with a withEmailVerified directive or you may need to implement in your data layer.

1
type Mutation {
2
login(email String!, password: String!): String
3
updateProfile: Profile @withEmailVerified
4
}

Verbose Error Messaging

A silly mistake, but an easy one to clear up. In development, it’s handy to deliver the full error message and stack trace to the client, but doing this in production could give a hacker enough understanding of the inner workings of your system to exploit it.

Take the following, you have a simple query that accepts an ID (a string) and the resolver of getItemById passes the ID into a Postgres query.

1
# Shema
2
type Query {
3
getItemById(id: ID!): Item
4
}
5
6
# Query made by client
7
query myQuery {
8
getItemById(id: "*") {
9
id
10
}
11
}

You’d end up with an error that could look a little like:

select from \”items\” where \”id\” = $1 — invalid input syntax for integer: \” \”

Although this particular message doesn’t give us much other than telling us that there is a table ‘items’ and it has a column named ‘id’. You can easily see how if we had a few other tables referenced, you could build up a picture of the database structure. An attacker could follow a similar process to piece together business logic structure or class implementations.

Most GraphQL server libraries allow you to mask or reformat error messages, with Apollo Server you’d end up with:

1
new ApolloServer({
2
typeDefs,
3
resolvers,
4
formatError: error => new Error('Internal server error')
5
});

Summary

Although this is certainly in-exhaustive, hopefully, these points help you in starting on a journey to securing your GraphQL API. Securing your API is never ‘done’, I’d strongly recommend taking a browse through OWASP, a good place to start is with the OWASP Node Goat project’s Top 10.

For further reading on GraphQL specific security concepts, Max Stoiber’s post on Securing Your GraphQL API from Malicious Queries.

I'm the techy-co-founder at Planes. I love working with our developers and clients to solve technical challenges, whether that's through hands-on coding or coaching and support.
Henry
 
Kirkness
henry@planes.agency
Copied to clipboard!
We think you might like
Get more fresh ideas like this in your inbox
Get more fresh ideas like this in your inbox
Pages loaded on average in 1.0s, emitting
~0.46g of CO2
.
homehome
Let's shake things up
For clients
CJ Daniel-Nield
Co-Founder
cj@planes.agency
For careers
Sophie Aspden
People Lead
sophie@planes.agency
Everything else
Say hello
Drop us a line
hey@planes.agency