Skip to content

Commit

Permalink
feat: Add zones for rate limiting by ip, user, session, global (
Browse files Browse the repository at this point in the history
  • Loading branch information
dblythy authored Jun 9, 2023
1 parent e2a7218 commit 03fba97
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 3 deletions.
3 changes: 2 additions & 1 deletion spec/CloudCode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ describe('Cloud Code', () => {
it('can get config', () => {
const config = Parse.Server;
let currentConfig = Config.get('test');
expect(Object.keys(config)).toEqual(Object.keys(currentConfig));
const server = require('../lib/cloud-code/Parse.Server');
expect(Object.keys(config)).toEqual(Object.keys({ ...currentConfig, ...server }));
config.silent = false;
Parse.Server = config;
currentConfig = Config.get('test');
Expand Down
98 changes: 98 additions & 0 deletions spec/RateLimit.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,99 @@ describe('rate limit', () => {
await Parse.Cloud.run('test2');
});

describe('zone', () => {
const middlewares = require('../lib/middlewares');
it('can use global zone', async () => {
await reconfigureServer({
rateLimit: {
requestPath: '*',
requestTimeWindow: 10000,
requestCount: 1,
errorResponseMessage: 'Too many requests',
includeInternalRequests: true,
zone: Parse.Server.RateLimitZone.global,
},
});
const fakeReq = {
originalUrl: 'http://example.com/parse/',
url: 'http://example.com/',
body: {
_ApplicationId: 'test',
},
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
},
get: key => {
return fakeReq.headers[key];
},
};
fakeReq.ip = '127.0.0.1';
let fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status', 'setHeader', 'json']);
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
fakeReq.ip = '127.0.0.2';
fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status', 'setHeader']);
let resolvingPromise;
const promise = new Promise(resolve => {
resolvingPromise = resolve;
});
fakeRes.json = jasmine.createSpy('json').and.callFake(resolvingPromise);
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
throw 'Should not call next';
});
await promise;
expect(fakeRes.status).toHaveBeenCalledWith(429);
expect(fakeRes.json).toHaveBeenCalledWith({
code: Parse.Error.CONNECTION_FAILED,
error: 'Too many requests',
});
});

it('can use session zone', async () => {
await reconfigureServer({
rateLimit: {
requestPath: '/functions/*',
requestTimeWindow: 10000,
requestCount: 1,
errorResponseMessage: 'Too many requests',
includeInternalRequests: true,
zone: Parse.Server.RateLimitZone.session,
},
});
Parse.Cloud.define('test', () => 'Abc');
await Parse.User.signUp('username', 'password');
await Parse.Cloud.run('test');
await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith(
new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests')
);
await Parse.User.logIn('username', 'password');
await Parse.Cloud.run('test');
});

it('can use user zone', async () => {
await reconfigureServer({
rateLimit: {
requestPath: '/functions/*',
requestTimeWindow: 10000,
requestCount: 1,
errorResponseMessage: 'Too many requests',
includeInternalRequests: true,
zone: Parse.Server.RateLimitZone.user,
},
});
Parse.Cloud.define('test', () => 'Abc');
await Parse.User.signUp('username', 'password');
await Parse.Cloud.run('test');
await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith(
new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests')
);
await Parse.User.logIn('username', 'password');
await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith(
new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests')
);
});
});

it('can validate rateLimit', async () => {
const Config = require('../lib/Config');
const validateRateLimit = ({ rateLimit }) => Config.validateRateLimit(rateLimit);
Expand All @@ -350,6 +443,11 @@ describe('rate limit', () => {
expect(() =>
validateRateLimit({ rateLimit: [{ requestTimeWindow: [], requestPath: 'a' }] })
).toThrow('rateLimit.requestTimeWindow must be a number');
expect(() =>
validateRateLimit({
rateLimit: [{ requestPath: 'a', requestTimeWindow: 1000, requestCount: 3, zone: 'abc' }],
})
).toThrow('rateLimit.zone must be one of global, session, user, or ip');
expect(() =>
validateRateLimit({
rateLimit: [
Expand Down
6 changes: 6 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
SchemaOptions,
SecurityOptions,
} from './Options/Definitions';
import ParseServer from './cloud-code/Parse.Server';

function removeTrailingSlash(str) {
if (!str) {
Expand Down Expand Up @@ -609,6 +610,11 @@ export class Config {
if (option.errorResponseMessage && typeof option.errorResponseMessage !== 'string') {
throw `rateLimit.errorResponseMessage must be a string`;
}
const options = Object.keys(ParseServer.RateLimitZone);
if (option.zone && !options.includes(option.zone)) {
const formatter = new Intl.ListFormat('en', { style: 'short', type: 'disjunction' });
throw `rateLimit.zone must be one of ${formatter.format(options)}`;
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,11 @@ module.exports.RateLimitOptions = {
'The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.',
action: parsers.numberParser('requestTimeWindow'),
},
zone: {
env: 'PARSE_SERVER_RATE_LIMIT_ZONE',
help:
"The type of rate limit to apply. The following types are supported:<br><br>- `global`: rate limit based on the number of requests made by all users <br>- `ip`: rate limit based on the IP address of the request <br>- `user`: rate limit based on the user ID of the request <br>- `session`: rate limit based on the session token of the request <br><br><br>:default: 'ip'",
},
};
module.exports.SecurityOptions = {
checkGroups: {
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.

11 changes: 11 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,17 @@ export interface RateLimitOptions {
/* 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;
/*
The type of rate limit to apply. The following types are supported:
<br><br>
- `global`: rate limit based on the number of requests made by all users <br>
- `ip`: rate limit based on the IP address of the request <br>
- `user`: rate limit based on the user ID of the request <br>
- `session`: rate limit based on the session token of the request <br>
<br><br>
:default: 'ip'
*/
zone: ?string;
}

export interface SecurityOptions {
Expand Down
4 changes: 3 additions & 1 deletion src/ParseServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,11 @@ class ParseServer {

function addParseCloud() {
const ParseCloud = require('./cloud-code/Parse.Cloud');
const ParseServer = require('./cloud-code/Parse.Server');
Object.defineProperty(Parse, 'Server', {
get() {
return Config.get(Parse.applicationId);
const conf = Config.get(Parse.applicationId);
return { ...conf, ...ParseServer };
},
set(newVal) {
newVal.appId = Parse.applicationId;
Expand Down
19 changes: 19 additions & 0 deletions src/cloud-code/Parse.Server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const ParseServer = {};
/**
* ...
*
* @memberof Parse.Server
* @property {String} global Rate limit based on the number of requests made by all users.
* @property {String} session Rate limit based on the sessionToken.
* @property {String} user Rate limit based on the user ID.
* @property {String} ip Rate limit based on the request ip.
* ...
*/
ParseServer.RateLimitZone = Object.freeze({
global: 'global',
session: 'session',
user: 'user',
ip: 'ip',
});

module.exports = ParseServer;
17 changes: 16 additions & 1 deletion src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,22 @@ export const addRateLimit = (route, config, cloud) => {
}
return request.auth?.isMaster;
},
keyGenerator: request => {
keyGenerator: async request => {
if (route.zone === Parse.Server.RateLimitZone.global) {
return request.config.appId;
}
const token = request.info.sessionToken;
if (route.zone === Parse.Server.RateLimitZone.session && token) {
return token;
}
if (route.zone === Parse.Server.RateLimitZone.user && token) {
if (!request.auth) {
await new Promise(resolve => handleParseSession(request, null, resolve));
}
if (request.auth?.user?.id && request.zone === 'user') {
return request.auth.user.id;
}
}
return request.config.ip;
},
store: redisStore.store,
Expand Down

0 comments on commit 03fba97

Please sign in to comment.