From 09ab664de3587494bcb107c7586f2ccbd7934d7d Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 21 May 2022 15:53:04 -0400 Subject: [PATCH 1/4] initial framework and spec for express rate limiting middleware. --- package-lock.json | 141 +++++++++++++++++++++++++++++++ package.json | 5 +- src/@types/rateLimit.d.ts | 7 ++ src/analysis/buildTypeWeights.ts | 23 +++-- src/middleware/index.ts | 41 +++++++++ 5 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 src/middleware/index.ts diff --git a/package-lock.json b/package-lock.json index 1941a3c..05bd366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@babel/core": "^7.17.12", "@babel/preset-env": "^7.17.12", "@babel/preset-typescript": "^7.17.12", + "@types/express": "^4.17.13", "@types/jest": "^27.5.1", "@types/redis-mock": "^0.17.1", "@typescript-eslint/eslint-plugin": "^5.24.0", @@ -2467,6 +2468,48 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -2602,6 +2645,12 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "node_modules/@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, "node_modules/@types/node": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.34.tgz", @@ -2614,6 +2663,18 @@ "integrity": "sha512-XFjFHmaLVifrAKaZ+EKghFHtHSUonyw8P2Qmy2/+osBnrKbH9UYtlK10zg8/kCt47MFilll/DEDKy3DHfJ0URw==", "dev": true }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "node_modules/@types/redis": { "version": "2.8.32", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", @@ -2632,6 +2693,16 @@ "@types/redis": "^2.8.0" } }, + "node_modules/@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -9391,6 +9462,48 @@ "@babel/types": "^7.3.0" } }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/express": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", + "integrity": "sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.28", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz", + "integrity": "sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -9507,6 +9620,12 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, "@types/node": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.34.tgz", @@ -9519,6 +9638,18 @@ "integrity": "sha512-XFjFHmaLVifrAKaZ+EKghFHtHSUonyw8P2Qmy2/+osBnrKbH9UYtlK10zg8/kCt47MFilll/DEDKy3DHfJ0URw==", "dev": true }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, "@types/redis": { "version": "2.8.32", "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz", @@ -9537,6 +9668,16 @@ "@types/redis": "^2.8.0" } }, + "@types/serve-static": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.10.tgz", + "integrity": "sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", diff --git a/package.json b/package.json index 91caf46..a154acd 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@babel/core": "^7.17.12", "@babel/preset-env": "^7.17.12", "@babel/preset-typescript": "^7.17.12", + "@types/express": "^4.17.13", "@types/jest": "^27.5.1", "@types/redis-mock": "^0.17.1", "@typescript-eslint/eslint-plugin": "^5.24.0", @@ -49,7 +50,7 @@ "*.{js,ts,css,md}": "prettier --write --ignore-unknown" }, "dependencies": { - "redis": "^4.1.0", - "graphql": "^16.5.0" + "graphql": "^16.5.0", + "redis": "^4.1.0" } } diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 34de442..13ceff2 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -22,3 +22,10 @@ interface RedisBucket { tokens: number; timestamp: number; } + +type RateLimiterSelection = + | 'TOKEN_BUCKET' + | 'LEAKY_BUCKET' + | 'FIXED_WINDOW' + | 'SLIDING_WINDOW_LOG' + | 'SLIDING_WINDOW_COUNTER'; diff --git a/src/analysis/buildTypeWeights.ts b/src/analysis/buildTypeWeights.ts index 5b8beb3..41a5a93 100644 --- a/src/analysis/buildTypeWeights.ts +++ b/src/analysis/buildTypeWeights.ts @@ -1,5 +1,19 @@ import { GraphQLSchema } from 'graphql/type/schema'; +/** + * Default TypeWeight Configuration: + * mutation: 10 + * object: 1 + * scalar: 0 + * connection: 2 + */ +export const defaultTypeWeightsConfig: TypeWeightConfig = { + mutation: 10, + object: 1, + scalar: 0, + connection: 2, +}; + /** * The default typeWeightsConfig object is based off of Shopifys implementation of query * cost analysis. Our function should input a users configuration of type weights or fall @@ -10,16 +24,11 @@ import { GraphQLSchema } from 'graphql/type/schema'; * - validate that the typeWeightsConfig parameter has no negative values (throw an error if it does) * * @param schema - * @param typeWeightsConfig + * @param typeWeightsConfig Defaults to {mutation: 10, object: 1, field: 0, connection: 2} */ function buildTypeWeightsFromSchema( schema: GraphQLSchema, - typeWeightsConfig: TypeWeightConfig = { - mutation: 10, - object: 1, - scalar: 0, - connection: 2, - } + typeWeightsConfig: TypeWeightConfig = defaultTypeWeightsConfig ): TypeWeightObject { throw Error(`getTypeWeightsFromSchema is not implemented.`); } diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..7799b62 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,41 @@ +import { RedisClientType } from 'redis'; +import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { GraphQLSchema } from 'graphql/type/schema'; +import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; + +// FIXME: Should redisClient have the option of an under the hood implementation (not provided by user) +// 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 +// FIXME: Should a 429 status be sent by default or do we allow the user to handle blocked requests? + +/** + * Primary entry point for adding GraphQL Rate Limiting middleware to an Express Server + * @param {RateLimiterSelection} rateLimiter Specify rate limiting algorithm to be used + * @param {GraphQLSchema} schema GraphQLSchema object + * @param {RedisClientType} redisClient redis client used for caching user request informaiton + * @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 + * if the query is allowed or sends a 429 status if the request is blocked + * @throws ValidationError if GraphQL Schema is invalid + */ +export function expressRateLimiter( + rateLimiter: RateLimiterSelection, + schema: GraphQLSchema, + redisClient: RedisClientType, + typeWeightConfig: TypeWeightConfig = defaultTypeWeightsConfig +): RequestHandler { + // TODO: Parse the schema to create a TypeWeightObject. Throw ValidationError if schema is invalid + // 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')); + }; + return middleware; +} + +export default expressRateLimiter; From f94394025051c59e360aa2bfaafea18df19ae824 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 21 May 2022 16:08:52 -0400 Subject: [PATCH 2/4] added redis client connection options --- src/middleware/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 7799b62..719ba05 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,9 +1,8 @@ -import { RedisClientType } from 'redis'; +import { RedisClientOptions } from 'redis'; import { Request, Response, NextFunction, RequestHandler } from 'express'; import { GraphQLSchema } from 'graphql/type/schema'; import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; -// FIXME: Should redisClient have the option of an under the hood implementation (not provided by user) // 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 // FIXME: Should a 429 status be sent by default or do we allow the user to handle blocked requests? @@ -12,7 +11,7 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; * Primary entry point for adding GraphQL Rate Limiting middleware to an Express Server * @param {RateLimiterSelection} rateLimiter Specify rate limiting algorithm to be used * @param {GraphQLSchema} schema GraphQLSchema object - * @param {RedisClientType} redisClient redis client used for caching user request informaiton + * @param {RedisClientOptions} redisClientOptions valid node-redis connection options. See https://github.com/redis/node-redis/blob/HEAD/docs/client-configuration.md * @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 @@ -22,17 +21,17 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; export function expressRateLimiter( rateLimiter: RateLimiterSelection, schema: GraphQLSchema, - redisClient: RedisClientType, + redisClientOptions: RedisClientOptions, typeWeightConfig: TypeWeightConfig = defaultTypeWeightsConfig ): RequestHandler { // 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')); }; return middleware; From 4ee8a3a41a685604cda69b3762c96d46cf5f0d18 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Sat, 21 May 2022 17:01:50 -0400 Subject: [PATCH 3/4] added rate limiter options param --- src/@types/rateLimit.d.ts | 12 ++++++++++++ src/middleware/index.ts | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/@types/rateLimit.d.ts b/src/@types/rateLimit.d.ts index 13ceff2..91ec955 100644 --- a/src/@types/rateLimit.d.ts +++ b/src/@types/rateLimit.d.ts @@ -29,3 +29,15 @@ type RateLimiterSelection = | 'FIXED_WINDOW' | 'SLIDING_WINDOW_LOG' | 'SLIDING_WINDOW_COUNTER'; + +/** + * @type {number} bucketSize - Size of the token bucket + * @type {number} refillRate - Rate at which tokens are added to the bucket in seconds + */ +interface TokenBucketOptions { + bucketSize: number; + refillRate: number; +} + +// TODO: This will be a union type where we can specify Option types for other Rate Limiters +type RateLimiterOptions = TokenBucketOptions; diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 719ba05..2fc8e68 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -10,6 +10,7 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; /** * Primary entry point for adding GraphQL Rate Limiting middleware to an Express Server * @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 {TypeWeightConfig} typeWeightConfig Optional type weight configuration for the GraphQL Schema. @@ -20,6 +21,7 @@ import { defaultTypeWeightsConfig } from '../analysis/buildTypeWeights'; */ export function expressRateLimiter( rateLimiter: RateLimiterSelection, + rateLimiterOptions: RateLimiterOptions, schema: GraphQLSchema, redisClientOptions: RedisClientOptions, typeWeightConfig: TypeWeightConfig = defaultTypeWeightsConfig From 61e96b018b80ec4c3dbf367261e098a327d9c471 Mon Sep 17 00:00:00 2001 From: Stephan Halarewicz Date: Mon, 23 May 2022 19:29:58 -0400 Subject: [PATCH 4/4] added timestamp task for express middleware --- src/middleware/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 2fc8e68..57fc4d6 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -26,6 +26,7 @@ export function expressRateLimiter( redisClientOptions: RedisClientOptions, 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