feat: Add request rate limiter based on IP address (#8174)

This commit is contained in:
Daniel
2023-01-06 23:39:02 +11:00
committed by GitHub
parent 0eac5dc6d4
commit 6c79f6a69e
13 changed files with 713 additions and 50 deletions

19
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"cors": "2.8.5", "cors": "2.8.5",
"deepcopy": "2.1.0", "deepcopy": "2.1.0",
"express": "4.18.2", "express": "4.18.2",
"express-rate-limit": "6.6.0",
"follow-redirects": "1.15.2", "follow-redirects": "1.15.2",
"graphql": "16.6.0", "graphql": "16.6.0",
"graphql-list-fields": "2.0.2", "graphql-list-fields": "2.0.2",
@@ -39,6 +40,7 @@
"mongodb": "4.10.0", "mongodb": "4.10.0",
"mustache": "4.2.0", "mustache": "4.2.0",
"parse": "3.4.2", "parse": "3.4.2",
"path-to-regexp": "0.1.7",
"pg-monitor": "1.5.0", "pg-monitor": "1.5.0",
"pg-promise": "10.12.1", "pg-promise": "10.12.1",
"pluralize": "8.0.0", "pluralize": "8.0.0",
@@ -7275,6 +7277,17 @@
"node": ">= 0.10.0" "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": { "node_modules/express/node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "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": { "ext": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",

View File

@@ -32,6 +32,7 @@
"cors": "2.8.5", "cors": "2.8.5",
"deepcopy": "2.1.0", "deepcopy": "2.1.0",
"express": "4.18.2", "express": "4.18.2",
"express-rate-limit": "6.6.0",
"follow-redirects": "1.15.2", "follow-redirects": "1.15.2",
"graphql": "16.6.0", "graphql": "16.6.0",
"graphql-list-fields": "2.0.2", "graphql-list-fields": "2.0.2",
@@ -48,6 +49,7 @@
"mongodb": "4.10.0", "mongodb": "4.10.0",
"mustache": "4.2.0", "mustache": "4.2.0",
"parse": "3.4.2", "parse": "3.4.2",
"path-to-regexp": "0.1.7",
"pg-monitor": "1.5.0", "pg-monitor": "1.5.0",
"pg-promise": "10.12.1", "pg-promise": "10.12.1",
"pluralize": "8.0.0", "pluralize": "8.0.0",
@@ -97,8 +99,8 @@
"mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter", "mock-mail-adapter": "file:spec/dependencies/mock-mail-adapter",
"mongodb-runner": "4.8.1", "mongodb-runner": "4.8.1",
"mongodb-version-list": "1.0.0", "mongodb-version-list": "1.0.0",
"node-fetch": "3.2.10",
"node-abort-controller": "3.0.1", "node-abort-controller": "3.0.1",
"node-fetch": "3.2.10",
"nyc": "15.1.0", "nyc": "15.1.0",
"prettier": "2.0.5", "prettier": "2.0.5",
"semantic-release": "17.4.6", "semantic-release": "17.4.6",

View File

@@ -44,6 +44,7 @@ const nestedOptionEnvPrefix = {
SecurityOptions: 'PARSE_SERVER_SECURITY_', SecurityOptions: 'PARSE_SERVER_SECURITY_',
SchemaOptions: 'PARSE_SERVER_SCHEMA_', SchemaOptions: 'PARSE_SERVER_SCHEMA_',
LogLevels: 'PARSE_SERVER_LOG_LEVELS_', LogLevels: 'PARSE_SERVER_LOG_LEVELS_',
RateLimitOptions: 'PARSE_SERVER_RATE_LIMIT_',
}; };
function last(array) { function last(array) {
@@ -111,7 +112,9 @@ function processProperty(property, iface) {
} }
let defaultValue; let defaultValue;
if (defaultLine) { if (defaultLine) {
defaultValue = defaultLine.split(' ')[1]; const defaultArray = defaultLine.split(' ');
defaultArray.shift();
defaultValue = defaultArray.join(' ');
} }
let type = property.value.type; let type = property.value.type;
let isRequired = true; let isRequired = true;

370
spec/RateLimit.spec.js Normal file
View File

@@ -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"`);
});
});

