arrow
Back to learn
Development

How to survive a Penetration Test as a GraphQL developer

by Henry Kirkness10 May 2022 6 Min Read

We love 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 prioritised over protection. This is great for an MVP launch but it doesn’t hold up as digital products begin to scale and attract attention.

To avoid this, we make sure we secure the back end of the digital products we build, whilst not impacting the quality or speed for clients.

We recently put that to the test when one of our startup partners was acquired by a larger organisation. They naturally wanted to perform an enterprise-level Penetration Test on the app before dishing out their cash.

So in this post, I cover some of GraphQL-specific security practices we can now say we've 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 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)

1 Access Control

Access Control, also known as Authorisation, 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 and 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 practices and prevent regressions.

2 Anti-Automation

Most applications are under threat from brute-force attacks on passwords, verification codes or any string that provides some sort of authorised 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 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

3 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 is 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 an 'withEmailVerified' directive or you may need to implement it in your data layer.

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

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, try 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