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 zones for rate limiting by ip, user, session, global #8508

Merged
merged 9 commits into from
Jun 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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