Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add rate limiting across multiple servers via Redis #8394

Merged
merged 15 commits into from
Mar 6, 2023
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';
mtrezza marked this conversation as resolved.
Show resolved Hide resolved
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