View File

@@ -85,6 +85,7 @@ export class Config {
requestKeywordDenylist, requestKeywordDenylist,
allowExpiredAuthDataToken, allowExpiredAuthDataToken,
logLevels, logLevels,
rateLimit,
}) { }) {
if (masterKey === readOnlyMasterKey) { if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different'); throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -126,6 +127,7 @@ export class Config {
this.validateEnforcePrivateUsers(enforcePrivateUsers); this.validateEnforcePrivateUsers(enforcePrivateUsers);
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
this.validateRequestKeywordDenylist(requestKeywordDenylist); this.validateRequestKeywordDenylist(requestKeywordDenylist);
this.validateRateLimit(rateLimit);
this.validateLogLevels(logLevels); 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() { generateEmailVerifyTokenExpiresAt() {
if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) { if (!this.verifyUserEmails || !this.emailVerifyTokenValidityDuration) {
return undefined; return undefined;

View File

@@ -2,7 +2,7 @@ import corsMiddleware from 'cors';
import { createServer, renderGraphiQL } from '@graphql-yoga/node'; import { createServer, renderGraphiQL } from '@graphql-yoga/node';
import { execute, subscribe } from 'graphql'; import { execute, subscribe } from 'graphql';
import { SubscriptionServer } from 'subscriptions-transport-ws'; import { SubscriptionServer } from 'subscriptions-transport-ws';
import { handleParseErrors, handleParseHeaders } from '../middlewares'; import { handleParseErrors, handleParseHeaders, handleParseSession } from '../middlewares';
import requiredParameter from '../requiredParameter'; import requiredParameter from '../requiredParameter';
import defaultLogger from '../logger'; import defaultLogger from '../logger';
import { ParseGraphQLSchema } from './ParseGraphQLSchema'; import { ParseGraphQLSchema } from './ParseGraphQLSchema';
@@ -82,6 +82,7 @@ class ParseGraphQLServer {
app.use(this.config.graphQLPath, corsMiddleware()); app.use(this.config.graphQLPath, corsMiddleware());
app.use(this.config.graphQLPath, handleParseHeaders); app.use(this.config.graphQLPath, handleParseHeaders);
app.use(this.config.graphQLPath, handleParseSession);
app.use(this.config.graphQLPath, handleParseErrors); app.use(this.config.graphQLPath, handleParseErrors);
app.use(this.config.graphQLPath, async (req, res) => { app.use(this.config.graphQLPath, async (req, res) => {
const server = await this._getServer(); const server = await this._getServer();

View File

@@ -411,6 +411,13 @@ module.exports.ParseServerOptions = {
'Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications', 'Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications',
action: parsers.objectParser, 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.<br><br>\u2139\uFE0F Mind the following limitations:<br>- 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<br>- 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<br>- 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: { readOnlyMasterKey: {
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
help: 'Read-only key, which has the same capabilities as MasterKey without writes', 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', 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 = { module.exports.SecurityOptions = {
checkGroups: { checkGroups: {
env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS', env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS',

View File

@@ -77,6 +77,7 @@
* @property {ProtectedFields} protectedFields Protected fields that should be treated with extra security when fetching details. * @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 {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 {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.<br><br> Mind the following limitations:<br>- 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<br>- 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<br>- 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 {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 {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 * @property {String} restAPIKey Key for REST calls
@@ -96,6 +97,17 @@
* @property {String} webhookKey Key sent with outgoing webhook calls * @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 * @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`. * @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`.

View File

@@ -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. /* 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__"}] */ :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */
requestKeywordDenylist: ?(RequestKeywordDenylist[]); 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.<br><br> Mind the following limitations:<br>- 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<br>- 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<br>- 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 { export interface SecurityOptions {

View File

@@ -179,7 +179,7 @@ class ParseServer {
* Create an express app for the parse server * Create an express app for the parse server
* @param {Object} options let you specify the maxUploadSize when creating the express app */ * @param {Object} options let you specify the maxUploadSize when creating the express app */
static app(options) { 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. // This app serves the Parse API directly.
// It's the equivalent of https://api.parse.com/1 in the hosted Parse API. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API.
var api = express(); var api = express();
@@ -214,6 +214,11 @@ class ParseServer {
api.use(bodyParser.json({ type: '*/*', limit: maxUploadSize })); api.use(bodyParser.json({ type: '*/*', limit: maxUploadSize }));
api.use(middlewares.allowMethodOverride); api.use(middlewares.allowMethodOverride);
api.use(middlewares.handleParseHeaders); 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 }); const appRouter = ParseServer.promiseRouter({ appId });
api.use(appRouter.expressRouter()); api.use(appRouter.expressRouter());

View File

@@ -53,12 +53,14 @@ export class FilesRouter {
limit: maxUploadSize, limit: maxUploadSize,
}), // Allow uploads without Content-Type, or with any Content-Type. }), // Allow uploads without Content-Type, or with any Content-Type.
Middlewares.handleParseHeaders, Middlewares.handleParseHeaders,
Middlewares.handleParseSession,
this.createHandler this.createHandler
); );
router.delete( router.delete(
'/files/:filename', '/files/:filename',
Middlewares.handleParseHeaders, Middlewares.handleParseHeaders,
Middlewares.handleParseSession,
Middlewares.enforceMasterKeyAccess, Middlewares.enforceMasterKeyAccess,
this.deleteHandler this.deleteHandler
); );

View File

@@ -1,6 +1,7 @@
import { Parse } from 'parse/node'; import { Parse } from 'parse/node';
import * as triggers from '../triggers'; import * as triggers from '../triggers';
import Deprecator from '../Deprecator/Deprecator'; import Deprecator from '../Deprecator/Deprecator';
import { addRateLimit } from '../middlewares';
const Config = require('../Config'); const Config = require('../Config');
function isParseObjectConstructor(object) { function isParseObjectConstructor(object) {
@@ -28,6 +29,7 @@ function validateValidator(validator) {
skipWithMasterKey: [Boolean], skipWithMasterKey: [Boolean],
requireUserKeys: [Array, Object], requireUserKeys: [Array, Object],
fields: [Array, Object], fields: [Array, Object],
rateLimit: [Object],
}; };
const getType = fn => { const getType = fn => {
if (Array.isArray(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 /** @namespace
* @name Parse * @name Parse
* @description The Parse SDK. * @description The Parse SDK.
@@ -111,6 +125,12 @@ var ParseCloud = {};
ParseCloud.define = function (functionName, handler, validationHandler) { ParseCloud.define = function (functionName, handler, validationHandler) {
validateValidator(validationHandler); validateValidator(validationHandler);
triggers.addFunction(functionName, handler, validationHandler, Parse.applicationId); 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, Parse.applicationId,
validationHandler 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, Parse.applicationId,
validationHandler 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 * @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}; * @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'; let className = '_User';
if (typeof handler === 'string' || isParseObjectConstructor(handler)) { if (typeof handler === 'string' || isParseObjectConstructor(handler)) {
// validation will occur downstream, this is to maintain internal // validation will occur downstream, this is to maintain internal
// code consistency with the other hook types. // code consistency with the other hook types.
className = triggers.getClassName(handler); className = triggers.getClassName(handler);
handler = arguments[1]; handler = arguments[1];
validationHandler = arguments.length >= 2 ? arguments[2] : null;
} }
triggers.addTrigger(triggers.Types.beforeLogin, className, handler, Parse.applicationId); 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, Parse.applicationId,
validationHandler validationHandler
); );
if (validationHandler && validationHandler.rateLimit) {
addRateLimit(
{
requestPath: getRoute(className),
requestMethods: 'GET',
...validationHandler.rateLimit,
},
Parse.applicationId
);
}
}; };
/** /**

View File

@@ -7,6 +7,9 @@ import defaultLogger from './logger';
import rest from './rest'; import rest from './rest';
import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter'; import MongoStorageAdapter from './Adapters/Storage/Mongo/MongoStorageAdapter';
import PostgresStorageAdapter from './Adapters/Storage/Postgres/PostgresStorageAdapter'; 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'; import ipRangeCheck from 'ip-range-check';
export const DEFAULT_ALLOWED_HEADERS = export const DEFAULT_ALLOWED_HEADERS =
@@ -189,8 +192,7 @@ export function handleParseHeaders(req, res, next) {
installationId: info.installationId, installationId: info.installationId,
isMaster: true, isMaster: true,
}); });
next(); return handleRateLimit(req, res, next);
return;
} }
var isReadOnlyMaster = info.masterKey === req.config.readOnlyMasterKey; var isReadOnlyMaster = info.masterKey === req.config.readOnlyMasterKey;
@@ -205,8 +207,7 @@ export function handleParseHeaders(req, res, next) {
isMaster: true, isMaster: true,
isReadOnly: true, isReadOnly: true,
}); });
next(); return handleRateLimit(req, res, next);
return;
} }
// Client keys are not required in parse-server, but if any have been configured in the server, validate them // 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, isMaster: false,
user: req.userFromJWT, user: req.userFromJWT,
}); });
next(); return handleRateLimit(req, res, next);
return;
} }
if (!info.sessionToken) { if (!info.sessionToken) {
@@ -244,48 +244,70 @@ export function handleParseHeaders(req, res, next) {
installationId: info.installationId, installationId: info.installationId,
isMaster: false, 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; return;
} }
next();
};
return Promise.resolve() export const handleParseSession = async (req, res, next) => {
.then(() => { try {
// handle the upgradeToRevocableSession path on it's own const info = req.info;
if ( if (req.auth) {
info.sessionToken && next();
req.url === '/upgradeToRevocableSession' && return;
info.sessionToken.indexOf('r:') != 0 }
) { let requestAuth = null;
return auth.getAuthForLegacySessionToken({ if (
config: req.config, info.sessionToken &&
installationId: info.installationId, req.url === '/upgradeToRevocableSession' &&
sessionToken: info.sessionToken, info.sessionToken.indexOf('r:') != 0
}); ) {
} else { requestAuth = await auth.getAuthForLegacySessionToken({
return auth.getAuthForSessionToken({ config: req.config,
config: req.config, installationId: info.installationId,
installationId: info.installationId, sessionToken: info.sessionToken,
sessionToken: info.sessionToken, });
}); } else {
} requestAuth = await auth.getAuthForSessionToken({
}) config: req.config,
.then(auth => { installationId: info.installationId,
if (auth) { sessionToken: info.sessionToken,
req.auth = auth; });
next(); }
} req.auth = requestAuth;
}) next();
.catch(error => { } catch (error) {
if (error instanceof Parse.Error) { if (error instanceof Parse.Error) {
next(error); next(error);
return; return;
} else { }
// TODO: Determine the correct error scenario. // TODO: Determine the correct error scenario.
req.config.loggerController.error('error getting auth for sessionToken', error); req.config.loggerController.error('error getting auth for sessionToken', error);
throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error); throw new Parse.Error(Parse.Error.UNKNOWN_ERROR, error);
} }
}); };
}
function getClientIp(req) { function getClientIp(req) {
return req.ip; return req.ip;
@@ -417,6 +439,56 @@ export function promiseEnforceMasterKeyAccess(request) {
return Promise.resolve(); 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 * 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. * in the request header. If a request has no request ID, it is executed anyway.