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.