Add security check (#7247)

* added Parse Server security option

* added SecurityRouter

* added Check class

* added CheckGroup class

* moved parameter validation to Utils

* added CheckRunner class

* added auto-run on server start

* added custom security checks as Parse Server option

* renamed script to check

* reformat log output

* added server config check

* improved contributing guideline

* improved contribution guide

* added check security log

* improved log format

* added checks

* fixed log fomat typo

* added database checks

* fixed database check

* removed database auth check in initial version

* improved contribution guide

* added security check tests

* fixed typo

* improved wording guidelines

* improved wording guidelines
This commit is contained in:
Manuel
2021-03-10 20:19:28 +01:00
committed by GitHub
parent 36c2608400
commit bee889a329
17 changed files with 1096 additions and 2 deletions

View File

@@ -11,6 +11,7 @@ import {
FileUploadOptions,
AccountLockoutOptions,
PagesOptions,
SecurityOptions,
} from './Options/Definitions';
import { isBoolean, isString } from 'lodash';
@@ -79,6 +80,7 @@ export class Config {
emailVerifyTokenReuseIfValid,
fileUpload,
pages,
security,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -114,6 +116,23 @@ export class Config {
this.validateAllowHeaders(allowHeaders);
this.validateIdempotencyOptions(idempotencyOptions);
this.validatePagesOptions(pages);
this.validateSecurityOptions(security);
}
static validateSecurityOptions(security) {
if (Object.prototype.toString.call(security) !== '[object Object]') {
throw 'Parse Server option security must be an object.';
}
if (security.enableCheck === undefined) {
security.enableCheck = SecurityOptions.enableCheck.default;
} else if (!isBoolean(security.enableCheck)) {
throw 'Parse Server option security.enableCheck must be a boolean.';
}
if (security.enableCheckLog === undefined) {
security.enableCheckLog = SecurityOptions.enableCheckLog.default;
} else if (!isBoolean(security.enableCheckLog)) {
throw 'Parse Server option security.enableCheckLog must be a boolean.';
}
}
static validatePagesOptions(pages) {

View File

@@ -373,6 +373,12 @@ module.exports.ParseServerOptions = {
action: parsers.numberParser('schemaCacheTTL'),
default: 5000,
},
security: {
env: 'PARSE_SERVER_SECURITY',
help: 'The security options to identify and report weak security settings.',
action: parsers.objectParser,
default: {},
},
serverCloseComplete: {
env: 'PARSE_SERVER_SERVER_CLOSE_COMPLETE',
help: 'Callback when server has closed',
@@ -424,6 +430,27 @@ module.exports.ParseServerOptions = {
help: 'Key sent with outgoing webhook calls',
},
};
module.exports.SecurityOptions = {
checkGroups: {
env: 'PARSE_SERVER_SECURITY_CHECK_GROUPS',
help:
'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`.',
action: parsers.arrayParser,
},
enableCheck: {
env: 'PARSE_SERVER_SECURITY_ENABLE_CHECK',
help: 'Is true if Parse Server should check for weak security settings.',
action: parsers.booleanParser,
default: false,
},
enableCheckLog: {
env: 'PARSE_SERVER_SECURITY_ENABLE_CHECK_LOG',
help:
'Is true if the security check report should be written to logs. This should only be enabled temporarily to not expose weak security settings in logs.',
action: parsers.booleanParser,
default: false,
},
};
module.exports.PagesOptions = {
customRoutes: {
env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES',

View File

@@ -68,6 +68,7 @@
* @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.
* @property {Boolean} scheduledPush Configuration for push scheduling, defaults to false.
* @property {Number} schemaCacheTTL The TTL for caching the schema for optimizing read/write operations. You should put a long TTL when your DB is in production. default to 5000; set 0 to disable.
* @property {SecurityOptions} security The security options to identify and report weak security settings.
* @property {Function} serverCloseComplete Callback when server has closed
* @property {Function} serverStartComplete Callback when server has started
* @property {String} serverURL URL to your parse server with http:// or https://.
@@ -80,6 +81,13 @@
* @property {String} webhookKey Key sent with outgoing webhook calls
*/
/**
* @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 {Boolean} enableCheck Is true if Parse Server should check for weak security settings.
* @property {Boolean} enableCheckLog Is true if the security check report should be written to logs. This should only be enabled temporarily to not expose weak security settings in logs.
*/
/**
* @interface PagesOptions
* @property {PagesRoute[]} customRoutes The custom routes.

View File

@@ -6,6 +6,7 @@ import { CacheAdapter } from '../Adapters/Cache/CacheAdapter';
import { MailAdapter } from '../Adapters/Email/MailAdapter';
import { PubSubAdapter } from '../Adapters/PubSub/PubSubAdapter';
import { WSSAdapter } from '../Adapters/WebSocketServer/WSSAdapter';
import { CheckGroup } from '../Security/CheckGroup';
// @flow
type Adapter<T> = string | any | T;
@@ -227,6 +228,20 @@ export interface ParseServerOptions {
serverStartComplete: ?(error: ?Error) => void;
/* Callback when server has closed */
serverCloseComplete: ?() => void;
/* The security options to identify and report weak security settings.
:DEFAULT: {} */
security: ?SecurityOptions;
}
export interface SecurityOptions {
/* Is true if Parse Server should check for weak security settings.
:DEFAULT: false */
enableCheck: ?boolean;
/* Is true if the security check report should be written to logs. This should only be enabled temporarily to not expose weak security settings in logs.
:DEFAULT: false */
enableCheckLog: ?boolean;
/* 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`. */
checkGroups: ?(CheckGroup[]);
}
export interface PagesOptions {

View File

@@ -41,6 +41,8 @@ import { AggregateRouter } from './Routers/AggregateRouter';
import { ParseServerRESTController } from './ParseServerRESTController';
import * as controllers from './Controllers';
import { ParseGraphQLServer } from './GraphQL/ParseGraphQLServer';
import { SecurityRouter } from './Routers/SecurityRouter';
import CheckRunner from './Security/CheckRunner';
// Mutate the Parse object to add the Cloud Code handlers
addParseCloud();
@@ -58,6 +60,7 @@ class ParseServer {
appId = requiredParameter('You must provide an appId!'),
masterKey = requiredParameter('You must provide a masterKey!'),
cloud,
security,
javascriptKey,
serverURL = requiredParameter('You must provide a serverURL!'),
serverStartComplete,
@@ -101,6 +104,10 @@ class ParseServer {
throw "argument 'cloud' must either be a string or a function";
}
}
if (security && security.enableCheck && security.enableCheckLog) {
new CheckRunner(options.security).run();
}
}
get app() {
@@ -219,6 +226,7 @@ class ParseServer {
new CloudCodeRouter(),
new AudiencesRouter(),
new AggregateRouter(),
new SecurityRouter(),
];
const routes = routers.reduce((memo, router) => {

View File

@@ -0,0 +1,31 @@
import PromiseRouter from '../PromiseRouter';
import * as middleware from '../middlewares';
import CheckRunner from '../Security/CheckRunner';
export class SecurityRouter extends PromiseRouter {
mountRoutes() {
this.route('GET', '/security',
middleware.promiseEnforceMasterKeyAccess,
this._enforceSecurityCheckEnabled,
async (req) => {
const report = await new CheckRunner(req.config.security).run();
return {
status: 200,
response: report,
};
}
);
}
async _enforceSecurityCheckEnabled(req) {
const config = req.config;
if (!config.security || !config.security.enableCheck) {
const error = new Error();
error.status = 409;
error.message = 'Enable Parse Server option `security.enableCheck` to run security check.';
throw error;
}
}
}
export default SecurityRouter;

85
src/Security/Check.js Normal file
View File

@@ -0,0 +1,85 @@
/**
* @module SecurityCheck
*/
import Utils from '../Utils';
import { isFunction, isString } from 'lodash';
/**
* A security check.
* @class Check
*/
class Check {
/**
* Constructs a new security check.
* @param {Object} params The parameters.
* @param {String} params.title The title.
* @param {String} params.warning The warning message if the check fails.
* @param {String} params.solution The solution to fix the check.
* @param {Promise} params.check The check as synchronous or asynchronous function.
*/
constructor(params) {
this._validateParams(params);
const { title, warning, solution, check } = params;
this.title = title;
this.warning = warning;
this.solution = solution;
this.check = check;
// Set default properties
this._checkState = CheckState.none;
this.error;
}
/**
* Returns the current check state.
* @return {CheckState} The check state.
*/
checkState() {
return this._checkState;
}
async run() {
// Get check as synchronous or asynchronous function
const check = this.check instanceof Promise ? await this.check : this.check;
// Run check
try {
check();
this._checkState = CheckState.success;
} catch (e) {
this.stateFailError = e;
this._checkState = CheckState.fail;
}
}
/**
* Validates the constructor parameters.
* @param {Object} params The parameters to validate.
*/
_validateParams(params) {
Utils.validateParams(params, {
group: { t: 'string', v: isString },
title: { t: 'string', v: isString },
warning: { t: 'string', v: isString },
solution: { t: 'string', v: isString },
check: { t: 'function', v: isFunction },
});
}
}
/**
* The check state.
*/
const CheckState = Object.freeze({
none: "none",
fail: "fail",
success: "success",
});
export default Check;
module.exports = {
Check,
CheckState,
};

View File

@@ -0,0 +1,45 @@
/**
* @module SecurityCheck
*/
/**
* A group of security checks.
* @interface CheckGroup
*/
class CheckGroup {
constructor() {
this._name = this.setName();
this._checks = this.setChecks();
}
/**
* The security check group name; to be overridden by child class.
*/
setName() {
throw `Check group has no name.`;
}
name() {
return this._name;
}
/**
* The security checks; to be overridden by child class.
*/
setChecks() {
throw `Check group has no checks.`;
}
checks() {
return this._checks;
}
/**
* Runs all checks.
*/
async run() {
for (const check of this._checks) {
check.run();
}
}
}
module.exports = CheckGroup;

View File

@@ -0,0 +1,47 @@
/**
* @module SecurityCheck
*/
import { Check } from '../Check';
import CheckGroup from '../CheckGroup';
import Config from '../../Config';
import Parse from 'parse/node';
/**
* The security checks group for Parse Server configuration.
* Checks common Parse Server parameters such as access keys.
*/
class CheckGroupDatabase extends CheckGroup {
setName() {
return 'Database';
}
setChecks() {
const config = Config.get(Parse.applicationId);
const databaseAdapter = config.database.adapter;
const databaseUrl = databaseAdapter._uri;
return [
new Check({
title: 'Secure database password',
warning: 'The database password is insecure and vulnerable to brute force attacks.',
solution: 'Choose a longer and/or more complex password with a combination of upper- and lowercase characters, numbers and special characters.',
check: () => {
const password = databaseUrl.match(/\/\/\S+:(\S+)@/)[1];
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasNonAlphasNumerics = /\W/.test(password);
// Ensure length
if (password.length < 14) {
throw 1;
}
// Ensure at least 3 out of 4 requirements passed
if (hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphasNumerics < 3) {
throw 1;
}
},
}),
];
}
}
module.exports = CheckGroupDatabase;

View File

@@ -0,0 +1,65 @@
/**
* @module SecurityCheck
*/
import { Check } from '../Check';
import CheckGroup from '../CheckGroup';
import Config from '../../Config';
import Parse from 'parse/node';
/**
* The security checks group for Parse Server configuration.
* Checks common Parse Server parameters such as access keys.
*/
class CheckGroupServerConfig extends CheckGroup {
setName() {
return 'Parse Server Configuration';
}
setChecks() {
const config = Config.get(Parse.applicationId);
return [
new Check({
title: 'Secure master key',
warning: 'The Parse Server master key is insecure and vulnerable to brute force attacks.',
solution: 'Choose a longer and/or more complex master key with a combination of upper- and lowercase characters, numbers and special characters.',
check: () => {
const masterKey = config.masterKey;
const hasUpperCase = /[A-Z]/.test(masterKey);
const hasLowerCase = /[a-z]/.test(masterKey);
const hasNumbers = /\d/.test(masterKey);
const hasNonAlphasNumerics = /\W/.test(masterKey);
// Ensure length
if (masterKey.length < 14) {
throw 1;
}
// Ensure at least 3 out of 4 requirements passed
if (hasUpperCase + hasLowerCase + hasNumbers + hasNonAlphasNumerics < 3) {
throw 1;
}
},
}),
new Check({
title: 'Security log disabled',
warning: 'Security checks in logs may expose vulnerabilities to anyone access to logs.',
solution: 'Change Parse Server configuration to \'security.enableCheckLog: false\'.',
check: () => {
if (config.security && config.security.enableCheckLog) {
throw 1;
}
},
}),
new Check({
title: 'Client class creation disabled',
warning: 'Attackers are allowed to create new classes without restriction and flood the database.',
solution: 'Change Parse Server configuration to \'allowClientClassCreation: false\'.',
check: () => {
if (config.allowClientClassCreation || config.allowClientClassCreation == null) {
throw 1;
}
},
}),
];
}
}
module.exports = CheckGroupServerConfig;

View File

@@ -0,0 +1,9 @@
/**
* @module SecurityCheck
*/
/**
* The list of security check groups.
*/
export { default as CheckGroupDatabase } from './CheckGroupDatabase';
export { default as CheckGroupServerConfig } from './CheckGroupServerConfig';

205
src/Security/CheckRunner.js Normal file
View File

@@ -0,0 +1,205 @@
/**
* @module SecurityCheck
*/
import Utils from '../Utils';
import { CheckState } from './Check';
import * as CheckGroups from './CheckGroups/CheckGroups';
import logger from '../logger';
import { isArray, isBoolean } from 'lodash';
/**
* The security check runner.
*/
class CheckRunner {
/**
* The security check runner.
* @param {Object} [config] The configuration options.
* @param {Boolean} [config.enableCheck=false] Is true if Parse Server should report weak security settings.
* @param {Boolean} [config.enableCheckLog=false] Is true if the security check report should be written to logs.
* @param {Object} [config.checkGroups] The check groups to run. Default are the groups defined in `./CheckGroups/CheckGroups.js`.
*/
constructor(config = {}) {
this._validateParams(config);
const { enableCheck = false, enableCheckLog = false, checkGroups = CheckGroups } = config;
this.enableCheck = enableCheck;
this.enableCheckLog = enableCheckLog;
this.checkGroups = checkGroups;
}
/**
* Runs all security checks and returns the results.
* @params
* @returns {Object} The security check report.
*/
async run({ version = '1.0.0' } = {}) {
// Instantiate check groups
const groups = Object.values(this.checkGroups)
.filter(c => typeof c === 'function')
.map(CheckGroup => new CheckGroup());
// Run checks
groups.forEach(group => group.run());
// Generate JSON report
const report = this._generateReport({ groups, version });
// If report should be written to logs
if (this.enableCheckLog) {
this._logReport(report)
}
return report;
}
/**
* Generates a security check report in JSON format with schema:
* ```
* {
* report: {
* version: "1.0.0", // The report version, defines the schema
* state: "fail" // The disjunctive indicator of failed checks in all groups.
* groups: [ // The check groups
* {
* name: "House", // The group name
* state: "fail" // The disjunctive indicator of failed checks in this group.
* checks: [ // The checks
* title: "Door locked", // The check title
* state: "fail" // The check state
* warning: "Anyone can enter your house." // The warning.
* solution: "Lock your door." // The solution.
* ]
* },
* ...
* ]
* }
* }
* ```
* @param {Object} params The parameters.
* @param {Array<CheckGroup>} params.groups The check groups.
* @param {String} params.version: The report schema version.
* @returns {Object} The report.
*/
_generateReport({ groups, version }) {
// Create report template
const report = {
report: {
version,
state: CheckState.success,
groups: []
}
};
// Identify report version
switch (version) {
case '1.0.0':
default:
// For each check group
for (const group of groups) {
// Create group report
const groupReport = {
name: group.name(),
state: CheckState.success,
checks: [],
}
// Create check reports
groupReport.checks = group.checks().map(check => {
const checkReport = {
title: check.title,
state: check.checkState(),
};
if (check.checkState() == CheckState.fail) {
checkReport.warning = check.warning;
checkReport.solution = check.solution;
report.report.state = CheckState.fail;
groupReport.state = CheckState.fail;
}
return checkReport;
});
report.report.groups.push(groupReport);
}
}
return report;
}
/**
* Logs the security check report.
* @param {Object} report The report to log.
*/
_logReport(report) {
// Determine log level depending on whether any check failed
const log = report.report.state == CheckState.success ? (s) => logger.info(s) : (s) => logger.warn(s);
// Declare output
const indent = ' ';
let output = '';
let checksCount = 0;
let failedChecksCount = 0;
let skippedCheckCount = 0;
// Traverse all groups and checks for compose output
for (const group of report.report.groups) {
output += `\n- ${group.name}`
for (const check of group.checks) {
checksCount++;
output += `\n${indent}${this._getLogIconForState(check.state)} ${check.title}`;
if (check.state == CheckState.fail) {
failedChecksCount++;
output += `\n${indent}${indent}Warning: ${check.warning}`;
output += ` ${check.solution}`;
} else if (check.state == CheckState.none) {
skippedCheckCount++;
output += `\n${indent}${indent}Test did not execute, this is likely an internal server issue, please report.`;
}
}
}
output =
`\n###################################` +
`\n# #` +
`\n# Parse Server Security Check #` +
`\n# #` +
`\n###################################` +
`\n` +
`\n${failedChecksCount > 0 ? 'Warning: ' : ''}${failedChecksCount} weak security setting(s) found${failedChecksCount > 0 ? '!' : ''}` +
`\n${checksCount} check(s) executed` +
`\n${skippedCheckCount} check(s) skipped` +
`\n` +
`${output}`;
// Write log
log(output);
}
/**
* Returns an icon for use in the report log output.
* @param {CheckState} state The check state.
* @returns {String} The icon.
*/
_getLogIconForState(state) {
switch (state) {
case CheckState.success: return '✅';
case CheckState.fail: return '❌';
default: return '';
}
}
/**
* Validates the constructor parameters.
* @param {Object} params The parameters to validate.
*/
_validateParams(params) {
Utils.validateParams(params, {
enableCheck: { t: 'boolean', v: isBoolean, o: true },
enableCheckLog: { t: 'boolean', v: isBoolean, o: true },
checkGroups: { t: 'array', v: isArray, o: true },
});
}
}
module.exports = CheckRunner;

View File

@@ -118,6 +118,71 @@ class Utils {
}
return result;
}
/**
* Determines whether an object is a Promise.
* @param {any} object The object to validate.
* @returns {Boolean} Returns true if the object is a promise.
*/
static isPromise(object) {
return object instanceof Promise;
}
/**
* Creates an object with all permutations of the original keys.
* @param {Object} object The object to permutate.
* @param {Integer} [index=0] The current key index.
* @param {Object} [current={}] The current result entry being composed.
* @param {Array} [results=[]] The resulting array of permutations.
*/
static getObjectKeyPermutations(object, index = 0, current = {}, results = []) {
const keys = Object.keys(object);
const key = keys[index];
const values = object[key];
for (const value of values) {
current[key] = value;
const nextIndex = index + 1;
if (nextIndex < keys.length) {
this.getObjectKeyPermutations(object, nextIndex, current, results);
} else {
const result = Object.assign({}, current);
results.push(result);
}
}
return results;
}
/**
* Validates parameters and throws if a parameter is invalid.
* Example parameter types syntax:
* ```
* {
* parameterName: {
* t: 'boolean',
* v: isBoolean,
* o: true
* },
* ...
* }
* ```
* @param {Object} params The parameters to validate.
* @param {Array<Object>} types The parameter types used for validation.
* @param {Object} types.t The parameter type; used for error message, not for validation.
* @param {Object} types.v The function to validate the parameter value.
* @param {Boolean} [types.o=false] Is true if the parameter is optional.
*/
static validateParams(params, types) {
for (const key of Object.keys(params)) {
const type = types[key];
const isOptional = !!type.o;
const param = params[key];
if (!(isOptional && param == null) && (!type.v(param))) {
throw `Invalid parameter ${key} must be of type ${type.t} but is ${typeof param}`;
}
}
}
}
module.exports = Utils;