diff --git a/app/file-upload/server/lib/FileUpload.js b/app/file-upload/server/lib/FileUpload.js index ee59c0b65a795..4654c9f197e80 100644 --- a/app/file-upload/server/lib/FileUpload.js +++ b/app/file-upload/server/lib/FileUpload.js @@ -23,6 +23,7 @@ import { roomTypes } from '../../../utils/server/lib/roomTypes'; import { hasPermission } from '../../../authorization/server/functions/hasPermission'; import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom'; import { fileUploadIsValidContentType } from '../../../utils/lib/fileUploadRestrictions'; +import { isValidJWT, generateJWT } from '../../../utils/server/lib/JWTHelper'; const cookie = new Cookies(); let maxFileSize = 0; @@ -294,6 +295,7 @@ export const FileUpload = { } let { rc_uid, rc_token, rc_rid, rc_room_type } = query; + const { token } = query; if (!rc_uid && headers.cookie) { rc_uid = cookie.get('rc_uid', headers.cookie); @@ -305,7 +307,8 @@ export const FileUpload = { const isAuthorizedByCookies = rc_uid && rc_token && Users.findOneByIdAndLoginToken(rc_uid, rc_token); const isAuthorizedByHeaders = headers['x-user-id'] && headers['x-auth-token'] && Users.findOneByIdAndLoginToken(headers['x-user-id'], headers['x-auth-token']); const isAuthorizedByRoom = rc_room_type && roomTypes.getConfig(rc_room_type).canAccessUploadedFile({ rc_uid, rc_rid, rc_token }); - return isAuthorizedByCookies || isAuthorizedByHeaders || isAuthorizedByRoom; + const isAuthorizedByJWT = !settings.get('FileUpload_Enable_json_web_token_for_files') || (token && isValidJWT(token, settings.get('FileUpload_json_web_token_secret_for_files'))); + return isAuthorizedByCookies || isAuthorizedByHeaders || isAuthorizedByRoom || isAuthorizedByJWT; }, addExtensionTo(file) { if (mime.lookup(file.name) === file.type) { @@ -389,6 +392,17 @@ export const FileUpload = { request.get(fileUrl, (fileRes) => fileRes.pipe(res)); }, + + generateJWTToFileUrls({ rid, userId, fileId }) { + if (!settings.get('FileUpload_ProtectFiles') || !settings.get('FileUpload_Enable_json_web_token_for_files')) { + return; + } + return generateJWT({ + rid, + userId, + fileId, + }, settings.get('FileUpload_json_web_token_secret_for_files')); + }, }; export class FileUploadClass { diff --git a/app/file-upload/server/startup/settings.js b/app/file-upload/server/startup/settings.js index 0421843ff6909..02bea02fce4f7 100644 --- a/app/file-upload/server/startup/settings.js +++ b/app/file-upload/server/startup/settings.js @@ -24,6 +24,26 @@ settings.addGroup('FileUpload', function() { i18nDescription: 'FileUpload_ProtectFilesDescription', }); + this.add('FileUpload_Enable_json_web_token_for_files', true, { + type: 'boolean', + i18nLabel: 'FileUpload_Enable_json_web_token_for_files', + i18nDescription: 'FileUpload_Enable_json_web_token_for_files_description', + enableQuery: { + _id: 'FileUpload_ProtectFiles', + value: true, + }, + }); + + this.add('FileUpload_json_web_token_secret_for_files', '', { + type: 'string', + i18nLabel: 'FileUpload_json_web_token_secret_for_files', + i18nDescription: 'FileUpload_json_web_token_secret_for_files_description', + enableQuery: { + _id: 'FileUpload_Enable_json_web_token_for_files', + value: true, + }, + }); + this.add('FileUpload_Storage_Type', 'GridFS', { type: 'select', values: [{ diff --git a/app/livechat/server/api/v1/message.js b/app/livechat/server/api/v1/message.js index d4e2ba3b8a03b..67311c3eba91c 100644 --- a/app/livechat/server/api/v1/message.js +++ b/app/livechat/server/api/v1/message.js @@ -8,6 +8,7 @@ import { API } from '../../../../api'; import { loadMessageHistory } from '../../../../lib'; import { findGuest, findRoom, normalizeHttpHeaderData } from '../lib/livechat'; import { Livechat } from '../../lib/Livechat'; +import { normalizeMessageAttachments } from '../../../../utils/server/functions/normalizeMessageAttachments'; API.v1.addRoute('livechat/message', { post() { @@ -90,14 +91,18 @@ API.v1.addRoute('livechat/message/:_id', { throw new Meteor.Error('invalid-room'); } - const message = Messages.findOneById(_id); + let message = Messages.findOneById(_id); if (!message) { throw new Meteor.Error('invalid-message'); } + if (message.file) { + message = normalizeMessageAttachments(message); + } + return API.v1.success({ message }); } catch (e) { - return API.v1.failure(e.error); + return API.v1.failure(e); } }, @@ -133,13 +138,17 @@ API.v1.addRoute('livechat/message/:_id', { const result = Livechat.updateMessage({ guest, message: { _id: msg._id, msg: this.bodyParams.msg } }); if (result) { - const message = Messages.findOneById(_id); + let message = Messages.findOneById(_id); + if (message.file) { + message = normalizeMessageAttachments(message); + } + return API.v1.success({ message }); } return API.v1.failure(); } catch (e) { - return API.v1.failure(e.error); + return API.v1.failure(e); } }, delete() { @@ -183,7 +192,7 @@ API.v1.addRoute('livechat/message/:_id', { return API.v1.failure(); } catch (e) { - return API.v1.failure(e.error); + return API.v1.failure(e); } }, }); @@ -227,10 +236,12 @@ API.v1.addRoute('livechat/messages.history/:rid', { limit = parseInt(this.queryParams.limit); } - const messages = loadMessageHistory({ userId: guest._id, rid, end, limit, ls }); - return API.v1.success(messages); + const messages = loadMessageHistory({ userId: guest._id, rid, end, limit, ls }) + .messages + .map(normalizeMessageAttachments); + return API.v1.success({ messages }); } catch (e) { - return API.v1.failure(e.error); + return API.v1.failure(e); } }, }); diff --git a/app/livechat/server/hooks/externalMessage.js b/app/livechat/server/hooks/externalMessage.js index 2ef76fbb88f1f..facbdbbf513ae 100644 --- a/app/livechat/server/hooks/externalMessage.js +++ b/app/livechat/server/hooks/externalMessage.js @@ -5,6 +5,7 @@ import { settings } from '../../../settings'; import { callbacks } from '../../../callbacks'; import { SystemLogger } from '../../../logger'; import { LivechatExternalMessage } from '../../lib/LivechatExternalMessage'; +import { normalizeMessageAttachments } from '../../../utils/server/functions/normalizeMessageAttachments'; let knowledgeEnabled = false; let apiaiKey = ''; @@ -33,6 +34,10 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } + if (message.file) { + message = normalizeMessageAttachments(message); + } + // if the message hasn't a token, it was not sent by the visitor, so ignore it if (!message.token) { return message; diff --git a/app/livechat/server/hooks/saveAnalyticsData.js b/app/livechat/server/hooks/saveAnalyticsData.js index cfd5215e81626..7849e4c73da99 100644 --- a/app/livechat/server/hooks/saveAnalyticsData.js +++ b/app/livechat/server/hooks/saveAnalyticsData.js @@ -1,5 +1,6 @@ import { callbacks } from '../../../callbacks'; import { LivechatRooms } from '../../../models'; +import { normalizeMessageAttachments } from '../../../utils/server/functions/normalizeMessageAttachments'; callbacks.add('afterSaveMessage', function(message, room) { // skips this callback if the message was edited @@ -12,6 +13,9 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } + if (message.file) { + message = normalizeMessageAttachments(message); + } const now = new Date(); let analyticsData; diff --git a/app/livechat/server/hooks/sendToCRM.js b/app/livechat/server/hooks/sendToCRM.js index acbf777826a8a..827b2f9952dbb 100644 --- a/app/livechat/server/hooks/sendToCRM.js +++ b/app/livechat/server/hooks/sendToCRM.js @@ -2,6 +2,7 @@ import { settings } from '../../../settings'; import { callbacks } from '../../../callbacks'; import { Messages, LivechatRooms } from '../../../models'; import { Livechat } from '../lib/Livechat'; +import { normalizeMessageAttachments } from '../../../utils/server/functions/normalizeMessageAttachments'; const msgNavType = 'livechat_navigation_history'; @@ -55,7 +56,12 @@ function sendToCRM(type, room, includeMessages = true) { msg.navigation = message.navigation; } - postData.messages.push(msg); + if (message.file) { + msg.file = message.file; + msg.attachments = message.attachments; + } + + postData.messages.push(normalizeMessageAttachments(msg)); }); } diff --git a/app/livechat/server/hooks/sendToFacebook.js b/app/livechat/server/hooks/sendToFacebook.js index 30cafd9859a15..e479b7e2ff240 100644 --- a/app/livechat/server/hooks/sendToFacebook.js +++ b/app/livechat/server/hooks/sendToFacebook.js @@ -1,6 +1,7 @@ import { callbacks } from '../../../callbacks'; import { settings } from '../../../settings'; import OmniChannel from '../lib/OmniChannel'; +import { normalizeMessageAttachments } from '../../../utils/server/functions/normalizeMessageAttachments'; callbacks.add('afterSaveMessage', function(message, room) { // skips this callback if the message was edited @@ -27,6 +28,10 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } + if (message.file) { + message = normalizeMessageAttachments(message); + } + OmniChannel.reply({ page: room.facebook.page.id, token: room.v.token, diff --git a/app/livechat/server/methods/sendMessageLivechat.js b/app/livechat/server/methods/sendMessageLivechat.js index 47c2dad856cdd..b294d00f22c04 100644 --- a/app/livechat/server/methods/sendMessageLivechat.js +++ b/app/livechat/server/methods/sendMessageLivechat.js @@ -5,7 +5,7 @@ import { LivechatVisitors } from '../../../models'; import { Livechat } from '../lib/Livechat'; Meteor.methods({ - sendMessageLivechat({ token, _id, rid, msg, attachments }, agent) { + sendMessageLivechat({ token, _id, rid, msg, file, attachments }, agent) { check(token, String); check(_id, String); check(rid, String); @@ -36,6 +36,7 @@ Meteor.methods({ rid, msg, token, + file, attachments, }, agent, diff --git a/app/livechat/server/sendMessageBySMS.js b/app/livechat/server/sendMessageBySMS.js index 1715cd6e13309..ec1704ee6acad 100644 --- a/app/livechat/server/sendMessageBySMS.js +++ b/app/livechat/server/sendMessageBySMS.js @@ -2,6 +2,7 @@ import { callbacks } from '../../callbacks'; import { settings } from '../../settings'; import { SMS } from '../../sms'; import { LivechatVisitors } from '../../models'; +import { normalizeMessageAttachments } from '../../utils/server/functions/normalizeMessageAttachments'; callbacks.add('afterSaveMessage', function(message, room) { // skips this callback if the message was edited @@ -28,6 +29,11 @@ callbacks.add('afterSaveMessage', function(message, room) { return message; } + + if (message.file) { + message = normalizeMessageAttachments(message); + } + const SMSService = SMS.getService(settings.get('SMS_Service')); if (!SMSService) { diff --git a/app/utils/server/functions/normalizeMessageAttachments.js b/app/utils/server/functions/normalizeMessageAttachments.js new file mode 100644 index 0000000000000..20b4332f0c6fd --- /dev/null +++ b/app/utils/server/functions/normalizeMessageAttachments.js @@ -0,0 +1,18 @@ +import { FileUpload } from '../../../file-upload/server'; + +export const normalizeMessageAttachments = (message) => { + if (message.file && message.attachments && Array.isArray(message.attachments) && message.attachments.length) { + const jwt = FileUpload.generateJWTToFileUrls({ rid: message.rid, userId: message.u._id, fileId: message.file._id }); + if (jwt) { + message.attachments.forEach((attachment) => { + if (attachment.title_link) { + attachment.title_link = `${ attachment.title_link }?token=${ jwt }`; + } + if (attachment.image_url) { + attachment.image_url = `${ attachment.image_url }?token=${ jwt }`; + } + }); + } + } + return message; +}; diff --git a/app/utils/server/lib/JWTHelper.js b/app/utils/server/lib/JWTHelper.js new file mode 100644 index 0000000000000..a860a0fa1e669 --- /dev/null +++ b/app/utils/server/lib/JWTHelper.js @@ -0,0 +1,28 @@ +import { jws } from 'jsrsasign'; + +const HEADER = { + typ: 'JWT', + alg: 'HS256', +}; + +export const generateJWT = (payload, secret) => { + const tokenPayload = { + iat: jws.IntDate.get('now'), + nbf: jws.IntDate.get('now'), + exp: jws.IntDate.get('now + 1hour'), + aud: 'RocketChat', + context: payload, + }; + + const header = JSON.stringify(HEADER); + + return jws.JWS.sign(HEADER.alg, header, JSON.stringify(tokenPayload), { rstr: secret }); +}; + +export const isValidJWT = (jwt, secret) => { + try { + return jws.JWS.verify(jwt, secret, HEADER); + } catch (error) { + return false; + } +}; diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 8598413ddc1ad..c871305be09a1 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1392,6 +1392,10 @@ "FileUpload_Enabled": "File Uploads Enabled", "FileUpload_Error": "File Upload Error", "FileUpload_Enabled_Direct": "File Uploads Enabled in Direct Messages ", + "FileUpload_Enable_json_web_token_for_files": "Enable Json Web Tokens protection to file uploads", + "FileUpload_Enable_json_web_token_for_files_description": "Appends a JWT to uploaded files urls", + "FileUpload_json_web_token_secret_for_files": "File Upload Json Web Token Secret", + "FileUpload_json_web_token_secret_for_files_description": "File Upload Json Web Token Secret (Used to be able to access uploaded files without authentication)", "FileUpload_File_Empty": "File empty", "FileUpload_FileSystemPath": "System Path", "FileUpload_GoogleStorage_AccessId": "Google Storage Access Id", diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index 8a815d6232d59..1ca622dcd5d88 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -154,4 +154,5 @@ import './v153'; import './v154'; import './v155'; import './v156'; +import './v157'; import './xrun'; diff --git a/server/startup/migrations/v157.js b/server/startup/migrations/v157.js new file mode 100644 index 0000000000000..f3490c6cd1414 --- /dev/null +++ b/server/startup/migrations/v157.js @@ -0,0 +1,44 @@ +import { Random } from 'meteor/random'; + +import { Migrations } from '../../../app/migrations/server'; +import { Settings } from '../../../app/models/server'; +import { settings } from '../../../app/settings/server'; + +Migrations.add({ + version: 157, + up() { + Settings.upsert({ + _id: 'FileUpload_Enable_json_web_token_for_files', + }, + { + _id: 'FileUpload_Enable_json_web_token_for_files', + value: settings.get('FileUpload_ProtectFiles'), + type: 'boolean', + group: 'FileUpload', + i18nLabel: 'FileUpload_Enable_json_web_token_for_files', + i18nDescription: 'FileUpload_Enable_json_web_token_for_files_description', + enableQuery: { + _id: 'FileUpload_ProtectFiles', + value: true, + }, + }); + Settings.upsert({ + _id: 'FileUpload_json_web_token_secret_for_files', + }, + { + _id: 'FileUpload_json_web_token_secret_for_files', + value: Random.secret(), + type: 'string', + group: 'FileUpload', + i18nLabel: 'FileUpload_json_web_token_secret_for_files', + i18nDescription: 'FileUpload_json_web_token_secret_for_files_description', + enableQuery: { + _id: 'FileUpload_Enable_json_web_token_for_files', + value: true, + }, + }); + }, + down() { + // Down migration does not apply in this case + }, +});