diff --git a/package-lock.json b/package-lock.json index 4199bbc70ae..e97746c926d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "pg-monitor": "2.0.0", "pg-promise": "11.3.0", "pluralize": "8.0.0", + "rate-limit-redis": "3.0.1", "redis": "4.0.6", "semver": "7.3.8", "subscriptions-transport-ws": "0.11.0", @@ -16746,6 +16747,17 @@ "node": ">= 0.6" } }, + "node_modules/rate-limit-redis": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-3.0.1.tgz", + "integrity": "sha512-L6yhOUBrAZ8VEMX9DwlM3X6hfm8yq+gBO4LoOW7+JgmNq59zE7QmLz4v5VnwYPvLeSh/e7PDcrzUI3UumJw1iw==", + "engines": { + "node": ">= 14.5.0" + }, + "peerDependencies": { + "express-rate-limit": "^6" + } + }, "node_modules/raw-body": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", @@ -33032,6 +33044,12 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" }, + "rate-limit-redis": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-3.0.1.tgz", + "integrity": "sha512-L6yhOUBrAZ8VEMX9DwlM3X6hfm8yq+gBO4LoOW7+JgmNq59zE7QmLz4v5VnwYPvLeSh/e7PDcrzUI3UumJw1iw==", + "requires": {} + }, "raw-body": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", diff --git a/package.json b/package.json index 2d4e0e071a2..7d101a165e3 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "pg-monitor": "2.0.0", "pg-promise": "11.3.0", "pluralize": "8.0.0", + "rate-limit-redis": "3.0.1", "redis": "4.0.6", "semver": "7.3.8", "subscriptions-transport-ws": "0.11.0", diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 10bc1277196..894c8fcf822 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -1,3 +1,4 @@ +const RedisCacheAdapter = require('../lib/Adapters/Cache/RedisCacheAdapter').default; describe('rate limit', () => { it('can limit cloud functions', async () => { Parse.Cloud.define('test', () => 'Abc'); @@ -388,4 +389,33 @@ describe('rate limit', () => { }) ).toBeRejectedWith(`Invalid rate limit option "path"`); }); + describe_only(() => { + return process.env.PARSE_SERVER_TEST_CACHE === 'redis'; + })('with RedisCache', function () { + it('does work with cache', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + redisUrl: 'redis://localhost:6379', + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await expectAsync(obj.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + const cache = new RedisCacheAdapter(); + await cache.connect(); + const value = await cache.get('rl:127.0.0.1'); + expect(value).toEqual(2); + const ttl = await cache.client.ttl('rl:127.0.0.1'); + expect(ttl).toEqual(10); + }); + }); }); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index a25e69c70af..b2f0542256a 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -557,6 +557,11 @@ module.exports.RateLimitOptions = { action: parsers.booleanParser, default: false, }, + redisUrl: { + env: 'PARSE_SERVER_RATE_LIMIT_REDIS_URL', + help: + 'Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests.', + }, requestCount: { env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_COUNT', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 0eb6488c746..1ab8c03d58e 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -104,6 +104,7 @@ * @property {String} errorResponseMessage The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`. * @property {Boolean} includeInternalRequests Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks. * @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. + * @property {String} redisUrl Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests. * @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html diff --git a/src/Options/index.js b/src/Options/index.js index 778374e7e79..a4d83f94fc2 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -320,6 +320,9 @@ export interface RateLimitOptions { /* Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks. :DEFAULT: false */ includeInternalRequests: ?boolean; + /* Optional, the URL of the Redis server to store rate limit data. This allows to rate limit requests for multiple servers by calculating the sum of all requests across all servers. This is useful if multiple servers are processing requests behind a load balancer. For example, the limit of 10 requests is reached if each of 2 servers processed 5 requests. + */ + redisUrl: ?string; } export interface SecurityOptions { diff --git a/src/middlewares.js b/src/middlewares.js index 788c9a3f625..8f3915e4b69 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -11,6 +11,8 @@ import rateLimit from 'express-rate-limit'; import { RateLimitOptions } from './Options/Definitions'; import pathToRegexp from 'path-to-regexp'; import ipRangeCheck from 'ip-range-check'; +import RedisStore from 'rate-limit-redis'; +import { createClient } from 'redis'; export const DEFAULT_ALLOWED_HEADERS = 'X-Parse-Master-Key, X-Parse-REST-API-Key, X-Parse-Javascript-Key, X-Parse-Application-Id, X-Parse-Client-Version, X-Parse-Session-Token, X-Requested-With, X-Parse-Revocable-Session, X-Parse-Request-Id, Content-Type, Pragma, Cache-Control'; @@ -476,6 +478,35 @@ export const addRateLimit = (route, config) => { if (!config.rateLimits) { config.rateLimits = []; } + const redisStore = { + connectionPromise: Promise.resolve(), + store: null, + connected: false, + }; + if (route.redisrUrl) { + const client = createClient({ + url: route.redisrUrl, + }); + redisStore.connectionPromise = async () => { + if (redisStore.connected) { + return; + } + try { + await client.connect(); + redisStore.connected = true; + } catch (e) { + const log = config?.loggerController || defaultLogger; + log.error(`Could not connect to redisURL in rate limit: ${e}`); + } + }; + redisStore.connectionPromise(); + redisStore.store = new RedisStore({ + sendCommand: async (...args) => { + await redisStore.connectionPromise(); + return client.sendCommand(args); + }, + }); + } config.rateLimits.push({ path: pathToRegexp(route.requestPath), handler: rateLimit({ @@ -512,6 +543,7 @@ export const addRateLimit = (route, config) => { keyGenerator: request => { return request.config.ip; }, + store: redisStore.store, }), }); Config.put(config);