Skip to content
This repository was archived by the owner on Oct 11, 2022. It is now read-only.

GraphQL rate limiting #2874

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/mutations/message/addMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ export default async (
// at this point we are only dealing with thread messages
const thread = await loaders.thread.load(message.threadId);

if (thread.isDeleted) {
return new UserError("Can't reply in a deleted thread.");
if (!thread || thread.isDeleted) {
return new UserError("Can't post message in non-existant thread.");
}

if (thread.isLocked) {
Expand Down
62 changes: 40 additions & 22 deletions api/routes/api/graphql.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,50 @@
import { graphqlExpress } from 'graphql-server-express';
import depthLimit from 'graphql-depth-limit';
import costAnalysis from 'graphql-cost-analysis';
import rateLimiter from '../../utils/graphql-rate-limiter';
import Raven from 'shared/raven';
import UserError from '../../utils/UserError';
import createLoaders from '../../loaders/';

import createErrorFormatter from '../../utils/create-graphql-error-formatter';
import schema from '../../schema';

export default graphqlExpress(req => ({
schema,
formatError: createErrorFormatter(req),
context: {
user: req.user ? (req.user.bannedAt ? null : req.user) : null,
loaders: createLoaders(),
},
validationRules: [
depthLimit(10),
costAnalysis({
variables: req.body.variables,
maximumCost: 750,
defaultCost: 1,
createError: (max, actual) => {
const err = new UserError(
`GraphQL query exceeds maximum complexity, please remove some nesting or fields and try again. (max: ${max}, actual: ${actual})`
);
return err;
},
}),
],
}));
import { parse } from 'graphql';

export default graphqlExpress(async req => {
if (!req.body.query) throw new Error('No query provided.');
let doc;
try {
doc = parse(req.body.query);
// Ignore any errors that happen while parsing and let graphql-express handle them
} catch (err) {}
if (doc) {
await rateLimiter({
doc,
schema,
id: (req.user && req.user.id) || 'anonymous',
});
}
return {
schema,
formatError: createErrorFormatter(req),
context: {
user: req.user ? (req.user.bannedAt ? null : req.user) : null,
loaders: createLoaders(),
},
validationRules: [
depthLimit(10),
costAnalysis({
variables: req.body.variables,
maximumCost: 750,
defaultCost: 1,
createError: (max, actual) => {
const err = new UserError(
`GraphQL query exceeds maximum complexity, please remove some nesting or fields and try again. (max: ${max}, actual: ${actual})`
);
return err;
},
}),
],
};
});
2 changes: 1 addition & 1 deletion api/types/Message.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const Message = /* GraphQL */ `


extend type Mutation {
addMessage(message: MessageInput!): Message
addMessage(message: MessageInput!): Message @rateLimit(limit: 2, window: 60000)
deleteMessage(id: ID!): Boolean
}

Expand Down
105 changes: 105 additions & 0 deletions api/utils/graphql-rate-limiter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* @flow
*
* GraphQL rate limiting validation rule
*
*/
import { visit, parse } from 'graphql';
import type {
OperationDefinitionNode,
ValidationContext,
Schema,
} from 'graphql';

const getFieldsByType = (
schema: Schema,
operation: OperationDefinitionNode
) => {
switch (operation.operation) {
case 'query': {
return schema.getQueryType().getFields();
}
case 'mutation': {
return schema.getMutationType().getFields();
}
default: {
return;
}
}
};

const getRateLimitedFields = (
fields: any,
operation: OperationDefinitionNode
) => {
return operation.selectionSet.selections
.filter(node => {
if (node.kind !== 'Field') return false;
const type = fields[node.name.value];
if (
!type ||
!type.astNode.directives.find(({ name }) => name.value === 'rateLimit')
)
return false;
return true;
})
.map(({ name }) => {
const field = fields[name.value];
const directive = field.astNode.directives.find(
({ name }) => name.value === 'rateLimit'
);
const { limit, window } = directive.arguments.reduce((obj, arg) => {
if (arg.name.value === 'limit')
obj.limit = parseInt(arg.value.value, 10);
if (arg.name.value === 'window')
obj.window = parseInt(arg.value.value, 10);
return obj;
}, {});
return { limit, window, field };
});
};

type Timestamp = number;

type Records = {
[userId: string | number]: {
[fieldName: string]: Array<Timestamp>,
},
};

const records: Records = {};

export default async ({
doc,
schema,
id,
}: {
doc: $Call<parse>,
schema: Schema,
id: string | number,
}) => {
return visit(doc, {
OperationDefinition: {
enter: (operation: OperationDefinitionNode) => {
const fields = getFieldsByType(schema, operation);
const rateLimitedFields = getRateLimitedFields(fields, operation);
rateLimitedFields.forEach(
({ field, limit, window: rateLimitingWindow }) => {
if (!records[id]) records[id] = {};
if (!records[id][field.name]) records[id][field.name] = [];
const calls = records[id][field.name];
records[id][field.name] = calls.filter(
timestamp => timestamp + rateLimitingWindow > Date.now()
);
if (calls.length > limit)
throw new Error(
`You've sent too many requests to ${
field.name
}, please take a break.`
);
records[id][field.name].push(Date.now());
}
);
},
},
});
};