diff --git a/package-lock.json b/package-lock.json index 253582a..2b2d0fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5990,12 +5990,12 @@ "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -12300,12 +12300,12 @@ "lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, "lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=" + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, "lodash.memoize": { "version": "4.1.2", diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 302821c..2eded16 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -15,7 +15,7 @@ interface RateLimiter { interface RateLimiterResponse { success: boolean; - tokens?: number; + tokens: number; } interface RedisBucket { diff --git a/src/analysis/typeComplexityAnalysis.ts b/src/analysis/typeComplexityAnalysis.ts index 585550b..cc603f3 100644 --- a/src/analysis/typeComplexityAnalysis.ts +++ b/src/analysis/typeComplexityAnalysis.ts @@ -1,4 +1,4 @@ -import { parse } from 'graphql'; +import { DocumentNode } from 'graphql'; /** * This function should @@ -13,9 +13,15 @@ import { parse } from 'graphql'; * * @param {string} queryString * @param {TypeWeightObject} typeWeights + * @param {any | undefined} varibales * @param {string} complexityOption */ -function getQueryTypeComplexity(queryString: string, typeWeights: TypeWeightObject): number { +// TODO add queryVaribables parameter +function getQueryTypeComplexity( + queryString: DocumentNode, + varibales: any | undefined, + typeWeights: TypeWeightObject +): number { throw Error('getQueryComplexity is not implemented.'); } diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 36ebe48..75b0ee3 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,7 +1,11 @@ import Redis, { RedisOptions } from 'ioredis'; -import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { parse, validate } from 'graphql'; import { GraphQLSchema } from 'graphql/type/schema'; -import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; +import { Request, Response, NextFunction, RequestHandler } from 'express'; + +import buildTypeWeightsFromSchema, { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; +import setupRateLimiter from './rateLimiterSetup'; +import getQueryTypeComplexity from '../analysis/typeComplexityAnalysis'; // FIXME: Will the developer be responsible for first parsing the schema from a file? // Can consider accepting a string representing a the filepath to a schema @@ -12,7 +16,7 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; * @param {RateLimiterSelection} rateLimiter Specify rate limiting algorithm to be used * @param {RateLimiterOptions} options Specify the appropriate options for the selected rateLimiter * @param {GraphQLSchema} schema GraphQLSchema object - * @param {RedisClientOptions} redisClientOptions valid node-redis connection options. See https://github.com/redis/node-redis/blob/HEAD/docs/client-configuration.md + * @param {RedisClientOptions} RedisOptions ioredis connection options https://ioredis.readthedocs.io/en/stable/API/#new_Redis * @param {TypeWeightConfig} typeWeightConfig Optional type weight configuration for the GraphQL Schema. * Defaults to {mutation: 10, object: 1, field: 0, connection: 2} * @returns {RequestHandler} express middleware that computes the complexity of req.query and calls the next middleware @@ -21,23 +25,76 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; * @throws ValidationError if GraphQL Schema is invalid. */ export function expressRateLimiter( - rateLimiter: RateLimiterSelection, + rateLimiterAlgo: RateLimiterSelection, rateLimiterOptions: RateLimiterOptions, schema: GraphQLSchema, redisClientOptions: RedisOptions, typeWeightConfig: TypeWeightConfig = defaultTypeWeightsConfig ): RequestHandler { - // TODO: Set 'timestamp' on res.locals to record when the request is received in UNIX format. HTTP does not inlude this. - // TODO: Parse the schema to create a TypeWeightObject. Throw ValidationError if schema is invalid - // TODO: Connect to Redis store using provided options. Default to localhost:6379 - // TODO: Configure the selected RateLimtier - // TODO: Configure the complexity analysis algorithm to run for incoming requests - const middleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => { - // TODO: Parse query from req.query, compute complexity and pass necessary info to rate limiter - // TODO: Call next if query is successful, send 429 status if query blocked, call next(err) with any thrown errors - next(Error('Express rate limiting middleware not implemented')); + /** + * build the type weight object, create the redis client and instantiate the ratelimiter + * before returning the express middleware that calculates query complexity and throttles the requests + */ + // TODO: Throw ValidationError if schema is invalid + const typeWeightObject = buildTypeWeightsFromSchema(schema, typeWeightConfig); + // TODO: Throw error if connection is unsuccessful + const redisClient = new Redis(redisClientOptions); // Default port is 6379 automatically + const rateLimiter = setupRateLimiter(rateLimiterAlgo, rateLimiterOptions, redisClient); + + // return the rate limiting middleware + return async (req: Request, res: Response, next: NextFunction): Promise => { + const requestTimestamp = new Date().valueOf(); + const { query, variables }: { query: string; variables: any } = req.body; + if (!query) { + // FIXME: Throw an error here? Code currently passes this on to whatever is next + console.log('There is no query on the request'); + return next(); + } + + /** + * There are numorous ways to get the ip address off of the request object. + * - the header 'x-forward-for' will hold the originating ip address if a proxy is placed infront of the server. This would be commen for a production build. + * - req.ips wwill hold an array of ip addresses in'x-forward-for' header. client is likely at index zero + * - req.ip will have the ip address + * - req.socket.remoteAddress is an insatnce of net.socket which is used as another method of getting the ip address + * + * req.ip and req.ips will worx in express but not with other frameworks + */ + // check for a proxied ip address before using the ip address on request + const ip: string = req.ips[0] || req.ip; + + // FIXME: this will only work with type complexity + const queryAST = parse(query); + // validate the query against the schema. The GraphQL validation function returns an array of errors. + const validationErrors = validate(schema, queryAST); + // check if the length of the returned GraphQL Errors array is greater than zero. If it is, there were errors. Call next so that the GraphQL server can handle those. + if (validationErrors.length > 0) return next(); + + const queryComplexity = getQueryTypeComplexity(queryAST, variables, typeWeightObject); + + try { + // process the request and conditinoally respond to client with status code 429 o + // r pass the request onto the next middleware function + const rateLimiterResponse = await rateLimiter.processRequest( + ip, + requestTimestamp, + queryComplexity + ); + if (rateLimiterResponse.success === false) { + // TODO: add a header 'Retry-After' with the time to wait untill next query will succeed + // FIXME: send information about query complexity, tokens, etc, to the client on rejected query + res.status(429).send(); + } + res.locals.graphqlGate = { + timestamp: requestTimestamp, + complexity: queryComplexity, + tokens: rateLimiterResponse.tokens, + }; + return next(); + } catch (err) { + return next(err); + } }; - return middleware; } export default expressRateLimiter; diff --git a/src/middleware/rateLimiterSetup.ts b/src/middleware/rateLimiterSetup.ts new file mode 100644 index 0000000..4c5de39 --- /dev/null +++ b/src/middleware/rateLimiterSetup.ts @@ -0,0 +1,40 @@ +import Redis from 'ioredis'; +import TokenBucket from '../rateLimiters/tokenBucket'; + +/** + * Instatieate the rateLimiting algorithm class based on the developer selection and options + * + * @export + * @param {RateLimiterSelection} selection + * @param {RateLimiterOptions} options + * @param {Redis} client + * @return {*} + */ +export default function setupRateLimiter( + selection: RateLimiterSelection, + options: RateLimiterOptions, + client: Redis +) { + switch (selection) { + case 'TOKEN_BUCKET': + // todo validate options + return new TokenBucket(options.bucketSize, options.refillRate, client); + break; + case 'LEAKY_BUCKET': + throw new Error('Leaky Bucket algonithm has not be implemented.'); + break; + case 'FIXED_WINDOW': + throw new Error('Fixed Window algonithm has not be implemented.'); + break; + case 'SLIDING_WINDOW_LOG': + throw new Error('Sliding Window Log has not be implemented.'); + break; + case 'SLIDING_WINDOW_COUNTER': + throw new Error('Sliding Window Counter algonithm has not be implemented.'); + break; + default: + // typescript should never let us invoke this function with anything other than the options above + throw new Error('Selected rate limiting algorithm is not suppported'); + break; + } +} diff --git a/test/analysis/typeComplexityAnalysis.test.ts b/test/analysis/typeComplexityAnalysis.test.ts index e1bf05a..112b320 100644 --- a/test/analysis/typeComplexityAnalysis.test.ts +++ b/test/analysis/typeComplexityAnalysis.test.ts @@ -1,3 +1,4 @@ +import { parse } from 'graphql'; import getQueryTypeComplexity from '../../src/analysis/typeComplexityAnalysis'; /** @@ -168,45 +169,47 @@ const typeWeights: TypeWeightObject = { xdescribe('Test getQueryTypeComplexity function', () => { let query = ''; + let variables: any | undefined; describe('Calculates the correct type complexity for queries', () => { test('with one feild', () => { query = `Query { scalars { num } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + Scalars 1 + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + Scalars 1 }); test('with two or more fields', () => { query = `Query { scalars { num } test { name } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1 + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1 }); test('with one level of nested fields', () => { query = `Query { scalars { num, test { name } } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1 + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + scalars 1 + test 1 }); test('with multiple levels of nesting', () => { query = `Query { scalars { num, test { name, scalars { id } } } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(4); // Query 1 + scalars 1 + test 1 + scalars 1 + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // Query 1 + scalars 1 + test 1 + scalars 1 }); test('with aliases', () => { query = `Query { foo: scalar { num } bar: scalar { id }}`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + scalar 1 + scalar 1 + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + scalar 1 + scalar 1 }); test('with all scalar fields', () => { query = `Query { scalars { id, num, float, bool, string } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + scalar 1 + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + scalar 1 }); test('with arguments and variables', () => { query = `Query { hero(episode: EMPIRE) { id, name } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1 + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1 query = `Query { human(id: 1) { id, name, appearsIn } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(3); // Query 1 + human/character 1 + appearsIn/episode + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(3); // Query 1 + human/character 1 + appearsIn/episode // argument passed in as a variable - query = `Query { hero(episode: $ep) { id, name } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1 + variables = { ep: 'EMPIRE' }; + query = `Query varibaleQuery ($ep: Episode){ hero(episode: $ep) { id, name } }`; + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1 }); test('with fragments', () => { @@ -225,7 +228,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { appearsIn } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(5); // Query 1 + 2*(character 1 + appearsIn/episode 1) + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // Query 1 + 2*(character 1 + appearsIn/episode 1) }); test('with inline fragments', () => { @@ -241,7 +244,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { } } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(2); // Query 1 + hero/character 1) + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(2); // Query 1 + hero/character 1) }); /** @@ -255,12 +258,15 @@ xdescribe('Test getQueryTypeComplexity function', () => { name } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(false); // ? + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(false); // ? }); - test('with lists detrmined by arguments', () => { + test('with lists detrmined by arguments and variables', () => { query = `Query {reviews(episode: EMPIRE, first: 3) { stars, commentary } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(4); // 1 Query + 3 reviews + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // 1 Query + 3 reviews + variables = { first: 3 }; + query = `Query queryVaribales($first: Int) {reviews(episode: EMPIRE, first: $first) { stars, commentary } }`; + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(4); // 1 Query + 3 reviews }); test('with nested lists', () => { @@ -276,7 +282,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { } } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(17); // 1 Query + 1 human/character + (5 friends/character X 3 friends/characters) + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(17); // 1 Query + 1 human/character + (5 friends/character X 3 friends/characters) }); test('accounting for __typename feild', () => { @@ -294,7 +300,7 @@ xdescribe('Test getQueryTypeComplexity function', () => { } } }`; - expect(getQueryTypeComplexity(query, typeWeights)).toBe(5); // 1 Query + 4 search results + expect(getQueryTypeComplexity(parse(query), variables, typeWeights)).toBe(5); // 1 Query + 4 search results }); // todo: directives @skip, @include and custom directives @@ -302,11 +308,17 @@ xdescribe('Test getQueryTypeComplexity function', () => { // todo: expand on error handling test('Throws an error if for a bad query', () => { query = `Query { hello { hi } }`; // type doesn't exist - expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error'); + expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow( + 'Error' + ); query = `Query { hero(episode: EMPIRE){ starship } }`; // field doesn't exist - expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error'); + expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow( + 'Error' + ); query = `Query { hero(episode: EMPIRE) { id, name }`; // missing a closing bracket - expect(() => getQueryTypeComplexity(query, typeWeights)).toThrow('Error'); + expect(() => getQueryTypeComplexity(parse(query), variables, typeWeights)).toThrow( + 'Error' + ); }); }); diff --git a/test/middleware/express.test.ts b/test/middleware/express.test.ts index ff90782..6e63afa 100644 --- a/test/middleware/express.test.ts +++ b/test/middleware/express.test.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction, RequestHandler } from 'express'; import { GraphQLSchema, buildSchema } from 'graphql'; import * as ioredis from 'ioredis'; -import expressRateLimitMiddleware from '../../src/middleware/index'; +import { expressRateLimiter as expressRateLimitMiddleware } from '../../src/middleware/index'; // eslint-disable-next-line @typescript-eslint/no-var-requires const RedisMock = require('ioredis-mock');