// A RestWrite encapsulates everything we need to run an operation // that writes to the database. // This could be either a "create" or an "update". var SchemaController = require('./Controllers/SchemaController'); const Auth = require('./Auth'); const Utils = require('./Utils'); var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var Parse = require('parse/node'); var triggers = require('./triggers'); const util = require('util'); import RestQuery from './RestQuery'; import _ from 'lodash'; import logger from './logger'; import { requiredColumns } from './Controllers/SchemaController'; import { createSanitizedError } from './Error'; import { applyAuthDataOptimisticLock } from './AuthDataLock'; import * as InstallationDedup from './InstallationDedup'; // Operations whose result the database computes from the stored value const computedOperations = ['Increment', 'Add', 'AddUnique', 'Remove']; // query and data are both provided in REST API format. So data // types are encoded by plain old objects. // If query is null, this is a "create" and the data in data should be // created. // Otherwise this is an "update" - the object matching the query // should get updated with data. // RestWrite will handle objectId, createdAt, and updatedAt for // everything. It also knows to use triggers and special modifications // for the _User class. function RestWrite(config, auth, className, query, data, originalData, context, action) { if (auth.isReadOnly) { throw createSanitizedError( Parse.Error.OPERATION_FORBIDDEN, 'Cannot perform a write operation when using readOnlyMasterKey', config ); } this.config = config; this.auth = auth; this.className = className; this.storage = {}; this.runOptions = {}; this.context = context || {}; if (action) { this.runOptions.action = action; } if (!query) { if (this.config.allowCustomObjectId) { if (Object.prototype.hasOwnProperty.call(data, 'objectId') && !data.objectId) { throw new Parse.Error( Parse.Error.MISSING_OBJECT_ID, 'objectId must not be empty, null or undefined' ); } } else { if (data.objectId) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'objectId is an invalid field name.'); } if (data.id) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'id is an invalid field name.'); } } } // When the operation is complete, this.response may have several // fields. // response: the actual data to be returned // status: the http status code. if not present, treated like a 200 // location: the location header. if not present, no location header this.response = null; // Processing this operation may mutate our data, so we operate on a // copy this.query = structuredClone(query); this.data = structuredClone(data); // The client's dot notation keys and computed operations, before triggers change the data this.requestOperations = Object.keys(this.data) .filter(key => key.includes('.') || computedOperations.includes(this.data[key]?.__op)) .map(key => [key, this.data[key]]); // We never change originalData, so we do not need a deep copy this.originalData = originalData; // The timestamp we'll use for this whole operation this.updatedAt = Parse._encode(new Date()).iso; // Shared SchemaController to be reused to reduce the number of loadSchema() calls per request // Once set the schemaData should be immutable this.validSchemaController = null; this.pendingOps = { operations: null, identifier: null, }; } // A convenient method to perform all the steps of processing the // write, in order. // Returns a promise for a {response, status, location} object. // status and location are optional. RestWrite.prototype.execute = function () { return Promise.resolve() .then(() => { return this.checkInternalFields(); }) .then(() => { return this.getUserAndRoleACL(); }) .then(() => { return this.validateClientClassCreation(); }) .then(() => { return this.handleInstallation(); }) .then(() => { return this.handleSession(); }) .then(() => { return this.authorizeUserUpdate(); }) .then(() => { return this.validateAuthData(); }) .then(() => { return this.checkRestrictedFields(); }) .then(() => { return this.resolveFileUrls(); }) .then(() => { return this.validateDotNotationKeys(); }) .then(() => { return this.runBeforeSaveTrigger(); }) .then(() => { return this.ensureUniqueAuthDataId(); }) .then(() => { return this.deleteEmailResetTokenIfNeeded(); }) .then(() => { return this.validateWritePermission(); }) .then(() => { return this.validateSchema(); }) .then(schemaController => { this.validSchemaController = schemaController; return this.setRequiredFieldsIfNeeded(); }) .then(() => { return this.transformUser(); }) .then(() => { return this.expandFilesForExistingObjects(); }) .then(() => { return this.destroyDuplicatedSessions(); }) .then(() => { return this.runDatabaseOperation(); }) .then(() => { return this.createSessionTokenIfNeeded(); }) .then(() => { return this.handleFollowup(); }) .then(() => { return this.runAfterSaveTrigger(); }) .then(() => { return this.cleanUserAuthData(); }) .then(() => { return this.filterUnreadableFieldsInResponse(); }) .then(() => { return this.filterProtectedFieldsInResponse(); }) .then(() => { // Append the authDataResponse if exists if (this.authDataResponse) { if (this.response && this.response.response) { this.response.response.authDataResponse = this.authDataResponse; } } if (this.storage.rejectSignup && this.config.preventSignupWithUnverifiedEmail) { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } return this.response; }); }; // Uses the Auth object to get the list of roles, adds the user id RestWrite.prototype.getUserAndRoleACL = function () { if (this.auth.isMaster || this.auth.isMaintenance) { return Promise.resolve(); } this.runOptions.acl = ['*']; if (this.auth.user) { return this.auth.getUserRoles().then(roles => { this.runOptions.acl = this.runOptions.acl.concat(roles, [this.auth.user.id]); return; }); } else { return Promise.resolve(); } }; // Validates this operation against the allowClientClassCreation config. RestWrite.prototype.validateClientClassCreation = function () { if ( this.config.allowClientClassCreation === false && !this.auth.isMaster && !this.auth.isMaintenance && SchemaController.systemClasses.indexOf(this.className) === -1 ) { return this.config.database .loadSchema() .then(schemaController => schemaController.hasClass(this.className)) .then(hasClass => { if (hasClass !== true) { throw createSanitizedError( Parse.Error.OPERATION_FORBIDDEN, 'This user is not allowed to access non-existent class: ' + this.className, this.config ); } }); } else { return Promise.resolve(); } }; // Validates this operation against the schema. RestWrite.prototype.validateSchema = function () { return this.config.database.validateObject( this.className, this.data, this.query, this.runOptions, this.auth.isMaintenance ); }; // Resolves the URLs of file pointers in the data that have no URL, so that the // Parse objects built for triggers and LiveQuery can be encoded. RestWrite.prototype.resolveFileUrls = async function () { const files = Object.create(null); const collect = value => { if (!value || typeof value !== 'object') { return; } if (value.__type === 'File') { if (typeof value.name !== 'string' || value.name === '') { throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'This is not a valid File'); } if (!value.url) { files[value.name] = { __type: 'File', name: value.name }; } return; } Object.values(value).forEach(collect); }; collect(this.data); if (Object.keys(files).length === 0) { return; } await this.config.filesController.expandFilesInObject(this.config, files); this.fileUrls = Object.assign(this.fileUrls || Object.create(null), files); }; // Returns a copy of the data with the resolved URLs added to file pointers. RestWrite.prototype.cloneWithFileUrls = function (object) { const data = structuredClone(object); if (!this.fileUrls) { return data; } const addUrls = value => { if (!value || typeof value !== 'object') { return; } if (value.__type === 'File') { const file = typeof value.name === 'string' && this.fileUrls[value.name]; if (!value.url && file) { value.url = file.url; } return; } Object.values(value).forEach(addUrls); }; addUrls(data); return data; }; // Runs any beforeSave triggers against this operation. // Any change leads to our data being mutated. RestWrite.prototype.runBeforeSaveTrigger = function () { if (this.response || this.runOptions.many) { return; } // Avoid doing any setup for triggers if there is no 'beforeSave' trigger for this class. if ( !triggers.triggerExists(this.className, triggers.Types.beforeSave, this.config.applicationId) ) { return Promise.resolve(); } const { originalObject, updatedObject } = this.buildParseObjects({ copyStoredValues: true }); extendRevertToDotNotationKeys(updatedObject); const identifier = updatedObject._getStateIdentifier(); const stateController = Parse.CoreManager.getObjectStateController(); const [pending] = stateController.getPendingOps(identifier); this.pendingOps = { operations: { ...pending }, identifier, }; return Promise.resolve() .then(() => { // Before calling the trigger, validate the permissions for the save operation let databasePromise = null; if (this.query) { // Validate for updating databasePromise = this.config.database.update( this.className, this.query, this.data, this.runOptions, true, true ); } else { // Validate for creating databasePromise = this.config.database.create( this.className, this.data, this.runOptions, true ); } // In the case that there is no permission for the operation, it throws an error return databasePromise.then(result => { if (!result || result.length <= 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } }); }) .then(() => { return triggers.maybeRunTrigger( triggers.Types.beforeSave, this.auth, updatedObject, originalObject, this.config, this.context ); }) .then(response => { if (response && response.object) { this.storage.fieldsChangedByTrigger = _.reduce( response.object, (result, value, key) => { if (!_.isEqual(this.data[key], value)) { result.push(key); } return result; }, [] ); this.data = response.object; // We should delete the objectId for an update write if (this.query && this.query.objectId) { delete this.data.objectId; } } try { Utils.checkProhibitedKeywords(this.config, this.data); } catch (error) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `${error}`); } this.validateDotNotationKeys(); if (response && response.object) { // The trigger may have set file pointers without URL return this.resolveFileUrls(); } }); }; RestWrite.prototype.runBeforeLoginTrigger = async function (userData) { // Avoid doing any setup for triggers if there is no 'beforeLogin' trigger if ( !triggers.triggerExists(this.className, triggers.Types.beforeLogin, this.config.applicationId) ) { return; } // Cloud code gets a bit of extra data for its objects const extraData = { className: this.className }; // Expand file objects await this.config.filesController.expandFilesInObject(this.config, userData); const user = triggers.inflate(extraData, userData); // no need to return a response await triggers.maybeRunTrigger( triggers.Types.beforeLogin, this.auth, user, null, this.config, this.context ); }; RestWrite.prototype.setRequiredFieldsIfNeeded = function () { if (this.data) { return this.validSchemaController.getAllClasses().then(allClasses => { const schema = allClasses.find(oneClass => oneClass.className === this.className); const setRequiredFieldIfNeeded = (fieldName, setDefault) => { if ( this.data[fieldName] === undefined || this.data[fieldName] === null || this.data[fieldName] === '' || (typeof this.data[fieldName] === 'object' && this.data[fieldName].__op === 'Delete') ) { if ( setDefault && schema.fields[fieldName] && schema.fields[fieldName].defaultValue !== null && schema.fields[fieldName].defaultValue !== undefined && (this.data[fieldName] === undefined || (typeof this.data[fieldName] === 'object' && this.data[fieldName].__op === 'Delete')) ) { this.data[fieldName] = schema.fields[fieldName].defaultValue; this.storage.fieldsChangedByTrigger = this.storage.fieldsChangedByTrigger || []; if (this.storage.fieldsChangedByTrigger.indexOf(fieldName) < 0) { this.storage.fieldsChangedByTrigger.push(fieldName); } } else if (schema.fields[fieldName] && schema.fields[fieldName].required === true) { throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${fieldName} is required`); } } }; // add default ACL (only on CREATE, not UPDATE) if (!this.query && schema?.classLevelPermissions?.ACL && !this.data.ACL && JSON.stringify(schema.classLevelPermissions.ACL) !== JSON.stringify({ '*': { read: true, write: true } }) ) { const acl = structuredClone(schema.classLevelPermissions.ACL); if (acl.currentUser) { if (this.auth.user?.id) { acl[this.auth.user?.id] = structuredClone(acl.currentUser); } delete acl.currentUser; } this.data.ACL = acl; this.storage.fieldsChangedByTrigger = this.storage.fieldsChangedByTrigger || []; this.storage.fieldsChangedByTrigger.push('ACL'); } // Add default fields if (!this.query) { // allow customizing createdAt and updatedAt when using maintenance key if ( this.auth.isMaintenance && this.data.createdAt && this.data.createdAt.__type === 'Date' ) { this.data.createdAt = this.data.createdAt.iso; if (this.data.updatedAt && this.data.updatedAt.__type === 'Date') { const createdAt = new Date(this.data.createdAt); const updatedAt = new Date(this.data.updatedAt.iso); if (updatedAt < createdAt) { throw new Parse.Error( Parse.Error.VALIDATION_ERROR, 'updatedAt cannot occur before createdAt' ); } this.data.updatedAt = this.data.updatedAt.iso; } // if no updatedAt is provided, set it to createdAt to match default behavior else { this.data.updatedAt = this.data.createdAt; } } else { this.data.updatedAt = this.updatedAt; this.data.createdAt = this.updatedAt; } // Only assign new objectId if we are creating new object if (!this.data.objectId) { this.data.objectId = cryptoUtils.newObjectId(this.config.objectIdSize); } if (schema) { Object.keys(schema.fields).forEach(fieldName => { setRequiredFieldIfNeeded(fieldName, true); }); } } else if (schema) { this.data.updatedAt = this.updatedAt; Object.keys(this.data).forEach(fieldName => { setRequiredFieldIfNeeded(fieldName, false); }); } }); } return Promise.resolve(); }; // Transforms auth data for a user object. // Does nothing if this isn't a user object. // Returns a promise for when we're done if it can't finish this tick. RestWrite.prototype.validateAuthData = function () { if (this.className !== '_User') { return; } const authData = this.data.authData; const hasUsernameAndPassword = typeof this.data.username === 'string' && typeof this.data.password === 'string'; const hasAuthData = authData && Object.keys(authData).some(provider => { const providerData = authData[provider]; return providerData && typeof providerData === 'object' && Object.keys(providerData).length; }); const requiresCredentials = !this.query && !hasAuthData; const isMissing = value => typeof value !== 'string' || _.isEmpty(value); // An empty username on create with authData is replaced by a random one const checkUsername = requiresCredentials || (this.query && this.data.username !== undefined); const checkPassword = requiresCredentials || this.data.password !== undefined; if (checkUsername && isMissing(this.data.username)) { throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); } if (checkPassword && isMissing(this.data.password)) { throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required'); } if (!Object.prototype.hasOwnProperty.call(this.data, 'authData')) { // Nothing to validate here return; } else if (!this.data.authData) { // Handle saving authData to null throw new Parse.Error( Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.' ); } var providers = Object.keys(authData); if (!providers.length) { // Empty authData object, nothing to validate return; } const canHandleAuthData = providers.some(provider => { const providerAuthData = authData[provider] || {}; return !!Object.keys(providerAuthData).length; }); if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { return this.handleAuthData(authData); } throw new Parse.Error( Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.' ); }; RestWrite.prototype.filteredObjectsByACL = function (objects) { if (this.auth.isMaster || this.auth.isMaintenance) { return objects; } return objects.filter(object => { if (!object.ACL) { return true; // legacy users that have no ACL field on them } // Regular users that have been locked out. return object.ACL && Object.keys(object.ACL).length > 0; }); }; RestWrite.prototype.getUserId = function () { if (this.query && this.query.objectId && this.className === '_User') { return this.query.objectId; } else if (this.auth && this.auth.user && this.auth.user.id) { return this.auth.user.id; } }; // Developers are allowed to change authData via before save trigger RestWrite.prototype._throwIfAuthDataDuplicate = function (error) { if ( this.className === '_User' && error?.code === Parse.Error.DUPLICATE_VALUE && error.userInfo?.duplicated_field?.startsWith('_auth_data_') ) { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } }; // we need after before save to ensure that the developer // is not currently duplicating auth data ID RestWrite.prototype.ensureUniqueAuthDataId = async function () { if (this.className !== '_User' || !this.data.authData) { return; } const hasAuthDataId = Object.keys(this.data.authData).some( key => this.data.authData[key] && this.data.authData[key].id ); if (!hasAuthDataId) { return; } const r = await Auth.findUsersWithAuthData(this.config, this.data.authData); const results = this.filteredObjectsByACL(r); if (results.length > 1) { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } // use data.objectId in case of login time and found user during handle validateAuthData const userId = this.getUserId() || this.data.objectId; if (results.length === 1 && userId !== results[0].objectId) { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } }; RestWrite.prototype.handleAuthData = async function (authData) { let currentUserAuthData; if (this.query?.objectId) { const [currentUser] = await this.config.database.find( '_User', { objectId: this.query.objectId } ); currentUserAuthData = currentUser?.authData; } const r = await Auth.findUsersWithAuthData(this.config, authData, true, currentUserAuthData); const results = this.filteredObjectsByACL(r); const userId = this.getUserId(); const isLogin = !userId; const userResult = results[0]; const foundUserIsNotCurrentUser = userId && userResult && userId !== userResult.objectId; if (results.length > 1 || foundUserIsNotCurrentUser) { // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 // Let's run some validation before throwing await Auth.handleAuthDataValidation(authData, this, userResult, { isLogin }); throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } // No user found with provided authData we need to validate if (!results.length) { const { authData: validatedAuthData, authDataResponse } = await Auth.handleAuthDataValidation( authData, this, undefined, { isLogin } ); this.authDataResponse = authDataResponse; // Replace current authData by the new validated one this.data.authData = validatedAuthData; return; } // User found with provided authData if (results.length === 1) { this.storage.authProvider = Object.keys(authData).join(','); const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData( authData, userResult.authData ); const isCurrentUserLoggedOrMaster = (this.auth && this.auth.user && this.auth.user.id === userResult.objectId) || this.auth.isMaster; if (isLogin || isCurrentUserLoggedOrMaster) { // no user making the call // OR the user making the call is the right one // Login with auth data delete results[0].password; // need to set the objectId first otherwise location has trailing undefined this.data.objectId = userResult.objectId; if (!this.query || !this.query.objectId) { this.response = { response: userResult, location: this.location(), }; // Run beforeLogin hook before storing any updates // to authData on the db; changes to userResult // will be ignored. await this.runBeforeLoginTrigger(structuredClone(userResult)); // If we are in login operation via authData // we need to be sure that the user has provided // required authData Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( { config: this.config, auth: this.auth }, authData, userResult.authData, this.config ); } // Prevent validating if no mutated data detected on update if (!isLogin && !hasMutatedAuthData && isCurrentUserLoggedOrMaster) { return; } // Always validate all provided authData on login to prevent authentication // bypass via partial authData (e.g. sending only the provider ID without // an access token); on update only validate mutated ones if (isLogin || hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { const res = await Auth.handleAuthDataValidation( isLogin ? authData : mutatedAuthData, this, userResult, { isLogin } ); this.data.authData = res.authData; this.authDataResponse = res.authDataResponse; } // Capture original authData before mutating userResult via the response reference const originalAuthData = userResult?.authData ? Object.fromEntries( Object.entries(userResult.authData).map(([k, v]) => [k, v && typeof v === 'object' ? { ...v } : v] ) ) : undefined; // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... // we need to set it up there. // We are supposed to have a response only on LOGIN with authData, so we skip those // If we're not logging in, but just updating the current user, we can safely skip that part if (this.response) { // Assign the new authData in the response Object.keys(mutatedAuthData).forEach(provider => { this.response.response.authData[provider] = mutatedAuthData[provider]; }); // Run the DB update directly, as 'master' only if authData contains some keys // authData could not contains keys after validation if the authAdapter // uses the `doNotSave` option. Just update the authData part // Then we're good for the user, early exit of sorts if (Object.keys(this.data.authData).length) { const query = { objectId: this.data.objectId }; // Optimistic locking: include each changed original field in the WHERE clause // for providers whose data is being updated. This prevents concurrent requests // from both succeeding when consuming single-use tokens (e.g. MFA recovery codes // as arrays, or MFA SMS OTP tokens as strings). applyAuthDataOptimisticLock(query, originalAuthData, this.data.authData); try { await this.config.database.update( this.className, query, { authData: this.data.authData }, {} ); } catch (error) { if (error.code === Parse.Error.OBJECT_NOT_FOUND) { throw new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Invalid auth data'); } this._throwIfAuthDataDuplicate(error); throw error; } } } else if (this.query && this.data.authData && Object.keys(this.data.authData).length) { // UPDATE path (e.g. PUT /users/:id during linked-provider re-auth): apply // the same optimistic lock to the subsequent runDatabaseOperation update so // concurrent single-use token consumers cannot both succeed. applyAuthDataOptimisticLock(this.query, originalAuthData, this.data.authData); } } } }; // Rejects internal fields in client-supplied data RestWrite.prototype.checkInternalFields = function () { if (this.auth.isMaster || this.auth.isMaintenance) { return; } for (const fieldName in this.data) { if (fieldName.charAt(0) === '_') { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`); } } }; RestWrite.prototype.checkRestrictedFields = async function () { if (this.className !== '_User') { return; } if (!this.auth.isMaintenance && !this.auth.isMaster && 'emailVerified' in this.data) { throw createSanitizedError( Parse.Error.OPERATION_FORBIDDEN, "Clients aren't allowed to manually update email verification.", this.config ); } }; // Validates the create or update class-level permission before schema validation RestWrite.prototype.validateWritePermission = async function () { // A login with authData has already matched an existing user and set the response; // it creates no object, so the create permission does not apply. if (this.response || this.auth.isMaster || this.auth.isMaintenance) { return; } const schemaController = await this.config.database.loadSchema(); await schemaController.validatePermission( this.className, this.runOptions.acl || [], this.query ? 'update' : 'create' ); }; // Authorize a _User update before any step reads the target account RestWrite.prototype.authorizeUserUpdate = async function () { if (this.className !== '_User' || !this.query) { return; } if (this.auth.isMaster || this.auth.isMaintenance) { return; } if (this.auth.isUnauthenticated()) { throw createSanitizedError( Parse.Error.SESSION_MISSING, `Cannot modify user ${this.query.objectId}.`, this.config ); } // Body objectId must not retarget the update if (this.data.objectId !== undefined && this.data.objectId !== this.query.objectId) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } // Owner update reads only own data; the write stays ACL-checked if (this.auth.user.id === this.query.objectId) { return; } // Write access check via the write-path ACL enforcement await this.config.database.update( this.className, { objectId: this.query.objectId }, {}, this.runOptions, false, true ); }; // The non-third-party parts of User transformation RestWrite.prototype.transformUser = async function () { var promise = Promise.resolve(); if (this.className !== '_User') { return promise; } // Do not cleanup session if objectId is not set if (this.query && this.objectId()) { // If we're updating a _User object, we need to clear out the cache for that user. Find all their // session tokens, and remove them from the cache. const query = await RestQuery({ method: RestQuery.Method.find, config: this.config, auth: Auth.master(this.config), className: '_Session', runBeforeFind: false, restWhere: { user: { __type: 'Pointer', className: '_User', objectId: this.objectId(), }, }, }); promise = query.execute().then(results => { results.results.forEach(session => this.config.cacheController.user.del(session.sessionToken) ); }); } return promise .then(() => { // Transform the password if (this.data.password === undefined) { // ignore only if undefined. should proceed if empty ('') return Promise.resolve(); } if (this.query) { this.storage['clearSessions'] = true; // Generate a new session only if the user requested if (!this.auth.isMaster && !this.auth.isMaintenance) { this.storage['generateNewSession'] = true; } } return this._validatePasswordPolicy().then(() => { return passwordCrypto.hash(this.data.password).then(hashedPassword => { this.data._hashed_password = hashedPassword; delete this.data.password; }); }); }) .then(() => { return this._validateUserName(); }) .then(() => { return this._validateEmail(); }); }; RestWrite.prototype._validateUserName = function () { // Check for username uniqueness if (!this.data.username) { if (!this.query) { this.data.username = cryptoUtils.randomString(25); this.responseShouldHaveUsername = true; } return Promise.resolve(); } /* Usernames should be unique when compared case insensitively Users should be able to make case sensitive usernames and login using the case they entered. I.e. 'Snoopy' should preclude 'snoopy' as a valid username. */ return this.config.database .find( this.className, { username: this.data.username, objectId: { $ne: this.objectId() }, }, { limit: 1, caseInsensitive: true }, {}, this.validSchemaController ) .then(results => { if (results.length > 0) { throw new Parse.Error( Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.' ); } return; }); }; RestWrite.buildCreatedWith = function (action, authProvider) { return { action, authProvider: authProvider || 'password' }; }; RestWrite.prototype.getCreatedWith = function () { if (this.storage.createdWith) { return this.storage.createdWith; } const isCreateOperation = !this.query; const authDataProvider = this.data?.authData && Object.keys(this.data.authData).length && Object.keys(this.data.authData).join(','); const authProvider = this.storage.authProvider || authDataProvider; // storage.authProvider is only set for login (existing user found in handleAuthData) const action = this.storage.authProvider ? 'login' : isCreateOperation ? 'signup' : undefined; if (!action) { return; } const resolvedAuthProvider = authProvider || (action === 'signup' ? 'password' : undefined); this.storage.createdWith = RestWrite.buildCreatedWith(action, resolvedAuthProvider); return this.storage.createdWith; }; /* As with usernames, Parse should not allow case insensitive collisions of email. unlike with usernames (which can have case insensitive collisions in the case of auth adapters), emails should never have a case insensitive collision. This behavior can be enforced through a properly configured index see: https://docs.mongodb.com/manual/core/index-case-insensitive/#create-a-case-insensitive-index which could be implemented instead of this code based validation. Given that this lookup should be a relatively low use case and that the case sensitive unique index will be used by the db for the query, this is an adequate solution. */ RestWrite.prototype._validateEmail = function () { if (!this.data.email || this.data.email.__op === 'Delete') { return Promise.resolve(); } // Validate basic email address format in linear time if (!this.data.email.match(/^.[^@\n\r\u2028\u2029]*@.+$/)) { return Promise.reject( new Parse.Error(Parse.Error.INVALID_EMAIL_ADDRESS, 'Email address format is invalid.') ); } // Case insensitive match, see note above function. return this.config.database .find( this.className, { email: this.data.email, objectId: { $ne: this.objectId() }, }, { limit: 1, caseInsensitive: true }, {}, this.validSchemaController ) .then(results => { if (results.length > 0) { throw new Parse.Error( Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.' ); } if ( !this.data.authData || !Object.keys(this.data.authData).length || (Object.keys(this.data.authData).length === 1 && Object.keys(this.data.authData)[0] === 'anonymous') ) { // We updated the email, send a new validation const { originalObject, updatedObject } = this.buildParseObjects(); const request = { original: originalObject, object: updatedObject, master: this.auth.isMaster, ip: this.config.ip, installationId: this.auth.installationId, createdWith: this.getCreatedWith(), }; return this.config.userController.setEmailVerifyToken(this.data, request, this.storage); } }); }; RestWrite.prototype._validatePasswordPolicy = function () { if (!this.config.passwordPolicy) { return Promise.resolve(); } return this._validatePasswordRequirements().then(() => { return this._validatePasswordHistory(); }); }; RestWrite.prototype._validatePasswordRequirements = function () { // check if the password conforms to the defined password policy if configured // If we specified a custom error in our configuration use it. // Example: "Passwords must include a Capital Letter, Lowercase Letter, and a number." // // This is especially useful on the generic "password reset" page, // as it allows the programmer to communicate specific requirements instead of: // a. making the user guess whats wrong // b. making a custom password reset page that shows the requirements const policyError = this.config.passwordPolicy.validationError ? this.config.passwordPolicy.validationError : 'Password does not meet the Password Policy requirements.'; const containsUsernameError = 'Password cannot contain your username.'; // check whether the password meets the password strength requirements if ( (this.config.passwordPolicy.patternValidator && !this.config.passwordPolicy.patternValidator(this.data.password)) || (this.config.passwordPolicy.validatorCallback && !this.config.passwordPolicy.validatorCallback(this.data.password)) ) { return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, policyError)); } // check whether password contain username if (this.config.passwordPolicy.doNotAllowUsername === true) { if (this.data.username) { // username is not passed during password reset if (this.data.password.indexOf(this.data.username) >= 0) { return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError)); } } else if (this.query) { // retrieve the User object using the URL object ID during password reset return this.config.database.find('_User', { objectId: this.query.objectId }).then(results => { if (results.length != 1) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } if (this.data.password.indexOf(results[0].username) >= 0) { return Promise.reject( new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError) ); } return Promise.resolve(); }); } } return Promise.resolve(); }; RestWrite.prototype._validatePasswordHistory = function () { // check whether password is repeating from specified history if (this.query && this.config.passwordPolicy.maxPasswordHistory) { return this.config.database .find( '_User', { objectId: this.query.objectId }, { keys: ['_password_history', '_hashed_password'] }, Auth.maintenance(this.config) ) .then(results => { if (results.length != 1) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } const user = results[0]; let oldPasswords = []; if (user._password_history) { oldPasswords = _.take( user._password_history, this.config.passwordPolicy.maxPasswordHistory - 1 ); } oldPasswords.push(user.password); const newPassword = this.data.password; // compare the new password hash with all old password hashes const promises = oldPasswords.map(function (hash) { return passwordCrypto.compare(newPassword, hash).then(result => { if (result) // reject if there is a match { return Promise.reject('REPEAT_PASSWORD'); } return Promise.resolve(); }); }); // wait for all comparisons to complete return Promise.all(promises) .then(() => { return Promise.resolve(); }) .catch(err => { if (err === 'REPEAT_PASSWORD') // a match was found { return Promise.reject( new Parse.Error( Parse.Error.VALIDATION_ERROR, `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.` ) ); } throw err; }); }); } return Promise.resolve(); }; RestWrite.prototype.createSessionTokenIfNeeded = async function () { if (this.className !== '_User') { return; } // Don't generate session for updating user (this.query is set) unless authData exists if (this.query && !this.data.authData) { return; } // Don't generate new sessionToken if linking via sessionToken if (this.auth.user && this.data.authData) { return; } // If sign-up call if (!this.storage.authProvider) { // Create request object for verification functions const { originalObject, updatedObject } = this.buildParseObjects(); const request = { original: originalObject, object: updatedObject, master: this.auth.isMaster, ip: this.config.ip, installationId: this.auth.installationId, createdWith: this.getCreatedWith(), }; // Get verification conditions which can be booleans or functions; the purpose of this async/await // structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the // conditional statement below, as a developer may decide to execute expensive operations in them const verifyUserEmails = async () => this.config.verifyUserEmails === true || (typeof this.config.verifyUserEmails === 'function' && await Promise.resolve(this.config.verifyUserEmails(request)) === true); const preventLoginWithUnverifiedEmail = async () => this.config.preventLoginWithUnverifiedEmail === true || (typeof this.config.preventLoginWithUnverifiedEmail === 'function' && await Promise.resolve(this.config.preventLoginWithUnverifiedEmail(request)) === true); // If verification is required if (await verifyUserEmails() && await preventLoginWithUnverifiedEmail()) { this.storage.rejectSignup = true; return; } } return this.createSessionToken(); }; RestWrite.prototype.createSessionToken = async function () { // cloud installationId from Cloud Code, // never create session tokens from there. if (this.auth.installationId && this.auth.installationId === 'cloud') { return; } if (this.storage.authProvider == null && this.data.authData) { this.storage.authProvider = Object.keys(this.data.authData).join(','); // Invalidate cached createdWith since authProvider was just resolved delete this.storage.createdWith; } const createdWith = this.getCreatedWith(); const { sessionData, createSession } = RestWrite.createSession(this.config, { userId: this.objectId(), createdWith, installationId: this.auth.installationId, }); if (this.response && this.response.response) { this.response.response.sessionToken = sessionData.sessionToken; this.storage.issuedSessionToken = sessionData.sessionToken; } return createSession(); }; RestWrite.createSession = function ( config, { userId, createdWith, installationId, additionalSessionData } ) { const token = 'r:' + cryptoUtils.newToken(); const expiresAt = config.generateSessionExpiresAt(); const sessionData = { sessionToken: token, user: { __type: 'Pointer', className: '_User', objectId: userId, }, createdWith, expiresAt: Parse._encode(expiresAt), }; if (installationId) { sessionData.installationId = installationId; } Object.assign(sessionData, additionalSessionData); return { sessionData, createSession: () => new RestWrite(config, Auth.master(config), '_Session', null, sessionData).execute(), }; }; // Delete email reset tokens if user is changing password or email. RestWrite.prototype.deleteEmailResetTokenIfNeeded = function () { if (this.className !== '_User' || this.query === null) { // null query means create return; } if ('password' in this.data || 'email' in this.data) { const addOps = { _perishable_token: { __op: 'Delete' }, _perishable_token_expires_at: { __op: 'Delete' }, }; this.data = Object.assign(this.data, addOps); } }; RestWrite.prototype.destroyDuplicatedSessions = function () { // Skip if the response is already set, matching the other write-pipeline steps // (runDatabaseOperation, runAfterSaveTrigger). A non-master POST /classes/_Session // create has handleSession() set this.response before this runs, so this guard // prevents the dedup delete from acting on the client-supplied `user`/`installationId` // rather than on the server-generated session data. if (this.response) { return; } // Only for _Session, and at creation time if (this.className != '_Session' || this.query) { return; } // Destroy the sessions in 'Background' const { user, installationId, sessionToken } = this.data; if (!user || !installationId) { return; } if (!user.objectId) { return; } return this.config.database.destroy( '_Session', { user, installationId, sessionToken: { $ne: sessionToken }, }, {}, this.validSchemaController ).catch(e => { if (e.code !== Parse.Error.OBJECT_NOT_FOUND) { throw e; } }); }; // Handles any followup logic RestWrite.prototype.handleFollowup = function () { if (this.storage && this.storage['clearSessions'] && this.config.revokeSessionOnPasswordReset) { var sessionQuery = { user: { __type: 'Pointer', className: '_User', objectId: this.objectId(), }, }; delete this.storage['clearSessions']; return this.config.database .destroy('_Session', sessionQuery) .then(this.handleFollowup.bind(this)); } if (this.storage && this.storage['generateNewSession']) { delete this.storage['generateNewSession']; return this.createSessionToken().then(this.handleFollowup.bind(this)); } if (this.storage && this.storage['sendVerificationEmail']) { delete this.storage['sendVerificationEmail']; // Fire and forget! this.config.userController.sendVerificationEmail(this.data, { auth: this.auth }); return this.handleFollowup.bind(this); } }; // Handles the _Session class specialness. // Does nothing if this isn't an _Session object. RestWrite.prototype.handleSession = function () { if (this.response || this.className !== '_Session') { return; } if (!this.auth.user && !this.auth.isMaster && !this.auth.isMaintenance) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token required.'); } // TODO: Verify proper error to throw if ('ACL' in this.data) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Cannot set ' + 'ACL on a Session.'); } if (this.query) { if ('user' in this.data && !this.auth.isMaster && this.data.user?.objectId !== this.auth.user.id) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: user'); } else if ('installationId' in this.data) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: installationId'); } else if ('sessionToken' in this.data) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: sessionToken'); } else if ('expiresAt' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: expiresAt'); } else if ('createdWith' in this.data && !this.auth.isMaster && !this.auth.isMaintenance) { throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, 'Invalid key name: createdWith'); } if (!this.auth.isMaster) { this.query = { $and: [ this.query, { user: { __type: 'Pointer', className: '_User', objectId: this.auth.user.id, }, }, ], }; } } if (!this.query && !this.auth.isMaster && !this.auth.isMaintenance) { const additionalSessionData = {}; for (var key in this.data) { if (key === 'objectId' || key === 'user' || key === 'sessionToken' || key === 'expiresAt' || key === 'createdWith') { continue; } additionalSessionData[key] = this.data[key]; } const { sessionData, createSession } = RestWrite.createSession(this.config, { userId: this.auth.user.id, createdWith: { action: 'create', }, additionalSessionData, }); // Enforce the caller's class-level permissions and schema before the master write const validated = this.validateWritePermission().then(() => this.validateSchema()); return validated.then(() => createSession()).then(results => { if (!results.response) { throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Error creating session.'); } sessionData['objectId'] = results.response['objectId']; this.response = { status: 201, location: results.location, response: sessionData, }; }); } }; // Handles the _Installation class specialness. // Does nothing if this isn't an installation object. // If an installation is found, this can mutate this.query and turn a create // into an update. // Returns a promise for when we're done if it can't finish this tick. RestWrite.prototype.handleInstallation = function () { if (this.response || this.className !== '_Installation') { return; } // The deduplication below embeds these client-supplied values directly into database // queries that delete or update rows with master privileges, and it runs before // `validateSchema`, so their types must be enforced here: a non-string value would // otherwise reach the database as a query constraint (such as an operator object // `{"$ne": null}`) matching rows the client never identified, instead of as a literal // value to match against. The schema declares all three as `String`, but that check // cannot be reused here; it runs later in the write pipeline and moving it earlier // would mutate the schema before the permission check. The field list is a property of // this function rather than of the schema: it is the set of values spliced into the // deduplication queries below. for (const fieldName of ['deviceToken', 'installationId', 'appIdentifier']) { const value = this.data[fieldName]; if (value === undefined || value === null || typeof value === 'string') { continue; } if (fieldName === 'appIdentifier' && value.__op === 'Delete') { continue; } const actualType = Array.isArray(value) ? 'Array' : `${typeof value}`.replace(/^./, character => character.toUpperCase()); throw new Parse.Error( Parse.Error.INCORRECT_TYPE, `schema mismatch for _Installation.${fieldName}; expected String but got ${actualType}` ); } if ( !this.query && !this.data.deviceToken && !this.data.installationId && !this.auth.installationId ) { throw new Parse.Error( 135, 'at least one ID field (deviceToken, installationId) ' + 'must be specified in this operation' ); } // If the device token is 64 characters long, we assume it is for iOS // and lowercase it. if (this.data.deviceToken && this.data.deviceToken.length == 64) { this.data.deviceToken = this.data.deviceToken.toLowerCase(); } // We lowercase the installationId if present if (this.data.installationId) { this.data.installationId = this.data.installationId.toLowerCase(); } let installationId = this.data.installationId; // Create only: the request installation ID names the caller's own installation if (!installationId && !this.query && !this.auth.isMaster && !this.auth.isMaintenance) { installationId = this.auth.installationId; } if (installationId) { installationId = installationId.toLowerCase(); } // Updating _Installation but not updating anything critical if (this.query && !this.data.deviceToken && !installationId && !this.data.deviceType) { return; } var promise = Promise.resolve(); // Whether the caller may write the addressed installation, checked as the write checks it const canWriteInstallation = async () => { if (this.auth.isMaster || this.auth.isMaintenance) { return true; } try { await this.config.database.update( this.className, { objectId: this.query.objectId }, {}, this.runOptions, false, true ); return true; } catch (error) { if (error instanceof Parse.Error) { return false; } throw error; } }; var idMatch; // Will be a match on either objectId or installationId var objectIdMatch; var installationIdMatch; var deviceTokenMatches = []; // Instead of issuing 3 reads, let's do it with one OR. const orQueries = []; if (this.query && this.query.objectId) { orQueries.push({ objectId: this.query.objectId, }); } if (installationId) { orQueries.push({ installationId: installationId, }); } if (this.data.deviceToken) { orQueries.push({ deviceToken: this.data.deviceToken }); } if (orQueries.length == 0) { return; } promise = promise .then(() => { return this.config.database.find( '_Installation', { $or: orQueries, }, {} ); }) .then(async results => { results.forEach(result => { if (this.query && this.query.objectId && result.objectId == this.query.objectId) { objectIdMatch = result; } if (result.installationId == installationId) { installationIdMatch = result; } if (result.deviceToken == this.data.deviceToken) { deviceTokenMatches.push(result); } }); // Sanity checks when running a query if (this.query && this.query.objectId) { let error; if (!objectIdMatch) { error = new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for update.'); } else if ( this.data.installationId && objectIdMatch.installationId && this.data.installationId !== objectIdMatch.installationId ) { error = new Parse.Error(136, 'installationId may not be changed in this ' + 'operation'); } else if ( this.data.deviceToken && objectIdMatch.deviceToken && this.data.deviceToken !== objectIdMatch.deviceToken && !this.data.installationId && !objectIdMatch.installationId ) { error = new Parse.Error(136, 'deviceToken may not be changed in this ' + 'operation'); } else if (this.data.deviceType && this.data.deviceType !== objectIdMatch.deviceType) { error = new Parse.Error(136, 'deviceType may not be changed in this ' + 'operation'); } if (error) { if (await canWriteInstallation()) { throw error; } // Without write access, the write pipeline answers as for any class return false; } } if (this.query && this.query.objectId && objectIdMatch) { idMatch = objectIdMatch; } if (installationId && installationIdMatch) { idMatch = installationIdMatch; } // need to specify deviceType only if it's new if (!this.query && !this.data.deviceType && !idMatch) { throw new Parse.Error(135, 'deviceType must be specified in this operation'); } return true; }) .then(proceed => { if (!proceed) { return; } if (!idMatch) { if (!deviceTokenMatches.length) { return; } else if ( deviceTokenMatches.length == 1 && (!deviceTokenMatches[0]['installationId'] || !installationId) ) { // Single match on device token but none on installationId, and either // the passed object or the match is missing an installationId, so we // can just return the match. return deviceTokenMatches[0]['objectId']; } else if (!this.data.installationId) { throw new Parse.Error( 132, 'Must specify installationId when deviceToken ' + 'matches multiple Installation objects' ); } else { // Multiple device token matches and we specified an installation ID, // or a single match where both the passed and matching objects have // an installation ID. Clean out other installations that match the // deviceToken, and return nil to signal that a new object should be // created. const delQuery = { deviceToken: this.data.deviceToken, installationId: { $ne: installationId, }, }; if (this.data.appIdentifier) { // A `Delete` operation is applied only after the deduplication runs, and no // installation matched here to take a scope from. Skip the cleanup rather than // run it unscoped across every application, or query on the operation itself. if (typeof this.data.appIdentifier !== 'string') { return; } delQuery['appIdentifier'] = this.data.appIdentifier; } const installationOpts = this.config.installation || {}; return InstallationDedup.removeConflictingDeviceToken({ database: this.config.database, query: delQuery, action: installationOpts.duplicateDeviceTokenAction || 'delete', enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, runOptions: this.runOptions, validSchemaController: this.validSchemaController, }); } } else { if (deviceTokenMatches.length == 1 && !deviceTokenMatches[0]['installationId']) { // Exactly one device token match and it doesn't have an installation // ID. The two rows represent the same install; resolve the merge per // the configured options. const installationOpts = this.config.installation || {}; return InstallationDedup.applyDuplicateDeviceTokenMerge({ database: this.config.database, idMatch, deviceTokenMatch: deviceTokenMatches[0], action: installationOpts.duplicateDeviceTokenAction || 'delete', mergePriority: installationOpts.duplicateDeviceTokenMergePriority || 'deviceToken', enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, runOptions: this.runOptions, validSchemaController: this.validSchemaController, }); } else { if (this.data.deviceToken && idMatch.deviceToken != this.data.deviceToken) { // We're setting the device token on an existing installation, so // we should try cleaning out old installations that match this // device token. const delQuery = { deviceToken: this.data.deviceToken, }; // We have a unique install Id, use that to preserve // the interesting installation if (this.data.installationId) { delQuery['installationId'] = { $ne: this.data.installationId, }; } else if ( idMatch.objectId && this.data.objectId && idMatch.objectId == this.data.objectId ) { // we passed an objectId, preserve that instalation delQuery['objectId'] = { $ne: idMatch.objectId, }; } else { // What to do here? can't really clean up everything... return idMatch.objectId; } if (this.data.appIdentifier) { // A `Delete` operation is applied only after the deduplication runs, so scope // the cleanup to the value the matched installation still holds. Dropping the // constraint would let the cleanup reach installations of other applications, // and the operation itself cannot match a String, so skip the cleanup when no // scope is available. const appIdentifier = typeof this.data.appIdentifier === 'string' ? this.data.appIdentifier : idMatch.appIdentifier; if (typeof appIdentifier !== 'string') { return idMatch.objectId; } delQuery['appIdentifier'] = appIdentifier; } const installationOpts = this.config.installation || {}; return InstallationDedup.removeConflictingDeviceToken({ database: this.config.database, query: delQuery, action: installationOpts.duplicateDeviceTokenAction || 'delete', enforceAuth: installationOpts.duplicateDeviceTokenActionEnforceAuth === true, runOptions: this.runOptions, validSchemaController: this.validSchemaController, }).then(() => idMatch.objectId); } // In non-merge scenarios, just return the installation match id return idMatch.objectId; } } }) .then(objId => { if (objId) { this.query = { objectId: objId }; delete this.data.objectId; delete this.data.createdAt; } // TODO: Validate ops (add/remove on channels, $inc on badge, etc.) }); return promise; }; // If we short-circuited the object response - then we need to make sure we expand all the files, // since this might not have a query, meaning it won't return the full result back. // TODO: (nlutsenko) This should die when we move to per-class based controllers on _Session/_User RestWrite.prototype.expandFilesForExistingObjects = async function () { // Check whether we have a short-circuited response - only then run expansion. if (this.response && this.response.response) { await this.config.filesController.expandFilesInObject(this.config, this.response.response); } }; RestWrite.prototype.runDatabaseOperation = function () { if (this.response) { return; } if (this.className === '_Role') { this.config.cacheController.role.clear(); if (this.config.liveQueryController) { this.config.liveQueryController.clearCachedRoles(this.auth.user); } } if (this.className === '_User' && this.query && this.auth.isUnauthenticated()) { throw createSanitizedError( Parse.Error.SESSION_MISSING, `Cannot modify user ${this.query.objectId}.`, this.config ); } if (this.className === '_Product' && this.data.download) { this.data.downloadName = this.data.download.name; } // TODO: Add better detection for ACL, ensuring a user can't be locked from // their own user record. if (this.data.ACL && this.data.ACL['*unresolved']) { throw new Parse.Error(Parse.Error.INVALID_ACL, 'Invalid ACL.'); } if (this.query) { // Force the user to not lockout // Matched with parse.com if ( this.className === '_User' && this.data.ACL && this.auth.isMaster !== true && this.auth.isMaintenance !== true ) { this.data.ACL[this.query.objectId] = { read: true, write: true }; } // update password timestamp if user password is being changed if ( this.className === '_User' && this.data._hashed_password && this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordAge ) { this.data._password_changed_at = Parse._encode(new Date()); } // Ignore createdAt when update delete this.data.createdAt; let defer = Promise.resolve(); // if password history is enabled then save the current password to history if ( this.className === '_User' && this.data._hashed_password && this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordHistory ) { defer = this.config.database .find( '_User', { objectId: this.query.objectId }, { keys: ['_password_history', '_hashed_password'] }, Auth.maintenance(this.config) ) .then(results => { if (results.length != 1) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } const user = results[0]; let oldPasswords = []; if (user._password_history) { oldPasswords = _.take( user._password_history, this.config.passwordPolicy.maxPasswordHistory ); } //n-1 passwords go into history including last password while ( oldPasswords.length > Math.max(0, this.config.passwordPolicy.maxPasswordHistory - 2) ) { oldPasswords.shift(); } oldPasswords.push(user.password); this.data._password_history = oldPasswords; }); } return defer.then(() => { // Run an update // The unsanitized result of a client update carries the read access of the object const raw = !this.runOptions.many && !this.auth.isMaster && !this.auth.isMaintenance; return this.config.database .update( this.className, this.query, this.data, this.runOptions, raw, false, this.validSchemaController ) .catch(error => { this._throwIfAuthDataDuplicate(error); throw error; }) .then(async result => { let response = result; if (raw) { this.writtenReadAccess = result._rperm; this.writtenObject = result; response = await this.config.database._sanitizeDatabaseResult(this.data, result); } response.updatedAt = this.updatedAt; this._updateResponseWithData(response, this.data); this.response = { response }; }); }); } else { // Set the default ACL and password timestamp for the new _User if (this.className === '_User') { var ACL = this.data.ACL; // default public r/w ACL if (!ACL) { ACL = {}; if (!this.config.enforcePrivateUsers) { ACL['*'] = { read: true, write: false }; } } // make sure the user is not locked down ACL[this.data.objectId] = { read: true, write: true }; this.data.ACL = ACL; // password timestamp to be used when password expiry policy is enforced if (this.config.passwordPolicy && this.config.passwordPolicy.maxPasswordAge) { this.data._password_changed_at = Parse._encode(new Date()); } } // Run a create return this.config.database .create(this.className, this.data, this.runOptions, false, this.validSchemaController) .catch(error => { if (this.className !== '_User' || error.code !== Parse.Error.DUPLICATE_VALUE) { throw error; } this._throwIfAuthDataDuplicate(error); // Quick check, if we were able to infer the duplicated field name if (error && error.userInfo && error.userInfo.duplicated_field === 'username') { throw new Parse.Error( Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.' ); } if (error && error.userInfo && error.userInfo.duplicated_field === 'email') { throw new Parse.Error( Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.' ); } // If this was a failed user creation due to username or email already taken, we need to // check whether it was username or email and return the appropriate error. // Fallback to the original method // TODO: See if we can later do this without additional queries by using named indexes. return this.config.database .find( this.className, { username: this.data.username, objectId: { $ne: this.objectId() }, }, { limit: 1 } ) .then(results => { if (results.length > 0) { throw new Parse.Error( Parse.Error.USERNAME_TAKEN, 'Account already exists for this username.' ); } return this.config.database.find( this.className, { email: this.data.email, objectId: { $ne: this.objectId() } }, { limit: 1 } ); }) .then(results => { if (results.length > 0) { throw new Parse.Error( Parse.Error.EMAIL_TAKEN, 'Account already exists for this email address.' ); } throw new Parse.Error( Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided' ); }); }) .then(response => { response.objectId = this.data.objectId; response.createdAt = this.data.createdAt; if (this.responseShouldHaveUsername) { response.username = this.data.username; } this._updateResponseWithData(response, this.data); // Read access as stored const acl = this.data.ACL; this.writtenReadAccess = acl ? Object.keys(acl).filter(key => acl[key].read) : null; this.writtenObject = this.data; this.response = { status: 201, response, location: this.location(), }; }); } }; // Returns nothing - doesn't wait for the trigger. RestWrite.prototype.runAfterSaveTrigger = function () { if (!this.response || !this.response.response || this.runOptions.many) { return; } // Avoid doing any setup for triggers if there is no 'afterSave' trigger for this class. const hasAfterSaveHook = triggers.triggerExists( this.className, triggers.Types.afterSave, this.config.applicationId ); const hasLiveQuery = this.config.liveQueryController.hasLiveQuery(this.className); if (!hasAfterSaveHook && !hasLiveQuery) { return Promise.resolve(); } const { originalObject, updatedObject } = this.buildParseObjects(); updatedObject._handleSaveResponse( this.cloneWithFileUrls(this.response.response), this.response.status || 200 ); if (hasLiveQuery) { this.config.database .loadSchema() .then(schemaController => { // Notify LiveQueryServer if possible const perms = schemaController.getClassLevelPermissions(updatedObject.className); this.config.liveQueryController.onAfterSave( updatedObject.className, updatedObject, originalObject, perms ); }) .catch(err => { logger.error('LiveQuery afterSave notification failed', err); }); } if (!hasAfterSaveHook) { return Promise.resolve(); } // Run afterSave trigger return triggers .maybeRunTrigger( triggers.Types.afterSave, this.auth, updatedObject, originalObject, this.config, this.context ) .then(result => { const jsonReturned = result && !result._toFullJSON; if (jsonReturned) { // The returned object is the response as is this.storage.responseFromTrigger = true; this.pendingOps.operations = {}; this.response.response = result; } else { this.response.response = this._updateResponseWithData( (result || updatedObject).toJSON(), this.data, true ); } }) .catch(function (err) { logger.warn('afterSave caught an error', err); }); }; // A helper to figure out what location this operation happens at. RestWrite.prototype.location = function () { var middle = this.className === '_User' ? '/users/' : '/classes/' + this.className + '/'; const mount = this.config.mount || this.config.serverURL; return mount + middle + this.data.objectId; }; // A helper to get the object id for this operation. // Because it could be either on the query or on the data RestWrite.prototype.objectId = function () { return this.data.objectId || this.query.objectId; }; // Returns a copy of the data and delete bad keys (_auth_data, _hashed_password...) RestWrite.prototype.sanitizedData = function () { const data = Object.keys(this.data).reduce((data, key) => { // Regexp comes from Parse.Object.prototype.validate if (!/^[A-Za-z][0-9A-Za-z_]*$/.test(key)) { delete data[key]; } return data; }, this.cloneWithFileUrls(this.data)); return Parse._decode(undefined, data); }; // A raw view of the object's fields (stored values overlaid with whole-field // writes in this request) used to resolve whether a dot notation key targets a // pointer's object ID. RestWrite.prototype._dotNotationFieldRoot = function () { const root = Object.assign({}, this.originalData); if (this.data) { for (const key of Object.keys(this.data)) { if (key.indexOf('.') < 0) { root[key] = this.data[key]; } } } return root; }; const prohibitedDotNotationKey = key => new Parse.Error( Parse.Error.INVALID_KEY_NAME, `Prohibited dot notation key in request data: ${JSON.stringify(key)}.` ); // Values the SDK decodes to an instance rather than a plain object: an operation or a // typed value const decodesToInstance = value => { if (typeof value.__op === 'string') { return true; } switch (value.__type) { case 'Pointer': case 'Object': return !!value.className; case 'Relation': case 'Date': case 'File': case 'GeoPoint': case 'Polygon': return true; default: return false; } }; // Whether the SDK would resolve a non-leaf segment of the key, from the given depth // on, through the prototype chain of the container instead of an own key, writing // the final segment onto a shared built-in. const descendsIntoPrototype = (container, segments, depth = 0) => { for (let i = depth; i < segments.length - 1; i++) { if (container === null || typeof container !== 'object') { return false; } // A further segment walks the prototype of the decoded instance if (decodesToInstance(container)) { return true; } const segment = segments[i]; if (!hasOwn(container, segment)) { // An inherited member of a nested value resolves to a shared built-in; the root // has no prototype in the SDK, and a missing key is created fresh return i > 0 && segment in container; } container = container[segment]; } return false; }; // The container a written value provides for keys below it: the value itself, or // the elements an array operation adds const writtenContainer = value => { if (value === null || typeof value !== 'object') { return undefined; } if (typeof value.__op === 'string') { return Array.isArray(value.objects) ? value.objects : undefined; } return value; }; // Rejects a dot notation key that the SDK would apply through a prototype member of // the stored object (the trigger object is built from it before the other fields of // this request are applied), of the object as written by this request, or of a value // another key of this request writes above it. RestWrite.prototype.validateDotNotationKeys = function () { const keys = Object.keys(this.data); const keySet = new Set(keys); const roots = [this._dotNotationFieldRoot()]; if (this.originalData) { roots.push(this.originalData); } // Only a key that is a prefix of this key, ending on a segment boundary, writes above it const writtenAbove = segments => { let prefix = segments[0]; for (let depth = 1; depth < segments.length - 1; depth++) { if (keySet.has(prefix)) { const container = writtenContainer(this.data[prefix]); if (container !== undefined && descendsIntoPrototype(container, segments, depth)) { return true; } } prefix += `.${segments[depth]}`; } return false; }; for (const key of keys) { if (key.indexOf('.') < 0) { continue; } const segments = key.split('.'); if (roots.some(root => descendsIntoPrototype(root, segments)) || writtenAbove(segments)) { throw prohibitedDotNotationKey(key); } } }; // Returns an updated copy of the object RestWrite.prototype.buildParseObjects = function ({ copyStoredValues = false } = {}) { const extraData = { className: this.className, objectId: this.query?.objectId }; let originalObject; if (this.query && this.query.objectId) { originalObject = triggers.inflate(extraData, this.originalData); } const className = Parse.Object.fromJSON(extraData); const readOnlyAttributes = className.constructor.readOnlyAttributes ? className.constructor.readOnlyAttributes() : []; // For _Role class, 'name' cannot be set after the role has an objectId. // In afterSave context, _handleSaveResponse has already set the objectId, // so we treat 'name' as read-only to avoid Parse SDK validation errors. const isRoleAfterSave = this.className === '_Role' && this.response && !this.query; if (isRoleAfterSave && this.data.name && !readOnlyAttributes.includes('name')) { readOnlyAttributes.push('name'); } if (!this.originalData) { for (const attribute of readOnlyAttributes) { extraData[attribute] = this.data[attribute]; } } const updatedObject = triggers.inflate(extraData, this.originalData); const pointerRoot = this._dotNotationFieldRoot(); Object.keys(this.data).reduce(function (data, key) { if (key.indexOf('.') > 0) { // Null and undefined are set at their own dot notation path, like an operation if (data[key] == null || typeof data[key].__op === 'string') { // A dot notation key that removes a pointer's object ID would leave the // pointer unserializable and break the trigger object, so skip it. if (!readOnlyAttributes.includes(key) && !targetsPointerId(pointerRoot, key)) { // The SDK key name rules apply to top-level fields only updatedObject.set(key, data[key], { ignoreValidation: true }); } } else { // subdocument key with dot notation { 'x.y': v } => { 'x': { 'y' : v } }) const splittedKey = key.split('.'); const parentProp = splittedKey[0]; let parentVal = updatedObject.get(parentProp); if (typeof parentVal !== 'object') { parentVal = {}; } else if (copyStoredValues && parentVal === updatedObject._getServerData()[parentProp]) { // A copy, so that a revert restores the stored value parentVal = copyStoredValue(parentVal); } parentVal[splittedKey[1]] = data[key]; updatedObject.set(parentProp, parentVal); } delete data[key]; } return data; }, this.cloneWithFileUrls(this.data)); const sanitized = this.sanitizedData(); for (const attribute of readOnlyAttributes) { delete sanitized[attribute]; } updatedObject.set(sanitized); return { updatedObject, originalObject }; }; RestWrite.prototype.cleanUserAuthData = function () { if (this.response && this.response.response && this.className === '_User') { const user = this.response.response; if (user.authData) { Object.keys(user.authData).forEach(provider => { if (user.authData[provider] === null) { delete user.authData[provider]; } }); if (Object.keys(user.authData).length == 0) { delete user.authData; } } } }; // Strips protected fields from the write response when protectedFieldsSaveResponseExempt is false. RestWrite.prototype.filterProtectedFieldsInResponse = async function () { if (this.config.protectedFieldsSaveResponseExempt !== false) { return; } if (this.auth.isMaster || this.auth.isMaintenance) { return; } if (!this.response || !this.response.response) { return; } const schemaController = await this.config.database.loadSchema(); const protectedFields = this.config.database.addProtectedFields( schemaController, this.className, this.query ? { objectId: this.query.objectId } : {}, this.auth.user ? [this.auth.user.id].concat(this.auth.userRoles || []) : [], this.auth, {} ); if (!protectedFields) { return; } for (const key of Object.keys(this.response.response)) { if (protectedFields.some(field => key === field || key.startsWith(`${field}.`))) { delete this.response.response[key]; } } }; // Copies a stored value whose in-place change the SDK would save const copyStoredValue = value => { if (Array.isArray(value)) { return [...value]; } if (value instanceof Parse.GeoPoint) { return new Parse.GeoPoint(value.latitude, value.longitude); } if (value instanceof Parse.Polygon) { return new Parse.Polygon(value.coordinates); } if (value instanceof Parse.ACL) { return new Parse.ACL(value.toJSON()); } if (value !== null && Object.getPrototypeOf(value) === Object.prototype) { return { ...value }; } return value; }; // Also reverts the dot notation keys beneath a reverted field const extendRevertToDotNotationKeys = object => { const revert = object.revert; Object.defineProperty(object, 'revert', { configurable: true, writable: true, value: function (...keys) { const stateController = Parse.CoreManager.getObjectStateController(); const pendingOps = stateController.getPendingOps(this._getStateIdentifier()); const beneath = Object.keys(pendingOps[pendingOps.length - 1]).filter(path => keys.some(key => typeof key === 'string' && path.startsWith(`${key}.`)) ); return revert.call(this, ...keys, ...beneath); }, }); }; const hasOwn = (object, key) => object !== null && object !== undefined && Object.prototype.hasOwnProperty.call(object, key); // Whether a dot notation path can descend into the value const isContainer = value => value !== null && typeof value === 'object' && !value.__type && !value.__op; // Reads a dot notation path through own properties of containers only const getOwnPath = (object, path) => { let value = object; for (const key of path) { if (!isContainer(value) || !hasOwn(value, key)) { return undefined; } value = value[key]; } return value; }; // Whether a dot notation key targets a pointer's object ID (`id`/`objectId`). // Removing it leaves the pointer unserializable (`toPointer` throws), which // corrupts the trigger object; other sub-keys below a pointer are left to the // existing handling. const targetsPointerId = (root, key) => { const segments = key.split('.'); for (let depth = 1; depth < segments.length; depth++) { const parent = getOwnPath(root, segments.slice(0, depth)); if (parent !== null && typeof parent === 'object' && parent.__type === 'Pointer') { const next = segments[depth]; return next === 'id' || next === 'objectId'; } } return false; }; // Restores a reverted dot notation key in the response RestWrite.prototype._restoreRevertedPath = function (response, writeData, written, key, finalObject) { // A write at or beneath the key governs if (written.some(k => k === key || k.startsWith(`${key}.`))) { return; } const path = key.split('.'); // A returned object without the field leaves the stored value const fromFinal = !!finalObject && hasOwn(finalObject, path[0]); let value = fromFinal ? finalObject : this.originalData || {}; let depth = 0; let authoritative = fromFinal; if (!fromFinal) { // A written ancestor governs; computed operations are returned expanded const ancestor = written .filter(k => key.startsWith(`${k}.`)) .sort((a, b) => b.length - a.length)[0]; if (ancestor) { const ancestorPath = ancestor.split('.'); let returned = getOwnPath(response, ancestorPath); // An unchanged computed result is no longer in the response const op = writeData[ancestor]?.__op; if (returned === undefined && op && op !== 'Delete') { returned = getOwnPath(this.originalData, ancestorPath); } value = returned !== undefined ? returned : writeData[ancestor]; if (!isContainer(value)) { return; } depth = ancestorPath.length; authoritative = true; } } while (depth < path.length && isContainer(value) && hasOwn(value, path[depth])) { value = value[path[depth++]]; } // A missing array index returns the whole array, so the client keeps no hole const missingIndex = depth < path.length && Array.isArray(value) && /^\d+$/.test(path[depth]); if (depth < path.length && !missingIndex) { if (isContainer(value)) { // Delete at the first missing key depth++; value = undefined; } else if (value?.__op) { return; } else { const unchanged = util.isDeepStrictEqual( value, getOwnPath(this.originalData, path.slice(0, depth)) ); if (!fromFinal || unchanged) { // Clears the client operation beneath an unchanged typed value if (value?.__type && unchanged) { response[key] = { __op: 'Delete' }; } return; } } } const target = path.slice(0, depth).join('.'); const keys = [...written, ...Object.keys(response)]; const overlaps = prefix => keys.some(k => k === prefix || k.startsWith(`${prefix}.`) || prefix.startsWith(`${k}.`)); if (!authoritative && (value === undefined || missingIndex) && overlaps(target)) { // Another write creates the path; clear the shallowest untouched reverted part if (value === undefined) { for (let length = depth + 1; length <= path.length; length++) { const prefix = path.slice(0, length).join('.'); if (!overlaps(prefix)) { response[prefix] = { __op: 'Delete' }; break; } } } return; } for (const responseKey of Object.keys(response)) { if (responseKey.startsWith(`${target}.`)) { delete response[responseKey]; } } response[target] = value === undefined ? { __op: 'Delete' } : value; }; // The value of an operation applied to an empty value const appliedToEmpty = value => { switch (value?.__op) { case 'Increment': return value.amount; case 'Add': return value.objects; case 'AddUnique': return _.uniqWith(value.objects, util.isDeepStrictEqual); case 'Remove': return []; default: return value; } }; // Sets the dot notation keys of the request in the response, applied to an empty value RestWrite.prototype._setRequestPaths = function (response) { // Fields the response keeps, such as objectId const kept = Object.keys(response); const arrays = new Set(); for (const [key, value] of this.requestOperations) { const segments = key.split('.'); if (segments.length < 2 || kept.includes(segments[0])) { continue; } // The SDK cannot apply a dot notation path to ACL if (segments[0] === 'ACL') { continue; } const op = value?.__op; // Another operation, such as a relation operation, has no value if (op && op !== 'Delete' && !computedOperations.includes(op)) { continue; } // An index below an Array field if ( this.validSchemaController.getExpectedType(this.className, segments[0])?.type === 'Array' && /^\d+$/.test(segments[1]) ) { arrays.add(segments[0]); } let container = response; for (const segment of segments.slice(0, -1)) { if (!hasOwn(container, segment) || !isContainer(container[segment])) { container[segment] = {}; } container = container[segment]; } // An unset leaves only the parents if (op !== 'Delete') { container[segments[segments.length - 1]] = appliedToEmpty(value); } } // Only the elements of the request, in index order for (const field of arrays) { response[field] = Object.values(response[field]); } }; // Whether the caller can read the object as written; changes after the write are not considered RestWrite.prototype._canReadWrittenObject = function (aclGroup) { const readers = this.writtenReadAccess; // A write result without valid read access fails closed if (readers !== null && !Array.isArray(readers)) { return false; } // The object ACL if (readers && !readers.some(reader => aclGroup.includes(reader))) { return false; } // A client reads a session with a find constrained to the session user const operation = this.className === '_Session' ? 'find' : 'get'; const permissions = this.validSchemaController.getClassLevelPermissions(this.className); if ( SchemaController.SchemaController.permissionDenial( permissions, this.className, aclGroup, operation ) ) { return false; } // The pointer permissions of the written object const users = aclGroup.filter(entry => entry !== '*' && !entry.startsWith('role:')); return SchemaController.SchemaController.testPointerPermissions( permissions, aclGroup, operation, users.length === 1 ? users[0] : undefined, field => { let value = this.writtenObject?.[field]; // A create stores an operation applied to an empty value if (!this.query) { value = appliedToEmpty(value); } // An update result of the Postgres adapter has the objectId of a pointer const expected = this.validSchemaController.getExpectedType(this.className, field); return typeof value === 'string' && expected?.type === 'Pointer' ? { __type: 'Pointer', className: expected.targetClass, objectId: value } : value; } ); }; // Returns stored values in a save response only if the caller can read the object as written RestWrite.prototype.filterUnreadableFieldsInResponse = function () { const response = this.response?.response; if (!response || this.storage.responseFromTrigger) { return; } // Login and session requests build their own response if (!this.query && this.writtenReadAccess === undefined) { return; } if (this.auth.isMaster || this.auth.isMaintenance) { return; } // A session token issued by this request is not a stored value const issued = this.storage.issuedSessionToken; // Nor is a required column that echoes the caller's unchanged input const echoed = key => (requiredColumns.read[this.className] || []).includes(key) && !(this.storage.fieldsChangedByTrigger || []).includes(key) && !(key === 'username' && this.responseShouldHaveUsername) && util.isDeepStrictEqual(response[key], this.data[key]); const fields = Object.keys(response).filter( key => !['objectId', 'createdAt', 'updatedAt'].includes(key) && !(key === 'sessionToken' && issued && response[key] === issued) && !echoed(key) ); if (!fields.length) { return; } // A sign-up response is for the new user if it issues the new user a session const signUp = !this.query && this.className === '_User' && !!issued; const aclGroup = signUp ? ['*', this.data.objectId] : this.runOptions.acl || []; if (!this._canReadWrittenObject(aclGroup)) { // No stored values for a caller who cannot read the object for (const key of fields) { delete response[key]; } this._setRequestPaths(response); return; } // A field reverted in beforeSave has the value of the original object, which triggers that are // exempt from protected fields read with the master key if (this.config.protectedFieldsTriggerExempt && this.query && this.pendingOps.operations) { const stateController = Parse.CoreManager.getObjectStateController(); const [pending] = stateController.getPendingOps(this.pendingOps.identifier); const reverted = Object.keys(this.pendingOps.operations).filter(key => !pending[key]); for (const key of fields) { if ( reverted.some( path => path === key || path.startsWith(`${key}.`) || key.startsWith(`${path}.`) ) ) { delete response[key]; } } } }; RestWrite.prototype._updateResponseWithData = function (response, data, isFinalObject = false) { const stateController = Parse.CoreManager.getObjectStateController(); const [pending] = stateController.getPendingOps(this.pendingOps.identifier); let revertedData; const revertedPaths = []; for (const key in this.pendingOps.operations) { if (pending[key]) { continue; } // The SDK cannot apply an operation or a dot notation path to ACL if (key.startsWith('ACL.') || (key === 'ACL' && !hasOwn(this.originalData, 'ACL'))) { continue; } if (key.includes('.')) { revertedPaths.push(key); continue; } // Response only; the write data builds the afterSave and LiveQuery objects revertedData ||= { ...data }; revertedData[key] = hasOwn(this.originalData, key) ? this.originalData[key] : { __op: 'Delete' }; this.storage.fieldsChangedByTrigger ||= []; // Once only, so an afterSave unset is not undone by a second entry if (this.storage.fieldsChangedByTrigger.indexOf(key) < 0) { this.storage.fieldsChangedByTrigger.push(key); } } const responseData = revertedData || data; // The final object, before unchanged fields are removed from the response const finalObject = isFinalObject && revertedPaths.length ? { ...response } : undefined; const skipKeys = [...(requiredColumns.read[this.className] || [])]; if (!this.query) { skipKeys.push('objectId', 'createdAt'); } else { skipKeys.push('updatedAt'); delete response.objectId; } // The client's operation results and dot notation parents, also if unchanged const requested = this.requestOperations .filter(([key]) => key.includes('.') ? hasOwn(data, key) : computedOperations.includes(data[key]?.__op) ) .map(([key]) => key.split('.')[0]); for (const key in response) { if (skipKeys.includes(key)) { continue; } const value = response[key]; if ( value == null || (value.__type && value.__type === 'Pointer') || util.isDeepStrictEqual(responseData[key], value) || (!requested.includes(key) && util.isDeepStrictEqual((this.originalData || {})[key], value)) ) { delete response[key]; } } (this.storage.fieldsChangedByTrigger || []).forEach(fieldName => { const dataValue = responseData[fieldName]; if (!Object.prototype.hasOwnProperty.call(response, fieldName)) { response[fieldName] = dataValue; } if (response[fieldName] && response[fieldName].__op) { delete response[fieldName]; if (dataValue.__op == 'Delete') { response[fieldName] = dataValue; } } }); if (revertedPaths.length) { const written = Object.keys(data); for (const key of revertedPaths) { this._restoreRevertedPath(response, data, written, key, finalObject); } } return response; }; export default RestWrite; module.exports = RestWrite;