From 6c79f6a69e25e47846e3b0685d6bdfd6b91086b1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Jan 2023 23:39:02 +1100 Subject: [PATCH] feat: Add request rate limiter based on IP address (#8174) --- package-lock.json | 19 ++ package.json | 4 +- resources/buildConfigDefinitions.js | 5 +- spec/RateLimit.spec.js | 370 ++++++++++++++++++++++++++++ src/Config.js | 44 ++++ src/GraphQL/ParseGraphQLServer.js | 3 +- src/Options/Definitions.js | 53 ++++ src/Options/docs.js | 12 + src/Options/index.js | 23 ++ src/ParseServer.js | 7 +- src/Routers/FilesRouter.js | 2 + src/cloud-code/Parse.Cloud.js | 59 ++++- src/middlewares.js | 162 ++++++++---- 13 files changed, 713 insertions(+), 50 deletions(-) create mode 100644 spec/RateLimit.spec.js diff --git a/package-lock.json b/package-lock.json index 7815162b39..0fd72f4ad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "cors": "2.8.5", "deepcopy": "2.1.0", "express": "4.18.2", + "express-rate-limit": "6.6.0", "follow-redirects": "1.15.2", "graphql": "16.6.0", "graphql-list-fields": "2.0.2", @@ -39,6 +40,7 @@ "mongodb": "4.10.0", "mustache": "4.2.0", "parse": "3.4.2", + "path-to-regexp": "0.1.7", "pg-monitor": "1.5.0", "pg-promise": "10.12.1", "pluralize": "8.0.0", @@ -7275,6 +7277,17 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.6.0.tgz", + "integrity": "sha512-HFN2+4ZGdkQOS8Qli4z6knmJFnw6lZed67o6b7RGplWeb1Z0s8VXaj3dUgPIdm9hrhZXTRpCTHXA0/2Eqex0vA==", + "engines": { + "node": ">= 12.9.0" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -26197,6 +26210,12 @@ } } }, + "express-rate-limit": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.6.0.tgz", + "integrity": "sha512-HFN2+4ZGdkQOS8Qli4z6knmJFnw6lZed67o6b7RGplWeb1Z0s8VXaj3dUgPIdm9hrhZXTRpCTHXA0/2Eqex0vA==", + "requires": {} + }, "ext": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", diff --git a/package.json b/package.json index b5f2957913..0b748588c3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "cors": "2.8.5", "deepcopy": "2.1.0", "express": "4.18.2", + "express-rate-limit": "6.6.0", "follow-redirects": "1.15.2", "graphql": "16.6.0", "graphql-list-fields": "2.0.2", @@ -48,6 +49,7 @@ "mongodb": "4.10.0", "mustache": "4.2.0", "parse": "3.4.2", + "path-to-regexp": "0.1.7", "pg-monitor": "1.5.0", "pg-promise": "10.12.1", "pluralize": "8.0.0", @@ -97,8 +99,8 @@ "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", "mongodb-runner": "4.8.1", "mongodb-version-list": "1.0.0", - "node-fetch": "3.2.10", "node-abort-controller": "3.0.1", + "node-fetch": "3.2.10", "nyc": "15.1.0", "prettier": "2.0.5", "semantic-release": "17.4.6", diff --git a/resources/buildConfigDefinitions.js b/resources/buildConfigDefinitions.js index 7387d1eeba..3a69217016 100644 --- a/resources/buildConfigDefinitions.js +++ b/resources/buildConfigDefinitions.js @@ -44,6 +44,7 @@ const nestedOptionEnvPrefix = { SecurityOptions: 'PARSE_SERVER_SECURITY_', SchemaOptions: 'PARSE_SERVER_SCHEMA_', LogLevels: 'PARSE_SERVER_LOG_LEVELS_', + RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_', }; function last(array) { @@ -111,7 +112,9 @@ function processProperty(property, iface) { } let defaultValue; if (defaultLine) { - defaultValue = defaultLine.split(' ')[1]; + const defaultArray = defaultLine.split(' '); + defaultArray.shift(); + defaultValue = defaultArray.join(' '); } let type = property.value.type; let isRequired = true; diff --git a/spec/RateLimit.spec.js b/spec/RateLimit.spec.js new file mode 100644 index 0000000000..60aff61381 --- /dev/null +++ b/spec/RateLimit.spec.js @@ -0,0 +1,370 @@ +describe('rate limit', () => { + it('can limit cloud functions', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can add global limit', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: { + requestPath: '*', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + await expectAsync(new Parse.Object('Test').save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can limit cloud with validator', async () => { + Parse.Cloud.define('test', () => 'Abc', { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can skip with masterKey', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response1 = await Parse.Cloud.run('test', null, { useMasterKey: true }); + expect(response1).toBe('Abc'); + const response2 = await Parse.Cloud.run('test', null, { useMasterKey: true }); + expect(response2).toBe('Abc'); + }); + + it('should run with masterKey', async () => { + Parse.Cloud.define('test', () => 'Abc'); + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*', + requestTimeWindow: 10000, + requestCount: 1, + includeMasterKey: true, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const response1 = await Parse.Cloud.run('test', null, { useMasterKey: true }); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can limit saving objects', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await expectAsync(obj.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set method to post', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/*', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'POST', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await obj.save(); + const obj2 = new Parse.Object('Test'); + await expectAsync(obj2.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can use a validator for post', async () => { + Parse.Cloud.beforeSave('Test', () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await expectAsync(obj.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can use a validator for file', async () => { + Parse.Cloud.beforeSave(Parse.File, () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const file = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + await file.save(); + const file2 = new Parse.File('yolo.txt', [1, 2, 3], 'text/plain'); + await expectAsync(file2.save()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set method to get', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'GET', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await obj.save(); + await new Parse.Query('Test').first(); + await expectAsync(new Parse.Query('Test').first()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can use a validator', async () => { + await reconfigureServer({ silent: false }); + Parse.Cloud.beforeFind('TestObject', () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + const obj = new Parse.Object('TestObject'); + await obj.save(); + await obj.save(); + await new Parse.Query('TestObject').first(); + await expectAsync(new Parse.Query('TestObject').first()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + await expectAsync(new Parse.Query('TestObject').get('abc')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set method to delete', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/classes/Test/*', + requestTimeWindow: 10000, + requestCount: 1, + requestMethods: 'DELETE', + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + const obj = new Parse.Object('Test'); + await obj.save(); + await obj.destroy(); + await expectAsync(obj.destroy()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set beforeDelete', async () => { + const obj = new Parse.Object('TestDelete'); + await obj.save(); + Parse.Cloud.beforeDelete('TestDelete', () => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await obj.destroy(); + await expectAsync(obj.destroy()).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can set beforeLogin', async () => { + Parse.Cloud.beforeLogin(() => {}, { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + }); + await Parse.User.signUp('myUser', 'password'); + await Parse.User.logIn('myUser', 'password'); + await expectAsync(Parse.User.logIn('myUser', 'password')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests') + ); + }); + + it('can define limits via rateLimit and define', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*', + requestTimeWindow: 10000, + requestCount: 100, + errorResponseMessage: 'Too many requests', + includeInternalRequests: true, + }, + ], + }); + Parse.Cloud.define('test', () => 'Abc', { + rateLimit: { + requestTimeWindow: 10000, + requestCount: 1, + includeInternalRequests: true, + }, + }); + const response1 = await Parse.Cloud.run('test'); + expect(response1).toBe('Abc'); + await expectAsync(Parse.Cloud.run('test')).toBeRejectedWith( + new Parse.Error(Parse.Error.CONNECTION_FAILED, 'Too many requests.') + ); + }); + + it('does not limit internal calls', async () => { + await reconfigureServer({ + rateLimit: [ + { + requestPath: '/functions/*', + requestTimeWindow: 10000, + requestCount: 1, + errorResponseMessage: 'Too many requests', + }, + ], + }); + Parse.Cloud.define('test1', () => 'Abc'); + Parse.Cloud.define('test2', async () => { + await Parse.Cloud.run('test1'); + await Parse.Cloud.run('test1'); + }); + await Parse.Cloud.run('test2'); + }); + + it('can validate rateLimit', async () => { + const Config = require('../lib/Config'); + const validateRateLimit = ({ rateLimit }) => Config.validateRateLimit(rateLimit); + expect(() => + validateRateLimit({ rateLimit: 'a', requestTimeWindow: 1000, requestCount: 3 }) + ).toThrow('rateLimit must be an array or object'); + expect(() => validateRateLimit({ rateLimit: ['a'] })).toThrow( + 'rateLimit must be an array of objects' + ); + expect(() => validateRateLimit({ rateLimit: [{ requestPath: [] }] })).toThrow( + 'rateLimit.requestPath must be a string' + ); + expect(() => + validateRateLimit({ rateLimit: [{ requestTimeWindow: [], requestPath: 'a' }] }) + ).toThrow('rateLimit.requestTimeWindow must be a number'); + expect(() => + validateRateLimit({ + rateLimit: [ + { + includeInternalRequests: [], + requestTimeWindow: 1000, + requestCount: 3, + requestPath: 'a', + }, + ], + }) + ).toThrow('rateLimit.includeInternalRequests must be a boolean'); + expect(() => + validateRateLimit({ + rateLimit: [{ requestCount: [], requestTimeWindow: 1000, requestPath: 'a' }], + }) + ).toThrow('rateLimit.requestCount must be a number'); + expect(() => + validateRateLimit({ + rateLimit: [ + { errorResponseMessage: [], requestTimeWindow: 1000, requestCount: 3, requestPath: 'a' }, + ], + }) + ).toThrow('rateLimit.errorResponseMessage must be a string'); + expect(() => + validateRateLimit({ rateLimit: [{ requestCount: 3, requestPath: 'abc' }] }) + ).toThrow('rateLimit.requestTimeWindow must be defined'); + expect(() => + validateRateLimit({ rateLimit: [{ requestTimeWindow: 3, requestPath: 'abc' }] }) + ).toThrow('rateLimit.requestCount must be defined'); + expect(() => + validateRateLimit({ rateLimit: [{ requestTimeWindow: 3, requestCount: 'abc' }] }) + ).toThrow('rateLimit.requestPath must be defined'); + await expectAsync( + reconfigureServer({ + rateLimit: [{ requestTimeWindow: 3, requestCount: 1, path: 'abc', requestPath: 'a' }], + }) + ).toBeRejectedWith(`Invalid rate limit option "path"`); + }); +}); diff --git a/src/Config.js b/src/Config.js index f7deb7bcdf..a62cb5e393 100644 --- a/src/Config.js +++ b/src/Config.js @@ -85,6 +85,7 @@ export class Config { requestKeywordDenylist, allowExpiredAuthDataToken, logLevels, + rateLimit, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -126,6 +127,7 @@ export class Config { this.validateEnforcePrivateUsers(enforcePrivateUsers); this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateRequestKeywordDenylist(requestKeywordDenylist); + this.validateRateLimit(rateLimit); this.validateLogLevels(logLevels); } @@ -517,6 +519,48 @@ export class Config { } } + static validateRateLimit(rateLimit) { + if (!rateLimit) { + return; + } + if ( + Object.prototype.toString.call(rateLimit) !== '[object Object]' && + !Array.isArray(rateLimit) + ) { + throw `rateLimit must be an array or object`; + } + const options = Array.isArray(rateLimit) ? rateLimit : [rateLimit]; + for (const option of options) { + if (Object.prototype.toString.call(option) !== '[object Object]') { + throw `rateLimit must be an array of objects`; + } + if (option.requestPath == null) { + throw `rateLimit.requestPath must be defined`; + } + if (typeof option.requestPath !== 'string') { + throw `rateLimit.requestPath must be a string`; + } + if (option.requestTimeWindow == null) { + throw `rateLimit.requestTimeWindow must be defined`; + } + if (typeof option.requestTimeWindow !== 'number') { + throw `rateLimit.requestTimeWindow must be a number`; + } + if (option.includeInternalRequests && typeof option.includeInternalRequests !== 'boolean') { + throw `rateLimit.includeInternalRequests must be a boolean`; + } + if (option.requestCount == null) { + throw `rateLimit.requestCount must be defined`; + } + if (typeof option.requestCount !== 'number') { + throw `rateLimit.requestCount must be a number`; + } + if (option.errorResponseMessage && typeof option.errorResponseMessage !== 'string') { + throw `rateLimit.errorResponseMessage must be a string`; + } + } + } + generateEmailVerifyTokenExpiresAt() { if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) { return undefined; diff --git a/src/GraphQL/ParseGraphQLServer.js b/src/GraphQL/ParseGraphQLServer.js index 7ab03fc1be..44ea34f47b 100644 --- a/src/GraphQL/ParseGraphQLServer.js +++ b/src/GraphQL/ParseGraphQLServer.js @@ -2,7 +2,7 @@ import corsMiddleware from 'cors'; import { createServer, renderGraphiQL } from '@graphql-yoga/node'; import { execute, subscribe } from 'graphql'; import { SubscriptionServer } from 'subscriptions-transport-ws'; -import { handleParseErrors, handleParseHeaders } from '../middlewares'; +import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares'; import requiredParameter from '../requiredParameter'; import defaultLogger from '../logger'; import { ParseGraphQLSchema } from './ParseGraphQLSchema'; @@ -82,6 +82,7 @@ class ParseGraphQLServer { app.use(this.config.graphQLPath, corsMiddleware()); app.use(this.config.graphQLPath, handleParseHeaders); + app.use(this.config.graphQLPath, handleParseSession); app.use(this.config.graphQLPath, handleParseErrors); app.use(this.config.graphQLPath, async (req, res) => { const server = await this._getServer(); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index dda9c38ca5..7ba817cd6e 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -411,6 +411,13 @@ module.exports.ParseServerOptions = { 'Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications', action: parsers.objectParser, }, + rateLimit: { + env: 'PARSE_SERVER_RATE_LIMIT', + help: + "Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

\u2139\uFE0F Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case.", + action: parsers.arrayParser, + default: [], + }, readOnlyMasterKey: { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', help: 'Read-only key, which has the same capabilities as MasterKey without writes', @@ -516,6 +523,52 @@ module.exports.ParseServerOptions = { help: 'Key sent with outgoing webhook calls', }, }; +module.exports.RateLimitOptions = { + errorResponseMessage: { + env: 'PARSE_SERVER_RATE_LIMIT_ERROR_RESPONSE_MESSAGE', + help: + 'The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`.', + default: 'Too many requests.', + }, + includeInternalRequests: { + env: 'PARSE_SERVER_RATE_LIMIT_INCLUDE_INTERNAL_REQUESTS', + help: + 'Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks.', + action: parsers.booleanParser, + default: false, + }, + includeMasterKey: { + env: 'PARSE_SERVER_RATE_LIMIT_INCLUDE_MASTER_KEY', + help: + 'Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks.', + action: parsers.booleanParser, + default: false, + }, + requestCount: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_COUNT', + help: + 'The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied.', + action: parsers.numberParser('requestCount'), + }, + requestMethods: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_METHODS', + help: + 'Optional, the HTTP request methods to which the rate limit should be applied, default is all methods.', + action: parsers.arrayParser, + }, + requestPath: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_PATH', + help: + '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', + required: true, + }, + requestTimeWindow: { + env: 'PARSE_SERVER_RATE_LIMIT_REQUEST_TIME_WINDOW', + help: + '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'), + }, +}; module.exports.SecurityOptions = { checkGroups: { env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', diff --git a/src/Options/docs.js b/src/Options/docs.js index 12d6e50f8e..bfdb609fcc 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -77,6 +77,7 @@ * @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details. * @property {String} publicServerURL Public URL to your parse server with http:// or https://. * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications + * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls @@ -96,6 +97,17 @@ * @property {String} webhookKey Key sent with outgoing webhook calls */ +/** + * @interface RateLimitOptions + * @property {String} errorResponseMessage The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`. + * @property {Boolean} includeInternalRequests Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks. + * @property {Boolean} includeMasterKey Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. + * @property {Number} requestCount The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. + * @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. + */ + /** * @interface SecurityOptions * @property {CheckGroup[]} checkGroups The security check groups to run. This allows to add custom security checks or override existing ones. Default are the groups defined in `CheckGroups.js`. diff --git a/src/Options/index.js b/src/Options/index.js index 3e60ab3cac..4cf6e377ec 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -292,6 +292,29 @@ export interface ParseServerOptions { /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ requestKeywordDenylist: ?(RequestKeywordDenylist[]); + /* Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. + :DEFAULT: [] */ + rateLimit: ?(RateLimitOptions[]); +} + +export interface RateLimitOptions { + /* 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 */ + requestPath: string; + /* The window of time in milliseconds within which the number of requests set in `requestCount` can be made before the rate limit is applied. */ + requestTimeWindow: ?number; + /* The number of requests that can be made per IP address within the time window set in `requestTimeWindow` before the rate limit is applied. */ + requestCount: ?number; + /* The error message that should be returned in the body of the HTTP 429 response when the rate limit is hit. Default is `Too many requests.`. + :DEFAULT: Too many requests. */ + errorResponseMessage: ?string; + /* Optional, the HTTP request methods to which the rate limit should be applied, default is all methods. */ + requestMethods: ?(string[]); + /* Optional, if `true` the rate limit will also apply to requests using the `masterKey`, default is `false`. Note that a public Cloud Code function that triggers internal requests using the `masterKey` may circumvent rate limiting and be vulnerable to attacks. + :DEFAULT: false */ + includeMasterKey: ?boolean; + /* Optional, if `true` the rate limit will also apply to requests that are made in by Cloud Code, default is `false`. Note that a public Cloud Code function that triggers internal requests may circumvent rate limiting and be vulnerable to attacks. + :DEFAULT: false */ + includeInternalRequests: ?boolean; } export interface SecurityOptions { diff --git a/src/ParseServer.js b/src/ParseServer.js index e4f3fad586..07f06fa1fa 100644 --- a/src/ParseServer.js +++ b/src/ParseServer.js @@ -179,7 +179,7 @@ class ParseServer { * Create an express app for the parse server * @param {Object} options let you specify the maxUploadSize when creating the express app */ static app(options) { - const { maxUploadSize = '20mb', appId, directAccess, pages } = options; + const { maxUploadSize = '20mb', appId, directAccess, pages, rateLimit = [] } = options; // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); @@ -214,6 +214,11 @@ class ParseServer { api.use(bodyParser.json({ type: '*/*', limit: maxUploadSize })); api.use(middlewares.allowMethodOverride); api.use(middlewares.handleParseHeaders); + const routes = Array.isArray(rateLimit) ? rateLimit : [rateLimit]; + for (const route of routes) { + middlewares.addRateLimit(route, options); + } + api.use(middlewares.handleParseSession); const appRouter = ParseServer.promiseRouter({ appId }); api.use(appRouter.expressRouter()); diff --git a/src/Routers/FilesRouter.js b/src/Routers/FilesRouter.js index 08576de5e7..e911d772a4 100644 --- a/src/Routers/FilesRouter.js +++ b/src/Routers/FilesRouter.js @@ -53,12 +53,14 @@ export class FilesRouter { limit: maxUploadSize, }), // Allow uploads without Content-Type, or with any Content-Type. Middlewares.handleParseHeaders, + Middlewares.handleParseSession, this.createHandler ); router.delete( '/files/:filename', Middlewares.handleParseHeaders, + Middlewares.handleParseSession, Middlewares.enforceMasterKeyAccess, this.deleteHandler ); diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index c71fbc7c52..75faf44f3d 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -1,6 +1,7 @@ import { Parse } from 'parse/node'; import * as triggers from '../triggers'; import Deprecator from '../Deprecator/Deprecator'; +import { addRateLimit } from '../middlewares'; const Config = require('../Config'); function isParseObjectConstructor(object) { @@ -28,6 +29,7 @@ function validateValidator(validator) { skipWithMasterKey: [Boolean], requireUserKeys: [Array, Object], fields: [Array, Object], + rateLimit: [Object], }; const getType = fn => { if (Array.isArray(fn)) { @@ -72,6 +74,18 @@ function validateValidator(validator) { } } } +const getRoute = parseClass => { + const route = + { + _User: 'users', + _Session: 'sessions', + '@File': 'files', + }[parseClass] || 'classes'; + if (parseClass === '@File') { + return `/${route}/:id?*`; + } + return `/${route}/${parseClass}/:id?*`; +}; /** @namespace * @name Parse * @description The Parse SDK. @@ -111,6 +125,12 @@ var ParseCloud = {}; ParseCloud.define = function (functionName, handler, validationHandler) { validateValidator(validationHandler); triggers.addFunction(functionName, handler, validationHandler, Parse.applicationId); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { requestPath: `/functions/${functionName}`, ...validationHandler.rateLimit }, + Parse.applicationId + ); + } }; /** @@ -164,6 +184,16 @@ ParseCloud.beforeSave = function (parseClass, handler, validationHandler) { Parse.applicationId, validationHandler ); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { + requestPath: getRoute(className), + requestMethods: ['POST', 'PUT'], + ...validationHandler.rateLimit, + }, + Parse.applicationId + ); + } }; /** @@ -200,6 +230,16 @@ ParseCloud.beforeDelete = function (parseClass, handler, validationHandler) { Parse.applicationId, validationHandler ); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { + requestPath: getRoute(className), + requestMethods: 'DELETE', + ...validationHandler.rateLimit, + }, + Parse.applicationId + ); + } }; /** @@ -225,15 +265,22 @@ ParseCloud.beforeDelete = function (parseClass, handler, validationHandler) { * @name Parse.Cloud.beforeLogin * @param {Function} func The function to run before a login. This function can be async and should take one parameter a {@link Parse.Cloud.TriggerRequest}; */ -ParseCloud.beforeLogin = function (handler) { +ParseCloud.beforeLogin = function (handler, validationHandler) { let className = '_User'; if (typeof handler === 'string' || isParseObjectConstructor(handler)) { // validation will occur downstream, this is to maintain internal // code consistency with the other hook types. className = triggers.getClassName(handler); handler = arguments[1]; + validationHandler = arguments.length >= 2 ? arguments[2] : null; } triggers.addTrigger(triggers.Types.beforeLogin, className, handler, Parse.applicationId); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { requestPath: `/login`, requestMethods: 'POST', ...validationHandler.rateLimit }, + Parse.applicationId + ); + } }; /** @@ -402,6 +449,16 @@ ParseCloud.beforeFind = function (parseClass, handler, validationHandler) { Parse.applicationId, validationHandler ); + if (validationHandler && validationHandler.rateLimit) { + addRateLimit( + { + requestPath: getRoute(className), + requestMethods: 'GET', + ...validationHandler.rateLimit, + }, + Parse.applicationId + ); + } }; /** diff --git a/src/middlewares.js b/src/middlewares.js index 60f7938c91..0e525816ba 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -7,6 +7,9 @@ import defaultLogger from './logger'; import rest from './rest'; import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; import PostgresStorageAdapter from './Adapters/Storage/Postgres/PostgresStorageAdapter'; +import rateLimit from 'express-rate-limit'; +import { RateLimitOptions } from './Options/Definitions'; +import pathToRegexp from 'path-to-regexp'; import ipRangeCheck from 'ip-range-check'; export const DEFAULT_ALLOWED_HEADERS = @@ -189,8 +192,7 @@ export function handleParseHeaders(req, res, next) { installationId: info.installationId, isMaster: true, }); - next(); - return; + return handleRateLimit(req, res, next); } var isReadOnlyMaster = info.masterKey === req.config.readOnlyMasterKey; @@ -205,8 +207,7 @@ export function handleParseHeaders(req, res, next) { isMaster: true, isReadOnly: true, }); - next(); - return; + return handleRateLimit(req, res, next); } // Client keys are not required in parse-server, but if any have been configured in the server, validate them @@ -234,8 +235,7 @@ export function handleParseHeaders(req, res, next) { isMaster: false, user: req.userFromJWT, }); - next(); - return; + return handleRateLimit(req, res, next); } if (!info.sessionToken) { @@ -244,48 +244,70 @@ export function handleParseHeaders(req, res, next) { installationId: info.installationId, isMaster: false, }); - next(); + } + handleRateLimit(req, res, next); +} + +const handleRateLimit = async (req, res, next) => { + const rateLimits = req.config.rateLimits || []; + try { + await Promise.all( + rateLimits.map(async limit => { + const pathExp = new RegExp(limit.path); + if (pathExp.test(req.url)) { + await limit.handler(req, res, err => { + if (err) { + throw err; + } + }); + } + }) + ); + } catch (error) { + res.status(429); + res.json({ code: Parse.Error.CONNECTION_FAILED, error }); return; } + next(); +}; - return Promise.resolve() - .then(() => { - // handle the upgradeToRevocableSession path on it's own - if ( - info.sessionToken && - req.url === '/upgradeToRevocableSession' && - info.sessionToken.indexOf('r:') != 0 - ) { - return auth.getAuthForLegacySessionToken({ - config: req.config, - installationId: info.installationId, - sessionToken: info.sessionToken, - }); - } else { - return auth.getAuthForSessionToken({ - config: req.config, - installationId: info.installationId, - sessionToken: info.sessionToken, - }); - } - }) - .then(auth => { - if (auth) { - req.auth = auth; - next(); - } - }) - .catch(error => { - if (error instanceof Parse.Error) { - next(error); - return; - } else { - // TODO: Determine the correct error scenario. - req.config.loggerController.error('error getting auth for sessionToken', error); - throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); - } - }); -} +export const handleParseSession = async (req, res, next) => { + try { + const info = req.info; + if (req.auth) { + next(); + return; + } + let requestAuth = null; + if ( + info.sessionToken && + req.url === '/upgradeToRevocableSession' && + info.sessionToken.indexOf('r:') != 0 + ) { + requestAuth = await auth.getAuthForLegacySessionToken({ + config: req.config, + installationId: info.installationId, + sessionToken: info.sessionToken, + }); + } else { + requestAuth = await auth.getAuthForSessionToken({ + config: req.config, + installationId: info.installationId, + sessionToken: info.sessionToken, + }); + } + req.auth = requestAuth; + next(); + } catch (error) { + if (error instanceof Parse.Error) { + next(error); + return; + } + // TODO: Determine the correct error scenario. + req.config.loggerController.error('error getting auth for sessionToken', error); + throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); + } +}; function getClientIp(req) { return req.ip; @@ -417,6 +439,56 @@ export function promiseEnforceMasterKeyAccess(request) { return Promise.resolve(); } +export const addRateLimit = (route, config) => { + if (typeof config === 'string') { + config = Config.get(config); + } + for (const key in route) { + if (!RateLimitOptions[key]) { + throw `Invalid rate limit option "${key}"`; + } + } + if (!config.rateLimits) { + config.rateLimits = []; + } + config.rateLimits.push({ + path: pathToRegexp(route.requestPath), + handler: rateLimit({ + windowMs: route.requestTimeWindow, + max: route.requestCount, + message: route.errorResponseMessage || RateLimitOptions.errorResponseMessage.default, + handler: (request, response, next, options) => { + throw options.message; + }, + skip: request => { + if (request.ip === '127.0.0.1' && !route.includeInternalRequests) { + return true; + } + if (route.includeMasterKey) { + return false; + } + if (route.requestMethods) { + if (Array.isArray(route.requestMethods)) { + if (!route.requestMethods.includes(request.method)) { + return true; + } + } else { + const regExp = new RegExp(route.requestMethods); + if (!regExp.test(request.method)) { + return true; + } + } + } + return request.auth.isMaster; + }, + keyGenerator: request => { + return request.config.ip; + }, + }), + }); + Config.put(config); +}; + /** * Deduplicates a request to ensure idempotency. Duplicates are determined by the request ID * in the request header. If a request has no request ID, it is executed anyway.