diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index d65e73a887d63..f31039fbb47f7 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -14,6 +14,165 @@ import { RocketChatFile } from '../../../file/server'; import { FileUpload } from '../../../file-upload/server'; import { settings } from '../../../settings/server'; +const DEFAULT_AVATAR_DOWNLOAD_TIMEOUT_MS = 20_000; +const DEFAULT_MAX_FILE_SIZE = 104_857_600; + +const getMediaType = (contentTypeHeader: string | null): string => { + const mediaType = contentTypeHeader?.split(';', 1)[0].trim().toLowerCase(); + + return mediaType || ''; +}; + +const isImageContentType = (contentTypeHeader: string | null): boolean => /^image\/[^;\s]+$/.test(getMediaType(contentTypeHeader)); + +const isRequestTimeoutError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') { + return false; + } + + const { name, code, type } = error as { name?: string; code?: string; type?: string }; + + return name === 'AbortError' || code === 'ETIMEDOUT' || type === 'request-timeout' || type === 'body-timeout'; +}; + +const isResponseTooLargeError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') { + return false; + } + + return (error as { type?: string }).type === 'max-size'; +}; + +const redactUrl = (url: string): string => { + try { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + parsed.searchParams.forEach((_value, key) => { + if (/token|key|secret|password|auth|session|sid/i.test(key)) { + parsed.searchParams.set(key, '[redacted]'); + } + }); + + return parsed.toString(); + } catch { + return url; + } +}; + +const getMaxFileSize = (): number => { + const maxFileSizeSetting = Number(settings.get('FileUpload_MaxFileSize')); + + return Number.isFinite(maxFileSizeSetting) && maxFileSizeSetting > 0 ? maxFileSizeSetting : DEFAULT_MAX_FILE_SIZE; +}; + +const throwInvalidAvatarUrl = (dataURI: string): never => { + throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { + function: 'setUserAvatar', + url: dataURI, + }); +}; + +const throwAvatarDownloadTimeout = (dataURI: string): never => { + throw new Meteor.Error('error-avatar-download-timeout', 'Avatar download timed out', { + function: 'setUserAvatar', + url: dataURI, + timeoutMs: DEFAULT_AVATAR_DOWNLOAD_TIMEOUT_MS, + }); +}; + +const throwAvatarUrlHandling = (dataURI: string, username: string): never => { + throw new Meteor.Error( + 'error-avatar-url-handling', + `Error while handling avatar setting from a URL (${encodeURI(dataURI)}) for ${username}`, + { function: 'RocketChat.setUserAvatar', url: dataURI, username }, + ); +}; + +const getAvatarFromUrl = async ( + user: Pick & { username: string }, + dataURI: string, +): Promise<{ buffer: Buffer; type: string }> => { + const maxFileSize = getMaxFileSize(); + let response!: Response; + + try { + response = await fetch(dataURI, { + ignoreSsrfValidation: false, + allowList: settings.get('SSRF_Allowlist'), + size: maxFileSize, + timeout: DEFAULT_AVATAR_DOWNLOAD_TIMEOUT_MS, + }); + } catch (error) { + if (isRequestTimeoutError(error)) { + throwAvatarDownloadTimeout(dataURI); + } + + SystemLogger.info({ + msg: 'Not a valid response from the avatar url', + url: redactUrl(dataURI), + err: error, + }); + throwInvalidAvatarUrl(dataURI); + } + + if (response.status !== 200) { + if (response.status !== 404) { + SystemLogger.info({ + msg: 'Error while handling the setting of the avatar from a url', + url: redactUrl(dataURI), + username: user.username, + status: response.status, + }); + throwAvatarUrlHandling(dataURI, user.username); + } + + SystemLogger.info({ + msg: 'Not a valid response from the avatar url', + status: response.status, + url: redactUrl(dataURI), + }); + throwInvalidAvatarUrl(dataURI); + } + + const type = response.headers.get('content-type') || ''; + if (!isImageContentType(type)) { + SystemLogger.info({ + msg: 'Not a valid content-type from the provided avatar url', + contentType: type, + url: redactUrl(dataURI), + }); + throwInvalidAvatarUrl(dataURI); + } + + try { + return { + buffer: Buffer.from(await response.arrayBuffer()), + type, + }; + } catch (error) { + if (isResponseTooLargeError(error)) { + throw new Meteor.Error('error-file-too-large', 'Avatar file exceeds allowed size limit', { + function: 'setUserAvatar', + url: dataURI, + sizeLimit: maxFileSize, + }); + } + + if (isRequestTimeoutError(error)) { + throwAvatarDownloadTimeout(dataURI); + } + + SystemLogger.info({ + msg: 'Error while downloading avatar from provided url', + url: redactUrl(dataURI), + username: user.username, + err: error, + }); + return throwAvatarUrlHandling(dataURI, user.username); + } +}; + export const setAvatarFromServiceWithValidation = async ( userId: string, dataURI: string, @@ -100,67 +259,13 @@ export async function setUserAvatar( const { buffer, type } = await (async (): Promise<{ buffer: Buffer; type: string }> => { if (service === 'url' && typeof dataURI === 'string') { - let response: Response; - - try { - response = await fetch(dataURI, { - ignoreSsrfValidation: false, - allowList: settings.get('SSRF_Allowlist'), - }); - } catch (e) { - SystemLogger.info({ - msg: 'Not a valid response from the avatar url', - url: encodeURI(dataURI), - err: e, - }); - throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${encodeURI(dataURI)}`, { - function: 'setUserAvatar', - url: dataURI, - }); - } - - if (response.status !== 200) { - if (response.status !== 404) { - SystemLogger.info({ - msg: 'Error while handling the setting of the avatar from a url', - url: encodeURI(dataURI), - username: user.username, - status: response.status, - }); - throw new Meteor.Error( - 'error-avatar-url-handling', - `Error while handling avatar setting from a URL (${encodeURI(dataURI)}) for ${user.username}`, - { function: 'RocketChat.setUserAvatar', url: dataURI, username: user.username }, - ); - } - - SystemLogger.info({ - msg: 'Not a valid response from the avatar url', - status: response.status, - url: dataURI, - }); - throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, { + if (!user.username) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { function: 'setUserAvatar', - url: dataURI, }); } - if (!/image\/.+/.test(response.headers.get('content-type') || '')) { - SystemLogger.info({ - msg: 'Not a valid content-type from the provided avatar url', - contentType: response.headers.get('content-type'), - url: dataURI, - }); - throw new Meteor.Error('error-avatar-invalid-url', `Invalid avatar URL: ${dataURI}`, { - function: 'setUserAvatar', - url: dataURI, - }); - } - - return { - buffer: Buffer.from(await response.arrayBuffer()), - type: response.headers.get('content-type') || '', - }; + return getAvatarFromUrl({ username: user.username }, dataURI); } if (service === 'rest') { @@ -185,7 +290,9 @@ export async function setUserAvatar( })(); const fileStore = FileUpload.getStore('Avatars'); - user.username && (await fileStore.deleteByName(user.username, { session })); + if (user.username) { + await fileStore.deleteByName(user.username, { session }); + } const file = { userId: user._id, diff --git a/apps/meteor/tests/unit/app/lib/server/functions/setUserAvatar.spec.ts b/apps/meteor/tests/unit/app/lib/server/functions/setUserAvatar.spec.ts new file mode 100644 index 0000000000000..92dbfc6565b9c --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/functions/setUserAvatar.spec.ts @@ -0,0 +1,248 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +class MeteorError extends Error { + constructor( + public error: string, + public reason?: string, + public details?: Record, + ) { + super(error); + } +} + +const createResponse = ({ + status = 200, + headers = {}, + arrayBuffer, +}: { + status?: number; + headers?: Record; + arrayBuffer?: sinon.SinonStub; +}) => ({ + status, + headers: { + get: (key: string) => headers[key.toLowerCase()] || null, + }, + arrayBuffer: arrayBuffer ?? sinon.stub().resolves(Buffer.from('1234')), +}); + +describe('setUserAvatar', () => { + const user = { _id: 'user-id', username: 'tester' }; + const fileStore = { + deleteByName: sinon.stub().resolves(), + insert: sinon.stub().resolves({ etag: 'etag' }), + }; + + const stubs = { + Users: { + setAvatarData: sinon.stub().resolves(), + findOneById: sinon.stub(), + }, + fetch: sinon.stub(), + settings: { + get: sinon.stub(), + }, + SystemLogger: { + info: sinon.stub(), + }, + FileUpload: { + getStore: sinon.stub().returns(fileStore), + }, + RocketChatFile: { + dataURIParse: sinon.stub(), + }, + api: { + broadcast: sinon.stub(), + }, + hasPermissionAsync: sinon.stub(), + }; + + const { setUserAvatar } = proxyquire.noCallThru().load('../../../../../../app/lib/server/functions/setUserAvatar', { + '@rocket.chat/core-services': { api: stubs.api }, + '@rocket.chat/models': { Users: stubs.Users }, + '@rocket.chat/server-fetch': { serverFetch: stubs.fetch }, + 'meteor/meteor': { Meteor: { Error: MeteorError } }, + '../../../../server/database/utils': { onceTransactionCommitedSuccessfully: async (cb: any, _sess: any) => cb() }, + '../../../../server/lib/logger/system': { SystemLogger: stubs.SystemLogger }, + '../../../authorization/server/functions/hasPermission': { hasPermissionAsync: stubs.hasPermissionAsync }, + '../../../file/server': { RocketChatFile: stubs.RocketChatFile }, + '../../../file-upload/server': { FileUpload: stubs.FileUpload }, + '../../../settings/server': { settings: stubs.settings }, + }); + + beforeEach(() => { + stubs.settings.get.callsFake((key: string) => { + if (key === 'SSRF_Allowlist') { + return '*'; + } + + if (key === 'FileUpload_MaxFileSize') { + return 4; + } + + return undefined; + }); + }); + + afterEach(() => { + sinon.restore(); + fileStore.deleteByName.resetHistory(); + fileStore.insert.resetHistory(); + stubs.Users.setAvatarData.resetHistory(); + stubs.fetch.resetHistory(); + stubs.settings.get.resetHistory(); + stubs.SystemLogger.info.resetHistory(); + stubs.api.broadcast.resetHistory(); + }); + + it('rejects avatar url when content-length exceeds max size', async () => { + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'image/png', + }, + arrayBuffer: sinon.stub().rejects(Object.assign(new Error('too large'), { type: 'max-size' })), + }), + ); + + await expect(setUserAvatar(user, 'https://example.com/avatar.png', '', 'url')).to.be.rejectedWith('error-file-too-large'); + expect(fileStore.insert.called).to.be.false; + }); + + it('rejects avatar url when response body exceeds max size', async () => { + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'image/png', + }, + arrayBuffer: sinon.stub().rejects(Object.assign(new Error('too large'), { type: 'max-size' })), + }), + ); + + await expect(setUserAvatar(user, 'https://example.com/avatar.png', '', 'url')).to.be.rejectedWith('error-file-too-large'); + expect(fileStore.insert.called).to.be.false; + }); + + it('rejects avatar url when response exceeds lied content-length', async () => { + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'image/png', + 'content-length': '2', + }, + arrayBuffer: sinon.stub().rejects(Object.assign(new Error('too large'), { type: 'max-size' })), + }), + ); + + await expect(setUserAvatar(user, 'https://example.com/avatar.png', '', 'url')).to.be.rejectedWith('error-file-too-large'); + expect(fileStore.insert.called).to.be.false; + }); + + it('rejects non-image avatar url content type', async () => { + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'text/plain', + }, + }), + ); + + await expect(setUserAvatar(user, 'https://example.com/avatar.txt', '', 'url')).to.be.rejectedWith('error-avatar-invalid-url'); + expect(fileStore.insert.called).to.be.false; + }); + + it('rejects content types that only contain image slash in parameters', async () => { + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'text/plain; note=image/png', + }, + }), + ); + + await expect(setUserAvatar(user, 'https://example.com/avatar.txt', '', 'url')).to.be.rejectedWith('error-avatar-invalid-url'); + expect(fileStore.insert.called).to.be.false; + }); + + it('stores avatar when streamed image stays within limit', async () => { + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'image/png', + }, + arrayBuffer: sinon.stub().resolves(Buffer.from('1234')), + }), + ); + + await setUserAvatar(user, 'https://example.com/avatar.png', '', 'url'); + + expect(fileStore.insert.calledOnce).to.be.true; + expect(fileStore.insert.firstCall.args[0]).to.deep.include({ + userId: user._id, + type: 'image/png', + size: 4, + }); + expect(fileStore.insert.firstCall.args[1].equals(Buffer.from('1234'))).to.be.true; + expect(stubs.fetch.firstCall.args[1]).to.deep.include({ + size: 4, + timeout: 20_000, + }); + }); + + it('falls back to default max file size when setting is negative', async () => { + stubs.settings.get.callsFake((key: string) => { + if (key === 'SSRF_Allowlist') { + return '*'; + } + + if (key === 'FileUpload_MaxFileSize') { + return -1; + } + + return undefined; + }); + + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'image/png', + }, + arrayBuffer: sinon.stub().resolves(Buffer.from('1234567890')), + }), + ); + + await setUserAvatar(user, 'https://example.com/avatar.png', '', 'url'); + + expect(fileStore.insert.calledOnce).to.be.true; + expect(fileStore.insert.firstCall.args[0]).to.deep.include({ + userId: user._id, + type: 'image/png', + size: 10, + }); + }); + + it('rejects avatar url when body read times out', async () => { + stubs.fetch.resolves( + createResponse({ + headers: { + 'content-type': 'image/png', + }, + arrayBuffer: sinon.stub().rejects(Object.assign(new Error('Response timeout'), { type: 'body-timeout' })), + }), + ); + + await expect(setUserAvatar(user, 'https://example.com/avatar.png', '', 'url')).to.be.rejectedWith('error-avatar-download-timeout'); + expect(fileStore.insert.called).to.be.false; + }); + + it('maps fetch abort errors to avatar download timeout', async () => { + const abortError = Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }); + + stubs.fetch.rejects(abortError); + + await expect(setUserAvatar(user, 'https://example.com/avatar.png', '', 'url')).to.be.rejectedWith('error-avatar-download-timeout'); + expect(fileStore.insert.called).to.be.false; + }); +});