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

View File

@@ -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;

View File

@@ -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();

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',
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: {
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',

View File

@@ -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.<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 {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`.

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.
: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.<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 {

View File

@@ -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());

View File

@@ -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
);

View File

@@ -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
);
}
};
/**

View File

@@ -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.