Skip to content

Commit 03fba97

Browse files
authored
feat: Add zones for rate limiting by ip, user, session, global (#8508)
1 parent e2a7218 commit 03fba97

File tree

9 files changed

+161
-3
lines changed

9 files changed

+161
-3
lines changed

Diff for: spec/CloudCode.spec.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,8 @@ describe('Cloud Code', () => {
9595
it('can get config', () => {
9696
const config = Parse.Server;
9797
let currentConfig = Config.get('test');
98-
expect(Object.keys(config)).toEqual(Object.keys(currentConfig));
98+
const server = require('../lib/cloud-code/Parse.Server');
99+
expect(Object.keys(config)).toEqual(Object.keys({ ...currentConfig, ...server }));
99100
config.silent = false;
100101
Parse.Server = config;
101102
currentConfig = Config.get('test');

Diff for: spec/RateLimit.spec.js

+98
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,99 @@ describe('rate limit', () => {
335335
await Parse.Cloud.run('test2');
336336
});
337337

338+
describe('zone', () => {
339+
const middlewares = require('../lib/middlewares');
340+
it('can use global zone', async () => {
341+
await reconfigureServer({
342+
rateLimit: {
343+
requestPath: '*',
344+
requestTimeWindow: 10000,
345+
requestCount: 1,
346+
errorResponseMessage: 'Too many requests',
347+
includeInternalRequests: true,
348+
zone: Parse.Server.RateLimitZone.global,
349+
},
350+
});
351+
const fakeReq = {
352+
originalUrl: 'http://example.com/parse/',
353+
url: 'http://example.com/',
354+
body: {
355+
_ApplicationId: 'test',
356+
},
357+
headers: {
358+
'X-Parse-Application-Id': 'test',
359+
'X-Parse-REST-API-Key': 'rest',
360+
},
361+
get: key => {
362+
return fakeReq.headers[key];
363+
},
364+
};
365+
fakeReq.ip = '127.0.0.1';
366+
let fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status', 'setHeader', 'json']);
367+
await new Promise(resolve => middlewares.handleParseHeaders(fakeReq, fakeRes, resolve));
368+
fakeReq.ip = '127.0.0.2';
369+
fakeRes = jasmine.createSpyObj('fakeRes', ['end', 'status', 'setHeader']);
370+
let resolvingPromise;
371+
const promise = new Promise(resolve => {
372+
resolvingPromise = resolve;
373+
});
374+
fakeRes.json = jasmine.createSpy('json').and.callFake(resolvingPromise);
375+
middlewares.handleParseHeaders(fakeReq, fakeRes, () => {
376+
throw 'Should not call next';
377+
});
378+
await promise;
379+
expect(fakeRes.status).toHaveBeenCalledWith(429);
380+
expect(fakeRes.json).toHaveBeenCalledWith({
381+
code: Parse.Error.CONNECTION_FAILED,
382+
error: 'Too many requests',
383+
});
384+
});
385+
386+
it('can use session zone', async () => {
387+
await reconfigureServer({
388+
rateLimit: {
389+
requestPath: '/functions/*',
390+
requestTimeWindow: 10000,
391+
requestCount: 1,
392+
errorResponseMessage: 'Too many requests',
393+
includeInternalRequests: true,
394+
zone: Parse.Server.RateLimitZone.session,
395+
},
396+
});
397+
Parse.Cloud.define('test', () => 'Abc');
398+
await Parse.User.signUp('username', 'password');
399+
await Parse.Cloud.run('test');
400+
await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith(
401+
new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests')
402+
);
403+
await Parse.User.logIn('username', 'password');
404+
await Parse.Cloud.run('test');
405+
});
406+
407+
it('can use user zone', async () => {
408+
await reconfigureServer({
409+
rateLimit: {
410+
requestPath: '/functions/*',
411+
requestTimeWindow: 10000,
412+
requestCount: 1,
413+
errorResponseMessage: 'Too many requests',
414+
includeInternalRequests: true,
415+
zone: Parse.Server.RateLimitZone.user,
416+
},
417+
});
418+
Parse.Cloud.define('test', () => 'Abc');
419+
await Parse.User.signUp('username', 'password');
420+
await Parse.Cloud.run('test');
421+
await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith(
422+
new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests')
423+
);
424+
await Parse.User.logIn('username', 'password');
425+
await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith(
426+
new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests')
427+
);
428+
});
429+
});
430+
338431
it('can validate rateLimit', async () => {
339432
const Config = require('../lib/Config');
340433
const validateRateLimit = ({ rateLimit }) => Config.validateRateLimit(rateLimit);
@@ -350,6 +443,11 @@ describe('rate limit', () => {
350443
expect(() =>
351444
validateRateLimit({ rateLimit: [{ requestTimeWindow: [], requestPath: 'a' }] })
352445
).toThrow('rateLimit.requestTimeWindow must be a number');
446+
expect(() =>
447+
validateRateLimit({
448+
rateLimit: [{ requestPath: 'a', requestTimeWindow: 1000, requestCount: 3, zone: 'abc' }],
449+
})
450+
).toThrow('rateLimit.zone must be one of global, session, user, or ip');
353451
expect(() =>
354452
validateRateLimit({
355453
rateLimit: [

Diff for: src/Config.js

+6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
SchemaOptions,
1919
SecurityOptions,
2020
} from './Options/Definitions';
21+
import ParseServer from './cloud-code/Parse.Server';
2122

2223
function removeTrailingSlash(str) {
2324
if (!str) {
@@ -609,6 +610,11 @@ export class Config {
609610
if (option.errorResponseMessage && typeof option.errorResponseMessage !== 'string') {
610611
throw `rateLimit.errorResponseMessage must be a string`;
611612
}
613+
const options = Object.keys(ParseServer.RateLimitZone);
614+
if (option.zone && !options.includes(option.zone)) {
615+
const formatter = new Intl.ListFormat('en', { style: 'short', type: 'disjunction' });
616+
throw `rateLimit.zone must be one of ${formatter.format(options)}`;
617+
}
612618
}
613619
}
614620

Diff for: src/Options/Definitions.js

+5
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,11 @@ module.exports.RateLimitOptions = {
601601
'The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.',
602602
action: parsers.numberParser('requestTimeWindow'),
603603
},
604+
zone: {
605+
env: 'PARSE_SERVER_RATE_LIMIT_ZONE',
606+
help:
607+
"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'",
608+
},
604609
};
605610
module.exports.SecurityOptions = {
606611
checkGroups: {

Diff for: src/Options/docs.js

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: src/Options/index.js

+11
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,17 @@ export interface RateLimitOptions {
334334
/* 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.
335335
*/
336336
redisUrl: ?string;
337+
/*
338+
The type of rate limit to apply. The following types are supported:
339+
<br><br>
340+
- `global`: rate limit based on the number of requests made by all users <br>
341+
- `ip`: rate limit based on the IP address of the request <br>
342+
- `user`: rate limit based on the user ID of the request <br>
343+
- `session`: rate limit based on the session token of the request <br>
344+
<br><br>
345+
:default: 'ip'
346+
*/
347+
zone: ?string;
337348
}
338349

339350
export interface SecurityOptions {

Diff for: src/ParseServer.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -444,9 +444,11 @@ class ParseServer {
444444

445445
function addParseCloud() {
446446
const ParseCloud = require('./cloud-code/Parse.Cloud');
447+
const ParseServer = require('./cloud-code/Parse.Server');
447448
Object.defineProperty(Parse, 'Server', {
448449
get() {
449-
return Config.get(Parse.applicationId);
450+
const conf = Config.get(Parse.applicationId);
451+
return { ...conf, ...ParseServer };
450452
},
451453
set(newVal) {
452454
newVal.appId = Parse.applicationId;

Diff for: src/cloud-code/Parse.Server.js

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const ParseServer = {};
2+
/**
3+
* ...
4+
*
5+
* @memberof Parse.Server
6+
* @property {String} global Rate limit based on the number of requests made by all users.
7+
* @property {String} session Rate limit based on the sessionToken.
8+
* @property {String} user Rate limit based on the user ID.
9+
* @property {String} ip Rate limit based on the request ip.
10+
* ...
11+
*/
12+
ParseServer.RateLimitZone = Object.freeze({
13+
global: 'global',
14+
session: 'session',
15+
user: 'user',
16+
ip: 'ip',
17+
});
18+
19+
module.exports = ParseServer;

Diff for: src/middlewares.js

+16-1
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,22 @@ export const addRateLimit = (route, config, cloud) => {
549549
}
550550
return request.auth?.isMaster;
551551
},
552-
keyGenerator: request => {
552+
keyGenerator: async request => {
553+
if (route.zone === Parse.Server.RateLimitZone.global) {
554+
return request.config.appId;
555+
}
556+
const token = request.info.sessionToken;
557+
if (route.zone === Parse.Server.RateLimitZone.session && token) {
558+
return token;
559+
}
560+
if (route.zone === Parse.Server.RateLimitZone.user && token) {
561+
if (!request.auth) {
562+
await new Promise(resolve => handleParseSession(request, null, resolve));
563+
}
564+
if (request.auth?.user?.id && request.zone === 'user') {
565+
return request.auth.user.id;
566+
}
567+
}
553568
return request.config.ip;
554569
},
555570
store: redisStore.store,

0 commit comments

Comments
 (0)