Skip to content

Commit

Permalink
feat: Add rate limiting across multiple servers via Redis (#8394)
Browse files Browse the repository at this point in the history
  • Loading branch information
dblythy authored Mar 6, 2023
1 parent 0f1979f commit 34833e4
Show file tree
Hide file tree
Showing 7 changed files with 90 additions and 0 deletions.
18 changes: 18 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 30 additions & 0 deletions spec/RateLimit.spec.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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);
});
});
});
5 changes: 5 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -512,6 +543,7 @@ export const addRateLimit = (route, config) => {
keyGenerator: request => {
return request.config.ip;
},
store: redisStore.store,
}),
});
Config.put(config);
Expand Down

0 comments on commit 34833e4

Please sign in to comment.