Skip to content

Commit b217bb3

Browse files
committed
feat: create rateLimit zone to rate limit depending on global, ip, sessionToken, userId
1 parent 177891e commit b217bb3

File tree

6 files changed

+132
-1
lines changed

6 files changed

+132
-1
lines changed

spec/RateLimit.spec.js

Lines changed: 98 additions & 0 deletions
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: '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: '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: '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: [

src/Config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,9 @@ export class Config {
599599
if (option.errorResponseMessage && typeof option.errorResponseMessage !== 'string') {
600600
throw `rateLimit.errorResponseMessage must be a string`;
601601
}
602+
if (option.zone && !['global', 'session', 'user', 'ip'].includes(option.zone)) {
603+
throw `rateLimit.zone must be one of global, session, user or ip`;
604+
}
602605
}
603606
}
604607

src/Options/Definitions.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,11 @@ module.exports.RateLimitOptions = {
586586
'The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied.',
587587
action: parsers.numberParser('requestTimeWindow'),
588588
},
589+
zone: {
590+
env: 'PARSE_SERVER_RATE_LIMIT_ZONE',
591+
help:
592+
"The type of rate limit to apply. The following types are supported:- `global`: rate limit based on the number of requests made by all users- `ip`: rate limit based on the IP address of the request- `user`: rate limit based on the user ID of the request- `session`: rate limit based on the session token of the request:default: 'ip'",
593+
},
589594
};
590595
module.exports.SecurityOptions = {
591596
checkGroups: {

src/Options/docs.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,15 @@ export interface RateLimitOptions {
323323
/* 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.
324324
*/
325325
redisUrl: ?string;
326+
/*
327+
The type of rate limit to apply. The following types are supported:
328+
- `global`: rate limit based on the number of requests made by all users
329+
- `ip`: rate limit based on the IP address of the request
330+
- `user`: rate limit based on the user ID of the request
331+
- `session`: rate limit based on the session token of the request
332+
:default: 'ip'
333+
*/
334+
zone: ?string;
326335
}
327336

328337
export interface SecurityOptions {

src/middlewares.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,22 @@ export const addRateLimit = (route, config, cloud) => {
540540
}
541541
return request.auth?.isMaster;
542542
},
543-
keyGenerator: request => {
543+
keyGenerator: async request => {
544+
if (route.zone === 'global') {
545+
return request.config.appId;
546+
}
547+
const token = request.info.sessionToken;
548+
if (route.zone === 'session' && token) {
549+
return token;
550+
}
551+
if (route.zone === 'user' && token) {
552+
if (!request.auth) {
553+
await new Promise(resolve => handleParseSession(request, null, resolve));
554+
}
555+
if (request.auth?.user?.id && request.zone === 'user') {
556+
return request.auth.user.id;
557+
}
558+
}
544559
return request.config.ip;
545560
},
546561
store: redisStore.store,

0 commit comments

Comments
 (0)