fix: security vulnerability that allows remote code execution (GHSA-p6h4-93qp-jhcm) (#7843)
This commit is contained in:
@@ -35,7 +35,7 @@ export class Config {
|
||||
config.applicationId = applicationId;
|
||||
Object.keys(cacheInfo).forEach(key => {
|
||||
if (key == 'databaseController') {
|
||||
config.database = new DatabaseController(cacheInfo.databaseController.adapter);
|
||||
config.database = new DatabaseController(cacheInfo.databaseController.adapter, config);
|
||||
} else {
|
||||
config[key] = cacheInfo[key];
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export class Config {
|
||||
security,
|
||||
enforcePrivateUsers,
|
||||
schema,
|
||||
requestKeywordDenylist,
|
||||
}) {
|
||||
if (masterKey === readOnlyMasterKey) {
|
||||
throw new Error('masterKey and readOnlyMasterKey should be different');
|
||||
@@ -116,6 +117,15 @@ export class Config {
|
||||
this.validateSecurityOptions(security);
|
||||
this.validateSchemaOptions(schema);
|
||||
this.validateEnforcePrivateUsers(enforcePrivateUsers);
|
||||
this.validateRequestKeywordDenylist(requestKeywordDenylist);
|
||||
}
|
||||
|
||||
static validateRequestKeywordDenylist(requestKeywordDenylist) {
|
||||
if (requestKeywordDenylist === undefined) {
|
||||
requestKeywordDenylist = requestKeywordDenylist.default;
|
||||
} else if (!Array.isArray(requestKeywordDenylist)) {
|
||||
throw 'Parse Server option requestKeywordDenylist must be an array.';
|
||||
}
|
||||
}
|
||||
|
||||
static validateEnforcePrivateUsers(enforcePrivateUsers) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { StorageAdapter } from '../Adapters/Storage/StorageAdapter';
|
||||
import MongoStorageAdapter from '../Adapters/Storage/Mongo/MongoStorageAdapter';
|
||||
import SchemaCache from '../Adapters/Cache/SchemaCache';
|
||||
import type { LoadSchemaOptions } from './types';
|
||||
import type { ParseServerOptions } from '../Options';
|
||||
import type { QueryOptions, FullQueryOptions } from '../Adapters/Storage/StorageAdapter';
|
||||
|
||||
function addWriteACL(query, acl) {
|
||||
@@ -257,41 +258,6 @@ const isSpecialUpdateKey = key => {
|
||||
return specialKeysForUpdate.indexOf(key) >= 0;
|
||||
};
|
||||
|
||||
function expandResultOnKeyPath(object, key, value) {
|
||||
if (key.indexOf('.') < 0) {
|
||||
object[key] = value[key];
|
||||
return object;
|
||||
}
|
||||
const path = key.split('.');
|
||||
const firstKey = path[0];
|
||||
const nextPath = path.slice(1).join('.');
|
||||
object[firstKey] = expandResultOnKeyPath(object[firstKey] || {}, nextPath, value[firstKey]);
|
||||
delete object[key];
|
||||
return object;
|
||||
}
|
||||
|
||||
function sanitizeDatabaseResult(originalObject, result): Promise<any> {
|
||||
const response = {};
|
||||
if (!result) {
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
Object.keys(originalObject).forEach(key => {
|
||||
const keyUpdate = originalObject[key];
|
||||
// determine if that was an op
|
||||
if (
|
||||
keyUpdate &&
|
||||
typeof keyUpdate === 'object' &&
|
||||
keyUpdate.__op &&
|
||||
['Add', 'AddUnique', 'Remove', 'Increment'].indexOf(keyUpdate.__op) > -1
|
||||
) {
|
||||
// only valid ops that produce an actionable result
|
||||
// the op may have happend on a keypath
|
||||
expandResultOnKeyPath(response, key, result);
|
||||
}
|
||||
});
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
|
||||
function joinTableName(className, key) {
|
||||
return `_Join:${key}:${className}`;
|
||||
}
|
||||
@@ -397,14 +363,16 @@ class DatabaseController {
|
||||
schemaCache: any;
|
||||
schemaPromise: ?Promise<SchemaController.SchemaController>;
|
||||
_transactionalSession: ?any;
|
||||
options: ParseServerOptions;
|
||||
|
||||
constructor(adapter: StorageAdapter) {
|
||||
constructor(adapter: StorageAdapter, options: ParseServerOptions) {
|
||||
this.adapter = adapter;
|
||||
// We don't want a mutable this.schema, because then you could have
|
||||
// one request that uses different schemas for different parts of
|
||||
// it. Instead, use loadSchema to get a schema.
|
||||
this.schemaPromise = null;
|
||||
this._transactionalSession = null;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
collectionExists(className: string): Promise<boolean> {
|
||||
@@ -643,7 +611,7 @@ class DatabaseController {
|
||||
if (skipSanitization) {
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
return sanitizeDatabaseResult(originalUpdate, result);
|
||||
return this._sanitizeDatabaseResult(originalUpdate, result);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -870,7 +838,7 @@ class DatabaseController {
|
||||
object,
|
||||
relationUpdates
|
||||
).then(() => {
|
||||
return sanitizeDatabaseResult(originalObject, result.ops[0]);
|
||||
return this._sanitizeDatabaseResult(originalObject, result.ops[0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1771,6 +1739,60 @@ class DatabaseController {
|
||||
await this.adapter.updateSchemaWithIndexes();
|
||||
}
|
||||
|
||||
_expandResultOnKeyPath(object: any, key: string, value: any): any {
|
||||
if (key.indexOf('.') < 0) {
|
||||
object[key] = value[key];
|
||||
return object;
|
||||
}
|
||||
const path = key.split('.');
|
||||
const firstKey = path[0];
|
||||
const nextPath = path.slice(1).join('.');
|
||||
|
||||
// Scan request data for denied keywords
|
||||
if (this.options && this.options.requestKeywordDenylist) {
|
||||
// Scan request data for denied keywords
|
||||
for (const keyword of this.options.requestKeywordDenylist) {
|
||||
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(a).test(b)) || a === b;
|
||||
if (isMatch(firstKey, keyword.key)) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.INVALID_KEY_NAME,
|
||||
`Prohibited keyword in request data: ${JSON.stringify(keyword)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object[firstKey] = this._expandResultOnKeyPath(
|
||||
object[firstKey] || {},
|
||||
nextPath,
|
||||
value[firstKey]
|
||||
);
|
||||
delete object[key];
|
||||
return object;
|
||||
}
|
||||
|
||||
_sanitizeDatabaseResult(originalObject: any, result: any): Promise<any> {
|
||||
const response = {};
|
||||
if (!result) {
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
Object.keys(originalObject).forEach(key => {
|
||||
const keyUpdate = originalObject[key];
|
||||
// determine if that was an op
|
||||
if (
|
||||
keyUpdate &&
|
||||
typeof keyUpdate === 'object' &&
|
||||
keyUpdate.__op &&
|
||||
['Add', 'AddUnique', 'Remove', 'Increment'].indexOf(keyUpdate.__op) > -1
|
||||
) {
|
||||
// only valid ops that produce an actionable result
|
||||
// the op may have happened on a keypath
|
||||
this._expandResultOnKeyPath(response, key, result);
|
||||
}
|
||||
});
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
|
||||
static _validateQuery: any => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export function getDatabaseController(options: ParseServerOptions): DatabaseCont
|
||||
} else {
|
||||
databaseAdapter = loadAdapter(databaseAdapter);
|
||||
}
|
||||
return new DatabaseController(databaseAdapter);
|
||||
return new DatabaseController(databaseAdapter, options);
|
||||
}
|
||||
|
||||
export function getHooksController(
|
||||
|
||||
@@ -350,6 +350,24 @@ module.exports.ParseServerOptions = {
|
||||
env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY',
|
||||
help: 'Read-only key, which has the same capabilities as MasterKey without writes',
|
||||
},
|
||||
requestKeywordDenylist: {
|
||||
env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST',
|
||||
help:
|
||||
'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.',
|
||||
action: parsers.arrayParser,
|
||||
default: [
|
||||
{
|
||||
key: '_bsontype',
|
||||
value: 'Code',
|
||||
},
|
||||
{
|
||||
key: 'constructor',
|
||||
},
|
||||
{
|
||||
key: '__proto__',
|
||||
},
|
||||
],
|
||||
},
|
||||
restAPIKey: {
|
||||
env: 'PARSE_SERVER_REST_API_KEY',
|
||||
help: 'Key for REST calls',
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
* @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 {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
|
||||
* @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.
|
||||
|
||||
@@ -14,6 +14,10 @@ type Adapter<T> = string | any | T;
|
||||
type NumberOrBoolean = number | boolean;
|
||||
type NumberOrString = number | string;
|
||||
type ProtectedFields = any;
|
||||
type RequestKeywordDenylist = {
|
||||
key: string | any,
|
||||
value: any,
|
||||
};
|
||||
|
||||
export interface ParseServerOptions {
|
||||
/* Your Parse Application ID
|
||||
@@ -252,6 +256,9 @@ export interface ParseServerOptions {
|
||||
/* Set to true if new users should be created without public read and write access.
|
||||
:DEFAULT: false */
|
||||
enforcePrivateUsers: ?boolean;
|
||||
/* 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[]);
|
||||
}
|
||||
|
||||
export interface SecurityOptions {
|
||||
|
||||
@@ -6,6 +6,7 @@ var SchemaController = require('./Controllers/SchemaController');
|
||||
var deepcopy = require('deepcopy');
|
||||
|
||||
const Auth = require('./Auth');
|
||||
const Utils = require('./Utils');
|
||||
var cryptoUtils = require('./cryptoUtils');
|
||||
var passwordCrypto = require('./password');
|
||||
var Parse = require('parse/node');
|
||||
@@ -61,6 +62,19 @@ function RestWrite(config, auth, className, query, data, originalData, clientSDK
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.requestKeywordDenylist) {
|
||||
// Scan request data for denied keywords
|
||||
for (const keyword of this.config.requestKeywordDenylist) {
|
||||
const match = Utils.objectContainsKeyValue(data, keyword.key, keyword.value);
|
||||
if (match) {
|
||||
throw new Parse.Error(
|
||||
Parse.Error.INVALID_KEY_NAME,
|
||||
`Prohibited keyword in request data: ${JSON.stringify(keyword)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When the operation is complete, this.response may have several
|
||||
// fields.
|
||||
// response: the actual data to be returned
|
||||
|
||||
26
src/Utils.js
26
src/Utils.js
@@ -200,6 +200,32 @@ class Utils {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-scans an object for a matching key/value definition.
|
||||
* @param {Object} obj The object to scan.
|
||||
* @param {String | undefined} key The key to match, or undefined if only the value should be matched.
|
||||
* @param {any | undefined} value The value to match, or undefined if only the key should be matched.
|
||||
* @returns {Boolean} True if a match was found, false otherwise.
|
||||
*/
|
||||
static objectContainsKeyValue(obj, key, value) {
|
||||
const isMatch = (a, b) => (typeof a === 'string' && new RegExp(a).test(b)) || a === b;
|
||||
const isKeyMatch = k => isMatch(key, k);
|
||||
const isValueMatch = v => isMatch(value, v);
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (key !== undefined && value === undefined && isKeyMatch(k)) {
|
||||
return true;
|
||||
} else if (key === undefined && value !== undefined && isValueMatch(v)) {
|
||||
return true;
|
||||
} else if (key !== undefined && value !== undefined && isKeyMatch(k) && isValueMatch(v)) {
|
||||
return true;
|
||||
}
|
||||
if (['[object Object]', '[object Array]'].includes(Object.prototype.toString.call(v))) {
|
||||
return Utils.objectContainsKeyValue(v, key, value);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Utils;
|
||||
|
||||
Reference in New Issue
Block a user