{"record":{"id":"f35fc63d8b812553","repo":"RocketChat/Rocket.Chat","slug":"error-invalid-update-key","errorCode":"error-invalid-update-key","errorMessage":"Cannot update the message ${key}","messagePattern":"Cannot update the message (.+?)","errorType":"exception","errorClass":"Meteor.Error","httpStatus":null,"severity":"error","filePath":"apps/meteor/server/meteor-methods/messages/updateMessage.ts","lineNumber":28,"sourceCode":"import { applyAirGappedRestrictionsValidation } from '../../lib/cloud/license/airGappedRestrictionsWrapper';\nimport { updateMessage } from '../../lib/messages/updateMessage';\nimport { settings } from '../../settings';\n\nconst allowedEditedFields = ['tshow', 'alias', 'attachments', 'avatar', 'emoji', 'msg', 'customFields', 'content', 'e2eMentions'];\n\nexport async function executeUpdateMessage(\n\tuid: IUser['_id'],\n\tmessage: AtLeast<IMessage, '_id' | 'rid' | 'msg' | 'customFields'> | AtLeast<IMessage, '_id' | 'rid' | 'content'>,\n\tpreviewUrls?: string[],\n) {\n\tconst originalMessage = await Messages.findOneById(message._id);\n\tif (!originalMessage?._id) {\n\t\treturn;\n\t}\n\n\tObject.entries(message).forEach(([key, value]) => {\n\t\tif (!allowedEditedFields.includes(key) && value !== originalMessage[key as keyof IMessage]) {\n\t\t\tthrow new Meteor.Error('error-invalid-update-key', `Cannot update the message ${key}`, {\n\t\t\t\tmethod: 'updateMessage',\n\t\t\t});\n\t\t}\n\t});\n\n\t// IF the message has custom fields, always update\n\t// Ideally, we'll compare the custom fields to check for change, but since we don't know the shape of\n\t// custom fields, as it's user defined, we're gonna update\n\tconst msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg;\n\tif (msgText === message.msg && !previewUrls && !message.customFields) {\n\t\treturn;\n\t}\n\n\tif (!!message.tmid && originalMessage._id === message.tmid) {\n\t\tthrow new Meteor.Error('error-message-same-as-tmid', 'Cannot set tmid the same as the _id', {\n\t\t\tmethod: 'updateMessage',\n\t\t});\n\t}","sourceCodeStart":10,"sourceCodeEnd":46,"githubUrl":"https://github.com/RocketChat/Rocket.Chat/blob/b2c16d5842cbe6b69b59bdf6fc5e5f1afcd1f0b0/apps/meteor/server/meteor-methods/messages/updateMessage.ts#L10-L46","documentation":"executeUpdateMessage iterates every key of the submitted message patch and throws error-invalid-update-key when a key is outside allowedEditedFields AND its value differs from the stored message. Allowed keys are: tshow, alias, attachments, avatar, emoji, msg, customFields, content, e2eMentions. Echoing back unchanged protected fields (u, ts, rid, mentions, _id, ...) is tolerated; changing them is rejected, with the offending key named in the message.","triggerScenarios":"Client sends the whole message object from the UI with a mutated protected field (e.g. ts or mentions changed by client logic); integration attempts to change the author (u) or timestamp via updateMessage; a diffing bug that copies the full document into the patch; trying to move a message by editing rid.","commonSituations":"Message-edit forms binding the full message model instead of an { _id, rid, msg } patch; bots trying to rewrite history; client normalization (e.g. date parsing turning ts into a new object identity) counting as a value change.","solutions":["Send only the editable subset: { _id, rid, msg?, customFields?, attachments?, alias?, avatar?, emoji?, tshow?, content?, e2eMentions? }","Strip the patch through an allowlist picker before Meteor.call('updateMessage', ...)","Use the dedicated APIs for other mutations (pinning, reactions, moderation) instead of updateMessage","Ensure values that merely pass through (unchanged fields) are sent byte-identical or omitted, since deep-equality is what spares them"],"exampleFix":"// before: whole message object echoed back\nawait Meteor.callAsync('updateMessage', { ...messageFromStore, msg: newMsg });\n\n// after: minimal editable patch\nawait Meteor.callAsync('updateMessage', {\n\t_id: messageFromStore._id,\n\trid: messageFromStore.rid,\n\tmsg: newMsg,\n});","handlingStrategy":"type-guard","validationCode":null,"typeGuard":"const ALLOWED_EDIT_FIELDS = [\n\t'tshow',\n\t'alias',\n\t'attachments',\n\t'avatar',\n\t'emoji',\n\t'msg',\n\t'customFields',\n\t'content',\n\t'e2eMentions',\n] as const;\n\ntype EditableMessagePatch = {\n\t_id: string;\n\trid: string;\n} & Partial<Record<(typeof ALLOWED_EDIT_FIELDS)[number], unknown>>;\n\nconst toEditablePatch = (message: Record<string, unknown>): EditableMessagePatch =>\n\tObject.fromEntries(\n\t\tObject.entries(message).filter(\n\t\t\t([key]) => key === '_id' || key === 'rid' || (ALLOWED_EDIT_FIELDS as readonly string[]).includes(key),\n\t\t),\n\t) as EditableMessagePatch;\n\n// type guard\nconst isEditablePatch = (m: Record<string, unknown>): m is EditableMessagePatch =>\n\tObject.keys(m).every(\n\t\t(k) => k === '_id' || k === 'rid' || (ALLOWED_EDIT_FIELDS as readonly string[]).includes(k),\n\t);","tryCatchPattern":"try {\n\tawait Meteor.callAsync('updateMessage', patch);\n} catch (e: any) {\n\tif (e?.error === 'error-invalid-update-key') {\n\t\t// e.reason names the offending key: strip it and resend the reduced patch\n\t\tconst badKey = /message (.+)$/.exec(e.reason ?? '')?.[1];\n\t\tif (badKey) delete patch[badKey];\n\t}\n\tthrow e;\n}","preventionTips":["Send the minimal edit patch { _id, rid, msg } instead of echoing the full message object","Keep an allowlist of editable fields in client code and mirror it from the server constant","Do not client-parse dates or reassign objects on pass-through fields; any value drift counts as an edit attempt","Use dedicated endpoints for pinning, reactions and moderation rather than updateMessage"],"tags":["meteor-method","messages","editing","validation","allowlist"],"backgroundTag":"forbidden-field-update","analyzedSha":"b2c16d5842cbe6b69b59bdf6fc5e5f1afcd1f0b0","analyzedAt":"2026-08-18T15:26:39.429Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}