From 03fba97e0549bfcaeee9f2fa4c9905dbcc91840e Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 9 Jun 2023 21:27:56 +1000 Subject: [PATCH] feat: Add zones for rate limiting by `ip`, `user`, `session`, `global` (#8508) --- spec/CloudCode.spec.js | 3 +- spec/RateLimit.spec.js | 98 ++++++++++++++++++++++++++++++++++ src/Config.js | 6 +++ src/Options/Definitions.js | 5 ++ src/Options/docs.js | 1 + src/Options/index.js | 11 ++++ src/ParseServer.js | 4 +- src/cloud-code/Parse.Server.js | 19 +++++++ src/middlewares.js | 17 +++++- 9 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 src/cloud-code/Parse.Server.js diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index e77b1c69..a8795a4e 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/spec/RateLimit.spec.js b/spec/RateLimit.spec.js index 894c8fcf..3c578107 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: 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); @@ -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 f63b5d47..5e3a49bb 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) { @@ -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)}`; + } } } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 31700b4c..3815902c 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -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:

- `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 b1bf31a5..847e7df9 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -111,6 +111,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 492f4323..87813147 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -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: +

+ - `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/ParseServer.js b/src/ParseServer.js index 192ad9c4..6465e1f3 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -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; diff --git a/src/cloud-code/Parse.Server.js b/src/cloud-code/Parse.Server.js new file mode 100644 index 00000000..71295618 --- /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; diff --git a/src/middlewares.js b/src/middlewares.js index b86dafb6..a7e309b0 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -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,