From b217bb35dae17b7462b5cdfd696575bfa86eae5f Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 14 Apr 2023 15:14:53 +1000 Subject: [PATCH 1/6] feat: create rateLimit `zone` to rate limit depending on global, ip, sessionToken, userId --- spec/RateLimit.spec.js | 98 ++++++++++++++++++++++++++++++++++++++ src/Config.js | 3 ++ src/Options/Definitions.js | 5 ++ src/Options/docs.js | 1 + src/Options/index.js | 9 ++++ src/middlewares.js | 17 ++++++- 6 files changed, 132 insertions(+), 1 deletion(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 894c8fcf82..78adf447b6 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -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: '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: '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: '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); @@ -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: [ diff --git a/src/Config.js b/src/Config.js index 812d28c367..84642a76f3 100644 --- a/src/Config.js +++ b/src/Config.js @@ -599,6 +599,9 @@ export class Config { if (option.errorResponseMessage && typeof option.errorResponseMessage !== 'string') { throw `rateLimit.errorResponseMessage must be a string`; } + if (option.zone && !['global', 'session', 'user', 'ip'].includes(option.zone)) { + throw `rateLimit.zone must be one of global, session, user or ip`; + } } } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index b2f0542256..0370929d62 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -586,6 +586,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:- `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'", + }, }; module.exports.SecurityOptions = { checkGroups: { diff --git a/src/Options/docs.js b/src/Options/docs.js index 1ab8c03d58..1ee922c5af 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -109,6 +109,7 @@ * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html * @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. + * @property {String} zone 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' */ /** diff --git a/src/Options/index.js b/src/Options/index.js index a4d83f94fc..f13aa899d4 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -323,6 +323,15 @@ 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: + - `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' + */ + zone: ?string; } export interface SecurityOptions { diff --git a/src/middlewares.js b/src/middlewares.js index 0dca33135e..ce52576274 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -540,7 +540,22 @@ export const addRateLimit = (route, config, cloud) => { } return request.auth?.isMaster; }, - keyGenerator: request => { + keyGenerator: async request => { + if (route.zone === 'global') { + return request.config.appId; + } + const token = request.info.sessionToken; + if (route.zone === 'session' && token) { + return token; + } + if (route.zone === '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, From acaa31a8ea606e2dd2657a6f5d9361996b64c6e2 Mon Sep 17 00:00:00 2001 From: dblythy Date: Fri, 14 Apr 2023 15:19:35 +1000 Subject: [PATCH 2/6] defintions --- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 10 ++++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 0370929d62..e43d0dc077 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -589,7 +589,7 @@ module.exports.RateLimitOptions = { zone: { env: 'PARSE_SERVER_RATE_LIMIT_ZONE', help: - "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'", + "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'", }, }; module.exports.SecurityOptions = { diff --git a/src/Options/docs.js b/src/Options/docs.js index 1ee922c5af..1e86096892 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -109,7 +109,7 @@ * @property {String[]} requestMethods Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. * @property {String} requestPath The path of the API route to be rate limited. Route paths, in combination with a request method, define the endpoints at which requests can be made. Route paths can be strings, string patterns, or regular expression. See: https://expressjs.com/en/guide/routing.html * @property {Number} requestTimeWindow The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. - * @property {String} zone 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' + * @property {String} zone 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' */ /** diff --git a/src/Options/index.js b/src/Options/index.js index f13aa899d4..08202c379f 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -325,10 +325,12 @@ export interface RateLimitOptions { redisUrl: ?string; /* 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 +

+ - `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' */ zone: ?string; From 3413392ba669770aa5f0a6fe0726600ecdb0e515 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 16 May 2023 18:05:45 +1000 Subject: [PATCH 3/6] wip --- spec/RateLimit.spec.js | 6 +++--- src/ParseServer.js | 4 +++- src/cloud-code/Parse.Server.js | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) create mode 100644 src/cloud-code/Parse.Server.js diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 78adf447b6..1034c40e23 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -345,7 +345,7 @@ describe('rate limit', () => { requestCount: 1, errorResponseMessage: 'Too many requests', includeInternalRequests: true, - zone: 'global', + zone: Parse.Server.RateLimitZone.global, }, }); const fakeReq = { @@ -391,7 +391,7 @@ describe('rate limit', () => { requestCount: 1, errorResponseMessage: 'Too many requests', includeInternalRequests: true, - zone: 'session', + zone: Parse.Server.RateLimitZone.session, }, }); Parse.Cloud.define('test', () => 'Abc'); @@ -412,7 +412,7 @@ describe('rate limit', () => { requestCount: 1, errorResponseMessage: 'Too many requests', includeInternalRequests: true, - zone: 'user', + zone: Parse.Server.RateLimitZone.user, }, }); Parse.Cloud.define('test', () => 'Abc'); diff --git a/src/ParseServer.js b/src/ParseServer.js index 04379ecfd3..d9ec60ee64 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -438,9 +438,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; diff --git a/src/cloud-code/Parse.Server.js b/src/cloud-code/Parse.Server.js new file mode 100644 index 0000000000..71295618f2 --- /dev/null +++ b/src/cloud-code/Parse.Server.js @@ -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; From 5d8364482b2c81d1fcec0bab6b2ac3645e89e518 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 16 May 2023 18:06:57 +1000 Subject: [PATCH 4/6] Update middlewares.js --- src/middlewares.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/middlewares.js b/src/middlewares.js index ce52576274..7d523dce8c 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -541,14 +541,14 @@ export const addRateLimit = (route, config, cloud) => { return request.auth?.isMaster; }, keyGenerator: async request => { - if (route.zone === 'global') { + if (route.zone === Parse.Server.RateLimitOptions.global) { return request.config.appId; } const token = request.info.sessionToken; - if (route.zone === 'session' && token) { + if (route.zone === Parse.Server.RateLimitOptions.session && token) { return token; } - if (route.zone === 'user' && token) { + if (route.zone === Parse.Server.RateLimitOptions.user && token) { if (!request.auth) { await new Promise(resolve => handleParseSession(request, null, resolve)); } From 63b517486e79aa50663ea5e4a6d8d5bce1aa6e3e Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 16 May 2023 18:28:43 +1000 Subject: [PATCH 5/6] tests --- spec/CloudCode.spec.js | 3 ++- src/middlewares.js | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index c02999ad51..f07d38541a 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -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'); diff --git a/src/middlewares.js b/src/middlewares.js index be95f11e4e..d672ba840d 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -546,14 +546,14 @@ export const addRateLimit = (route, config, cloud) => { return request.auth?.isMaster; }, keyGenerator: async request => { - if (route.zone === Parse.Server.RateLimitOptions.global) { + if (route.zone === Parse.Server.RateLimitZone.global) { return request.config.appId; } const token = request.info.sessionToken; - if (route.zone === Parse.Server.RateLimitOptions.session && token) { + if (route.zone === Parse.Server.RateLimitZone.session && token) { return token; } - if (route.zone === Parse.Server.RateLimitOptions.user && token) { + if (route.zone === Parse.Server.RateLimitZone.user && token) { if (!request.auth) { await new Promise(resolve => handleParseSession(request, null, resolve)); } From 5a6ba116f734e8f9d15e52de2f45904272748810 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 16 May 2023 18:54:08 +1000 Subject: [PATCH 6/6] wip --- spec/RateLimit.spec.js | 2 +- src/Config.js | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 1034c40e23..3c57810702 100644 --- a/spec/RateLimit.spec.js +++ b/spec/RateLimit.spec.js @@ -447,7 +447,7 @@ describe('rate limit', () => { validateRateLimit({ rateLimit: [{ requestPath: 'a', requestTimeWindow: 1000, requestCount: 3, zone: 'abc' }], }) - ).toThrow('rateLimit.zone must be one of global, session, user or ip'); + ).toThrow('rateLimit.zone must be one of global, session, user, or ip'); expect(() => validateRateLimit({ rateLimit: [ diff --git a/src/Config.js b/src/Config.js index 84642a76f3..1137e690ed 100644 --- a/src/Config.js +++ b/src/Config.js @@ -18,6 +18,7 @@ import { SchemaOptions, SecurityOptions, } from './Options/Definitions'; +import ParseServer from './cloud-code/Parse.Server'; function removeTrailingSlash(str) { if (!str) { @@ -599,8 +600,10 @@ export class Config { if (option.errorResponseMessage && typeof option.errorResponseMessage !== 'string') { throw `rateLimit.errorResponseMessage must be a string`; } - if (option.zone && !['global', 'session', 'user', 'ip'].includes(option.zone)) { - throw `rateLimit.zone must be one of global, session, user or ip`; + 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)}`; } } }