diff --git a/.changeset/bump-patch-1781187995605.md b/.changeset/bump-patch-1781187995605.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1781187995605.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/fast-apes-know.md b/.changeset/fast-apes-know.md new file mode 100644 index 0000000000000..47242a936a4c2 --- /dev/null +++ b/.changeset/fast-apes-know.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Escapes HTML tags in exported data diff --git a/.changeset/rich-bananas-shine.md b/.changeset/rich-bananas-shine.md new file mode 100644 index 0000000000000..eacb88108a0f7 --- /dev/null +++ b/.changeset/rich-bananas-shine.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/salty-suits-strive.md b/.changeset/salty-suits-strive.md new file mode 100644 index 0000000000000..e9506dc744605 --- /dev/null +++ b/.changeset/salty-suits-strive.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes missing permission check on the `POST /api/v1/fingerprint` endpoint diff --git a/.changeset/slick-hats-arrive.md b/.changeset/slick-hats-arrive.md new file mode 100644 index 0000000000000..4ce84a4a831ee --- /dev/null +++ b/.changeset/slick-hats-arrive.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/federation-matrix': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/meteor': patch +--- + +Fixes an issue where `description` was incorrectly being used as alternative text for image attachments diff --git a/.github/actions/update-version-durability/package-lock.json b/.github/actions/update-version-durability/package-lock.json index cd65c8d5e37b2..ac82e53d36018 100644 --- a/.github/actions/update-version-durability/package-lock.json +++ b/.github/actions/update-version-durability/package-lock.json @@ -12,7 +12,7 @@ "@actions/core": "^1.10.1", "@octokit/rest": "^21.0.0", "@xmldom/xmldom": "^0.8.13", - "axios": "^1.7.2", + "axios": "^1.16.0", "beauty-html": "^1.3.1", "colors": "^1.4.0", "diff": "^5.1.0", @@ -202,6 +202,18 @@ "node": ">=10.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -209,13 +221,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -262,6 +275,23 @@ "node": ">= 0.8" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -488,6 +518,19 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -518,6 +561,12 @@ "node": ">= 0.6" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", diff --git a/.github/actions/update-version-durability/package.json b/.github/actions/update-version-durability/package.json index ba7dee8a6c92d..b7483014396c9 100644 --- a/.github/actions/update-version-durability/package.json +++ b/.github/actions/update-version-durability/package.json @@ -11,7 +11,7 @@ "dependencies": { "@actions/core": "^1.10.1", "@octokit/rest": "^21.0.0", - "axios": "^1.7.2", + "axios": "^1.16.0", "beauty-html": "^1.3.1", "colors": "^1.4.0", "diff": "^5.1.0", diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index cf7a9a2d3d1bf..d7b0be701a3f5 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -11,6 +11,7 @@ import { isMeteorCall, meSuccessResponseSchema, validateUnauthorizedErrorResponse, + validateForbiddenErrorResponse, validateBadRequestErrorResponse, } from '@rocket.chat/rest-typings'; import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings'; @@ -795,10 +796,12 @@ API.v1.post( 'fingerprint', { authRequired: true, + permissionsRequired: ['manage-cloud'], body: isFingerprintProps, response: { 200: fingerprintResponseSchema, 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, 400: validateBadRequestErrorResponse, }, }, diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.spec.ts b/apps/meteor/app/apple/lib/handleIdentityToken.spec.ts new file mode 100644 index 0000000000000..b63c3f9da6e4e --- /dev/null +++ b/apps/meteor/app/apple/lib/handleIdentityToken.spec.ts @@ -0,0 +1,133 @@ +import { generateKeyPairSync, sign } from 'node:crypto'; + +import { serverFetch } from '@rocket.chat/server-fetch'; +import { Response } from 'node-fetch'; + +import { handleIdentityToken } from './handleIdentityToken'; + +jest.mock('@rocket.chat/server-fetch', () => ({ + serverFetch: jest.fn(), +})); + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, +}); + +const jwkPublicKey = publicKey.export({ format: 'jwk' }); + +const toBase64Url = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + +describe('handleIdentityToken', () => { + const mockClientId = 'com.yourcompany.app'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should throw an error if the token has the wrong audience', async () => { + const header = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payload = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: 'wrong.client.id', + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const mockToken = `${header}.${payload}.dummySignature`; + + await expect(handleIdentityToken(mockToken, mockClientId)).rejects.toThrow('identityToken is not a valid Apple JWT or has expired'); + }); + + it('should successfully validate a valid token', async () => { + const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payloadB64 = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: mockClientId, + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey); + const signatureB64 = signatureBytes.toString('base64url'); + + const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`; + + if (!jwkPublicKey.n || !jwkPublicKey.e) { + throw new Error('Generated test key is missing modulus or exponent'); + } + + const mockJwksPayload = { + keys: [ + { + kty: 'RSA', + kid: 'mock-key-id', + use: 'sig', + alg: 'RS256', + n: jwkPublicKey.n, + e: jwkPublicKey.e, + }, + ], + }; + + jest.mocked(serverFetch).mockResolvedValue( + new Response(JSON.stringify(mockJwksPayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await handleIdentityToken(validMockToken, mockClientId); + + expect(result.id).toBe('user123'); + expect(result.iss).toBe('https://appleid.apple.com'); + }); + + it('should accept default mobile audience when client id setting is empty', async () => { + const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' }); + const payloadB64 = toBase64Url({ + iss: 'https://appleid.apple.com', + aud: 'chat.rocket.ios', + exp: Math.floor(Date.now() / 1000) + 3600, + sub: 'user123', + }); + + const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey); + const signatureB64 = signatureBytes.toString('base64url'); + + const validMockToken = `${headerB64}.${payloadB64}.${signatureB64}`; + + if (!jwkPublicKey.n || !jwkPublicKey.e) { + throw new Error('Generated test key is missing modulus or exponent'); + } + + const mockJwksPayload = { + keys: [ + { + kty: 'RSA', + kid: 'mock-key-id', + use: 'sig', + alg: 'RS256', + n: jwkPublicKey.n, + e: jwkPublicKey.e, + }, + ], + }; + + jest.mocked(serverFetch).mockResolvedValue( + new Response(JSON.stringify(mockJwksPayload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await handleIdentityToken(validMockToken, ''); + + expect(result.id).toBe('user123'); + expect(result.aud).toBe('chat.rocket.ios'); + }); +}); diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.ts b/apps/meteor/app/apple/lib/handleIdentityToken.ts index f3ab1c8f9e66f..ab4a9378b906d 100644 --- a/apps/meteor/app/apple/lib/handleIdentityToken.ts +++ b/apps/meteor/app/apple/lib/handleIdentityToken.ts @@ -1,52 +1,165 @@ +import { createPublicKey, verify } from 'node:crypto'; + import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import { KJUR } from 'jsrsasign'; -import NodeRSA from 'node-rsa'; -async function isValidAppleJWT(identityToken: string, header: any): Promise { - const request = await fetch('https://appleid.apple.com/auth/keys', { - method: 'GET', - // SECURITY: Hardcoded URL, no SSRF protection needed - ignoreSsrfValidation: true, - }); - const applePublicKeys = ((await request.json()) as { keys: { kid: string; e: string; n: string }[] }).keys; - const { kid } = header; +type AppleJWK = { + kty: string; + kid: string; + use: string; + alg: string; + n: string; + e: string; +}; + +type AppleJWTPayload = { + iss: string; + sub: string; + aud: string | string[]; + exp: number; + iat: number; + email?: string; + email_verified?: string | boolean; + is_private_email?: string | boolean; +}; + +const DEFAULT_APPLE_AUDIENCES = ['chat.rocket.ios']; + +let cachedKeys: AppleJWK[] | null = null; +let lastFetchTime = 0; +const CACHE_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours + +async function getApplePublicKeys(forceRefresh = false): Promise { + const now = Date.now(); - const key = applePublicKeys.find((k: any) => k.kid === kid); - if (!key) { - return false; + if (!forceRefresh && cachedKeys && now - lastFetchTime < CACHE_TTL_MS) { + return cachedKeys; } - const pubKey = new NodeRSA(); - pubKey.importKey({ n: Buffer.from(key.n, 'base64'), e: Buffer.from(key.e, 'base64') }, 'components-public'); - const userKey = pubKey.exportKey('public'); + try { + const response = await fetch('https://appleid.apple.com/auth/keys', { + method: 'GET', + // SECURITY: Hardcoded URL, no SSRF protection needed + ignoreSsrfValidation: true, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch Apple keys: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { keys: AppleJWK[] }; + cachedKeys = data.keys; + lastFetchTime = now; + + return cachedKeys; + } catch (error) { + if (cachedKeys) { + console.warn('Failed to refresh Apple public keys, using stale cache', error); + return cachedKeys; + } + throw new Error('Could not retrieve Apple public keys', { cause: error }); + } +} + +function decodeBase64Url(str: string): string { + return Buffer.from(str, 'base64url').toString('utf8'); +} + +async function verifyAppleJWT( + headerB64: string, + payloadB64: string, + signatureB64: string, + clientId: string, +): Promise { + const header = JSON.parse(decodeBase64Url(headerB64)); + const payload = JSON.parse(decodeBase64Url(payloadB64)) as AppleJWTPayload; + + const nowInSeconds = Math.floor(Date.now() / 1000); + + if (payload.exp < nowInSeconds) { + console.error('Apple JWT has expired'); + return null; + } + + if (payload.iss !== 'https://appleid.apple.com') { + console.error('Invalid issuer. Expected https://appleid.apple.com'); + return null; + } + + const audArray = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + + const configuredAudiences = clientId + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + + const allowedAudiences = Array.from(new Set([...DEFAULT_APPLE_AUDIENCES, ...configuredAudiences])); + + const isAudienceValid = allowedAudiences.some((allowedAud) => audArray.includes(allowedAud)); + + if (!isAudienceValid) { + console.error(`Invalid audience. Expected one of: ${allowedAudiences.join(', ')}`); + return null; + } + + let applePublicKeys = await getApplePublicKeys(); + let keyData = applePublicKeys.find((k) => k.kid === header.kid); + + if (!keyData) { + applePublicKeys = await getApplePublicKeys(true); // Force refresh + keyData = applePublicKeys.find((k) => k.kid === header.kid); + + if (!keyData) { + console.error('Matching Key ID (kid) not found in Apple JWKS'); + return null; + } + } try { - return KJUR.jws.JWS.verify(identityToken, userKey, ['RS256']); - } catch { - return false; + const publicKey = createPublicKey({ + key: { + kty: keyData.kty, + n: keyData.n, + e: keyData.e, + }, + format: 'jwk', + }); + + const isSignatureValid = verify( + 'RSA-SHA256', + Buffer.from(`${headerB64}.${payloadB64}`), + publicKey, + Buffer.from(signatureB64, 'base64url'), + ); + + return isSignatureValid ? payload : null; + } catch (error) { + console.error('Cryptographic signature verification failed:', error); + return null; } } -export async function handleIdentityToken(identityToken: string): Promise<{ id: string; email: string; name: string }> { - const decodedToken = KJUR.jws.JWS.parse(identityToken); +export async function handleIdentityToken(identityToken: string, clientId: string): Promise> { + const parts = identityToken.split('.'); - if (!(await isValidAppleJWT(identityToken, decodedToken.headerObj))) { - throw new Error('identityToken is not a valid JWT'); + if (parts.length !== 3) { + throw new Error('Malformed identityToken: JWT must have 3 parts'); } - if (!decodedToken.payloadObj) { - throw new Error('identityToken does not have a payload'); + const [headerB64, payloadB64, signatureB64] = parts; + + const payload = await verifyAppleJWT(headerB64, payloadB64, signatureB64, clientId); + + if (!payload) { + throw new Error('identityToken is not a valid Apple JWT or has expired'); } - const { iss, sub, email } = decodedToken.payloadObj as any; - if (!iss) { - throw new Error('Insufficient data in auth response token'); + if (!payload.sub) { + throw new Error('Insufficient data: Missing subject (sub) in auth response token'); } const serviceData = { - id: sub, - email, - name: '', + id: payload.sub, + ...payload, }; return serviceData; diff --git a/apps/meteor/app/apple/server/AppleCustomOAuth.ts b/apps/meteor/app/apple/server/AppleCustomOAuth.ts index d6617b7b5e81d..ac43edf1e3254 100644 --- a/apps/meteor/app/apple/server/AppleCustomOAuth.ts +++ b/apps/meteor/app/apple/server/AppleCustomOAuth.ts @@ -2,6 +2,7 @@ import { MeteorError } from '@rocket.chat/core-services'; import { Accounts } from 'meteor/accounts-base'; import { CustomOAuth } from '../../custom-oauth/server/custom_oauth_server'; +import { settings } from '../../settings/server'; import { handleIdentityToken } from '../lib/handleIdentityToken'; export class AppleCustomOAuth extends CustomOAuth { @@ -16,7 +17,9 @@ export class AppleCustomOAuth extends CustomOAuth { } try { - const serviceData = await handleIdentityToken(identityToken); + const clientId = settings.get('Accounts_OAuth_Apple_id') || ''; + + const serviceData = await handleIdentityToken(identityToken, clientId); if (usrObj?.name) { serviceData.name = `${usrObj.name.firstName}${usrObj.name.middleName ? ` ${usrObj.name.middleName}` : ''}${ diff --git a/apps/meteor/app/apple/server/appleOauthRegisterService.ts b/apps/meteor/app/apple/server/appleOauthRegisterService.ts index 7cb748c7ab917..f236b9598b23f 100644 --- a/apps/meteor/app/apple/server/appleOauthRegisterService.ts +++ b/apps/meteor/app/apple/server/appleOauthRegisterService.ts @@ -1,4 +1,5 @@ -import { KJUR } from 'jsrsasign'; +import { createPrivateKey, sign } from 'node:crypto'; + import { ServiceConfiguration } from 'meteor/service-configuration'; import { AppleCustomOAuth } from './AppleCustomOAuth'; @@ -7,6 +8,29 @@ import { config } from '../lib/config'; new AppleCustomOAuth('apple', config); +const toBase64Url = (obj: Record) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + +function generateAppleClientSecret(header: Record, payload: Record, privateKeyString: string): string { + const headerB64 = toBase64Url(header); + const payloadB64 = toBase64Url(payload); + const dataToSign = `${headerB64}.${payloadB64}`; + + const privateKey = createPrivateKey({ + key: privateKeyString, + format: 'pem', + type: 'pkcs8', + }); + + const signature = sign('sha256', Buffer.from(dataToSign), { + key: privateKey, + dsaEncoding: 'ieee-p1363', + }); + + const signatureB64 = signature.toString('base64url'); + + return `${dataToSign}.${signatureB64}`; +} + settings.watchMultiple( [ 'Accounts_OAuth_Apple', @@ -22,8 +46,14 @@ settings.watchMultiple( }); } - // if everything is empty but Apple login is enabled, don't show the login button - if (!clientId && !serverSecret && !iss && !kid) { + const [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid] = [clientId, serverSecret, iss, kid].map((value) => + typeof value === 'string' ? value.trim() : '', + ); + + const hasAllFields = [normalizedClientId, normalizedServerSecret, normalizedIss, normalizedKid].every(Boolean); + + // Hide web button if settings are incomplete, but preserve mobile-only setup if enabled. + if (!hasAllFields) { await ServiceConfiguration.configurations.upsertAsync( { service: 'apple', @@ -39,42 +69,57 @@ settings.watchMultiple( } const HEADER = { - kid, + kid: normalizedKid, alg: 'ES256', }; const now = new Date(); const exp = new Date(); - exp.setMonth(exp.getMonth() + 5); // from Apple docs expiration time must no be greater than 6 months - - const secret = KJUR.jws.JWS.sign( - null, - HEADER, - { - iss, - iat: Math.floor(now.getTime() / 1000), - exp: Math.floor(exp.getTime() / 1000), - aud: 'https://appleid.apple.com', - sub: clientId, - }, - serverSecret as string, - ); + exp.setMonth(exp.getMonth() + 5); - await ServiceConfiguration.configurations.upsertAsync( - { - service: 'apple', - }, - { - $set: { - showButton: true, - secret, - enabled: settings.get('Accounts_OAuth_Apple'), - loginStyle: 'popup', - clientId: clientId as string, - buttonColor: '#000', - buttonLabelColor: '#FFF', + try { + const secret = generateAppleClientSecret( + HEADER, + { + iss: normalizedIss, + iat: Math.floor(now.getTime() / 1000), + exp: Math.floor(exp.getTime() / 1000), + aud: 'https://appleid.apple.com', + sub: normalizedClientId, }, - }, - ); + normalizedServerSecret, + ); + + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: true, + secret, + enabled: settings.get('Accounts_OAuth_Apple'), + loginStyle: 'popup', + clientId: normalizedClientId, + buttonColor: '#000', + buttonLabelColor: '#FFF', + }, + }, + ); + } catch (error) { + console.error('Failed to configure Apple OAuth service', error); + + await ServiceConfiguration.configurations.upsertAsync( + { + service: 'apple', + }, + { + $set: { + showButton: false, + enabled: settings.get('Accounts_OAuth_Apple'), + }, + }, + ); + } }, ); diff --git a/apps/meteor/app/apple/server/loginHandler.spec.ts b/apps/meteor/app/apple/server/loginHandler.spec.ts new file mode 100644 index 0000000000000..ffa50a952d4cf --- /dev/null +++ b/apps/meteor/app/apple/server/loginHandler.spec.ts @@ -0,0 +1,124 @@ +import { Accounts } from 'meteor/accounts-base'; + +import { settings } from '../../settings/server'; +import { handleIdentityToken } from '../lib/handleIdentityToken'; + +jest.mock( + 'meteor/accounts-base', + () => ({ + Accounts: { + registerLoginHandler: jest.fn(), + updateOrCreateUserFromExternalService: jest.fn(), + LoginCancelledError: { numericError: 400 }, + }, + }), + { virtual: true }, +); + +jest.mock( + 'meteor/meteor', + () => ({ + Meteor: { + Error: class extends Error { + constructor( + public error: number, + public reason: string, + ) { + super(reason); + } + }, + }, + }), + { virtual: true }, +); + +jest.mock('../../settings/server', () => ({ + settings: { + get: jest.fn(), + }, +})); + +jest.mock('../lib/handleIdentityToken', () => ({ + handleIdentityToken: jest.fn(), +})); + +describe('Apple OAuth loginHandler', () => { + let loginHandlerCallback: Parameters[1]; + + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + require('./loginHandler'); + loginHandlerCallback = jest.mocked(Accounts.registerLoginHandler).mock.calls[0][1]; + }); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(settings.get).mockImplementation((key) => { + if (key === 'Accounts_OAuth_Apple') return true; + if (key === 'Accounts_OAuth_Apple_id') return 'com.yourcompany.app'; + return null; + }); + }); + + it('should not use the client-provided email if Apple does not provide one', async () => { + jest.mocked(handleIdentityToken).mockResolvedValue({ + id: 'apple-sub-123', + }); + + const maliciousLoginRequest = { + identityToken: 'valid.token.without_email', + email: 'alice@email.tld', + fullName: { givenName: 'Alice', familyName: 'Sender' }, + }; + + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'new-user-id' }); + + await loginHandlerCallback(maliciousLoginRequest); + + expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith( + 'apple', + { id: 'apple-sub-123' }, + { profile: { name: 'Alice Sender' } }, + ); + }); + + it('should successfully pass the email if Apple natively provides it in the signed JWT', async () => { + jest.mocked(handleIdentityToken).mockResolvedValue({ + id: 'apple-sub-123', + email: 'legit@email.tld', + }); + + const legitLoginRequest = { + identityToken: 'valid.token.with_email', + fullName: { givenName: 'John', familyName: 'Doe' }, + }; + + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' }); + + await loginHandlerCallback(legitLoginRequest); + + expect(Accounts.updateOrCreateUserFromExternalService).toHaveBeenCalledWith( + 'apple', + { id: 'apple-sub-123', email: 'legit@email.tld' }, + { profile: { name: 'John Doe' } }, + ); + }); + + it('should pass empty client id to token validation when setting is not configured', async () => { + jest.mocked(settings.get).mockImplementation((key) => { + if (key === 'Accounts_OAuth_Apple') return true; + if (key === 'Accounts_OAuth_Apple_id') return ''; + return null; + }); + + jest.mocked(handleIdentityToken).mockResolvedValue({ id: 'apple-sub-123' }); + jest.mocked(Accounts.updateOrCreateUserFromExternalService).mockResolvedValue({ userId: 'user-id' }); + + await loginHandlerCallback({ + identityToken: 'valid.token.with_mobile_default_audience', + fullName: { givenName: 'Mobile', familyName: 'User' }, + }); + + expect(handleIdentityToken).toHaveBeenCalledWith('valid.token.with_mobile_default_audience', ''); + }); +}); diff --git a/apps/meteor/app/apple/server/loginHandler.ts b/apps/meteor/app/apple/server/loginHandler.ts index 18ac7ddd75268..96ccd53323b32 100644 --- a/apps/meteor/app/apple/server/loginHandler.ts +++ b/apps/meteor/app/apple/server/loginHandler.ts @@ -13,26 +13,23 @@ Accounts.registerLoginHandler('apple', async (loginRequest) => { return; } - const { identityToken, fullName, email } = loginRequest; + const { identityToken, fullName } = loginRequest; try { - const serviceData = await handleIdentityToken(identityToken); + const clientId = settings.get('Accounts_OAuth_Apple_id') || ''; - if (!serviceData.email && email) { - serviceData.email = email; - } + const serviceData = await handleIdentityToken(identityToken, clientId); const profile: { name?: string } = {}; - const { givenName, familyName } = fullName; + const { givenName, familyName } = fullName || {}; if (givenName && familyName) { profile.name = `${givenName} ${familyName}`; } - const result = Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); + const result = await Accounts.updateOrCreateUserFromExternalService('apple', serviceData, { profile }); - // Ensure processing succeeded - if (result === undefined || result.userId === undefined) { + if (result?.userId === undefined) { return { type: 'apple', error: new Meteor.Error(Accounts.LoginCancelledError.numericError, 'User creation failed from Apple response token'), diff --git a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts index 9ab66e6086efc..428cd2f04fb67 100644 --- a/apps/meteor/app/autotranslate/client/lib/autotranslate.ts +++ b/apps/meteor/app/autotranslate/client/lib/autotranslate.ts @@ -69,6 +69,16 @@ export const AutoTranslate = { } } + if (attachment.description && attachment.translations && attachment.translations[language]) { + attachment.translations.original = attachment.description; + + if (autoTranslateShowInverse) { + attachment.description = attachment.translations.original; + } else { + attachment.description = attachment.translations[language]; + } + } + if (attachment.attachments && attachment.attachments.length > 0) { // @ts-expect-error - not sure what to do with this attachment.attachments = this.translateAttachments(attachment.attachments, language); diff --git a/apps/meteor/app/autotranslate/server/autotranslate.ts b/apps/meteor/app/autotranslate/server/autotranslate.ts index 3e04f6d39eb30..2f91e02463d58 100644 --- a/apps/meteor/app/autotranslate/server/autotranslate.ts +++ b/apps/meteor/app/autotranslate/server/autotranslate.ts @@ -320,7 +320,7 @@ export abstract class AutoTranslate { if (message.attachments && message.attachments.length > 0) { setImmediate(async () => { for (const [index, attachment] of message.attachments?.entries() ?? []) { - if (attachment.text) { + if (attachment.description || attachment.text) { // Removes the initial link `[ ](quoterl)` from quote message before translation const translatedText = attachment?.text?.replace(/\[(.*?)\]\(.*?\)/g, '$1') || attachment?.text; const attachmentMessage = { ...attachment, text: translatedText }; diff --git a/apps/meteor/app/autotranslate/server/deeplTranslate.ts b/apps/meteor/app/autotranslate/server/deeplTranslate.ts index 35f73e1755da6..d76a7ea2e4901 100644 --- a/apps/meteor/app/autotranslate/server/deeplTranslate.ts +++ b/apps/meteor/app/autotranslate/server/deeplTranslate.ts @@ -196,7 +196,7 @@ class DeeplAutoTranslate extends AutoTranslate { params: { auth_key: this.apiKey, target_lang: language, - text: attachment.text || '', + text: attachment.description || attachment.text || '', }, }); if (!result.ok) { diff --git a/apps/meteor/app/autotranslate/server/googleTranslate.ts b/apps/meteor/app/autotranslate/server/googleTranslate.ts index 53b9bb7c1d5ea..9667ae53c967a 100644 --- a/apps/meteor/app/autotranslate/server/googleTranslate.ts +++ b/apps/meteor/app/autotranslate/server/googleTranslate.ts @@ -195,7 +195,7 @@ class GoogleAutoTranslate extends AutoTranslate { key: this.apiKey, target: language, format: 'text', - q: attachment.text || '', + q: attachment.description || attachment.text || '', }, }); if (!result.ok) { diff --git a/apps/meteor/app/autotranslate/server/msTranslate.ts b/apps/meteor/app/autotranslate/server/msTranslate.ts index 6508734a1c0da..ddb345d3c895a 100644 --- a/apps/meteor/app/autotranslate/server/msTranslate.ts +++ b/apps/meteor/app/autotranslate/server/msTranslate.ts @@ -192,7 +192,7 @@ class MsAutoTranslate extends AutoTranslate { return this._translate( [ { - Text: attachment.text || '', + Text: attachment.description || attachment.text || '', }, ], targetLanguages, diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts index 7266a7d8187bc..ddec9231b3bb2 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts @@ -287,5 +287,185 @@ describe('FileUpload', () => { expect(result).to.be.true; expect(validateAndDecodeJWTStub.calledOnceWith('valid-token', 'test-secret')).to.be.true; }); + + describe('livechat room-based authorization (rc_room_type=l)', () => { + it('should allow access when livechat credentials are valid and file belongs to the same room', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(true); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.true; + expect(canAccessUploadedFileStub.calledOnce).to.be.true; + }); + + it('should deny access when livechat credentials are valid but file belongs to a different room', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(false); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/victim-file-id/secret.txt?rc_room_type=l&rc_rid=room-attacker&rc_token=attacker-token', + } as any; + + // File belongs to victim's room, not the attacker's room + const file = { _id: 'victim-file-id', rid: 'room-victim' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + + it('should pass the file object to canAccessUploadedFile', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(true); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=visitor-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + await FileUpload.requestCanAccessFiles(request, file); + + const callArgs = canAccessUploadedFileStub.firstCall.args; + expect(callArgs[1]).to.deep.equal(file); + }); + + it('should deny access when rc_room_type is provided but canAccessUploadedFile returns false', async () => { + settingsGetMap.set('FileUpload_ProtectFiles', true); + const canAccessUploadedFileStub = sinon.stub().resolves(false); + roomCoordinatorStub.getRoomDirectives.returns({ canAccessUploadedFile: canAccessUploadedFileStub }); + + const request = { + headers: {}, + url: '/file-upload/test-file-id/test-file.png?rc_room_type=l&rc_rid=room-1&rc_token=invalid-token', + } as any; + + const file = { _id: 'test-file-id', rid: 'room-1' } as any; + + const result = await FileUpload.requestCanAccessFiles(request, file); + expect(result).to.be.false; + }); + }); + }); + + describe('getRequestUserId', () => { + it('should return undefined when no url is provided', async () => { + const request = { headers: {}, url: undefined } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when no credentials are provided', async () => { + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when a uid is provided without a token', async () => { + const request = { headers: { 'x-user-id': 'user-1' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.called).to.be.false; + }); + + it('should return undefined when the login token is invalid', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves(null); + + const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'bad-token' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.be.undefined; + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_bad-token')).to.be.true; + }); + + it('should return the user id when credentials are valid via headers', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' }); + + const request = { headers: { 'x-user-id': 'user-1', 'x-auth-token': 'good-token' }, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.equal('user-1'); + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true; + }); + + it('should return the user id when credentials are valid via query string', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'user-1' }); + + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id?rc_uid=user-1&rc_token=good-token' } as any; + + const result = await FileUpload.getRequestUserId(request); + expect(result).to.equal('user-1'); + expect(usersModelStub.findOneByIdAndLoginToken.calledOnceWith('user-1', 'hashed_good-token')).to.be.true; + }); + }); + + describe('UserDataFiles.onRead', () => { + // eslint-disable-next-line new-cap + const getOnRead = () => FileUpload.defaults.UserDataFiles().onRead; + + const createResponse = () => { + const res = { writeHead: sinon.stub(), setHeader: sinon.stub() }; + res.writeHead.returns(res); + return res as any; + }; + + it('should deny access to an unauthenticated request', async () => { + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { headers: {}, url: '/ufs/UserDataFiles/file-id' } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.false; + expect(res.writeHead.calledOnceWith(403)).to.be.true; + expect(res.setHeader.called).to.be.false; + }); + + it('should deny access to an authenticated user who is not the owner', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'attacker-1' }); + + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { + headers: { 'x-user-id': 'attacker-1', 'x-auth-token': 'attacker-token' }, + url: '/ufs/UserDataFiles/file-id', + } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.false; + expect(res.writeHead.calledOnceWith(403)).to.be.true; + expect(res.setHeader.called).to.be.false; + }); + + it('should allow access to the owner of the export', async () => { + usersModelStub.findOneByIdAndLoginToken.resolves({ _id: 'owner-1' }); + + const res = createResponse(); + const file = { _id: 'file-id', userId: 'owner-1', name: 'export.zip' } as any; + const request = { + headers: { 'x-user-id': 'owner-1', 'x-auth-token': 'owner-token' }, + url: '/ufs/UserDataFiles/file-id', + } as any; + + const result = await getOnRead()('file-id', file, request, res); + expect(result).to.be.true; + expect(res.writeHead.called).to.be.false; + expect(res.setHeader.calledOnceWith('content-disposition', 'attachment; filename="export.zip"')).to.be.true; + }); }); }); diff --git a/apps/meteor/app/file-upload/server/lib/FileUpload.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.ts index 43dfc089e2e4f..adc8ec70dd5e2 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -98,7 +98,9 @@ const defaults: Record Partial> = { }, onValidate: FileUpload.uploadsOnValidate, async onRead(_fileId: string, file: IUpload, req: http.IncomingMessage, res: http.ServerResponse) { - if (!(await FileUpload.requestCanAccessFiles(req))) { + // UserDataFiles are GDPR data exports — only the owner of the export may download it. + const uid = await FileUpload.getRequestUserId(req); + if (!uid || uid !== file.userId) { res.writeHead(403); return false; } @@ -448,6 +450,31 @@ export const FileUpload = { await Avatars.updateFileNameById(file._id, user.username); }, + async getRequestUserId({ headers = {}, url }: http.IncomingMessage): Promise { + if (!url) { + return undefined; + } + + const { query } = URL.parse(url, true); + // eslint-disable-next-line @typescript-eslint/naming-convention + let { rc_uid, rc_token } = query as Record; + + if (!rc_uid && headers.cookie) { + rc_uid = cookie.get('rc_uid', headers.cookie); + rc_token = cookie.get('rc_token', headers.cookie); + } + + const uid = rc_uid || (headers['x-user-id'] as string); + const authToken = rc_token || (headers['x-auth-token'] as string); + + if (!uid || !authToken) { + return undefined; + } + + const user = await Users.findOneByIdAndLoginToken(uid, hashLoginToken(authToken), { projection: { _id: 1 } }); + return user?._id; + }, + async requestCanAccessFiles({ headers = {}, url }: http.IncomingMessage, file?: IUpload) { if (!url || !settings.get('FileUpload_ProtectFiles')) { return true; @@ -469,7 +496,7 @@ export const FileUpload = { rc_room_type && roomCoordinator .getRoomDirectives(rc_room_type) - .canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' }); + .canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' }, file); const isAuthorizedByJWT: () => boolean = () => { if (!token || typeof token !== 'string' || !settings.get('FileUpload_Enable_json_web_token_for_files')) { diff --git a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts index ec08a5d835a74..fe0fc015db704 100644 --- a/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts +++ b/apps/meteor/app/file-upload/server/methods/sendFileMessage.ts @@ -73,7 +73,7 @@ export const parseFileIntoMessageAttachments = async ( const attachment: FileAttachmentProps = { title: file.name, type: 'file', - description: file?.description, + image_alt: file?.description, title_link: fileUrl, title_link_download: true, image_url: fileUrl, diff --git a/apps/meteor/app/lib/server/functions/notifications/email.js b/apps/meteor/app/lib/server/functions/notifications/email.js index 4de543abb7dd6..c41445fcf55b2 100644 --- a/apps/meteor/app/lib/server/functions/notifications/email.js +++ b/apps/meteor/app/lib/server/functions/notifications/email.js @@ -77,8 +77,13 @@ export async function getEmailContent({ message, user, room }) { } if (hasFiles) { - const fileParts = files.map((file) => { - return escapeHTML(file.name); + const attachments = message.attachments || []; + const fileParts = files.map((file, index) => { + let part = escapeHTML(file.name); + if (attachments[index]?.description) { + part += `

${escapeHTML(attachments[index].description)}`; + } + return part; }); contentParts.push(fileParts.join('

')); } diff --git a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts index 498cef1624624..7a089abba0815 100644 --- a/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts +++ b/apps/meteor/app/lib/server/lib/sendNotificationsOnMessage.ts @@ -195,6 +195,8 @@ export const sendNotification = async ({ const firstAttachment = message.attachments?.length && message.attachments.shift(); if (firstAttachment) { + firstAttachment.description = + typeof firstAttachment.description === 'string' ? emojione.shortnameToUnicode(firstAttachment.description) : undefined; firstAttachment.text = typeof firstAttachment.text === 'string' ? emojione.shortnameToUnicode(firstAttachment.text) : undefined; } diff --git a/apps/meteor/app/lib/server/methods/updateMessage.ts b/apps/meteor/app/lib/server/methods/updateMessage.ts index 833b4403c0eca..45ba42f25f000 100644 --- a/apps/meteor/app/lib/server/methods/updateMessage.ts +++ b/apps/meteor/app/lib/server/methods/updateMessage.ts @@ -34,7 +34,7 @@ export async function executeUpdateMessage( // IF the message has custom fields, always update // Ideally, we'll compare the custom fields to check for change, but since we don't know the shape of // custom fields, as it's user defined, we're gonna update - const msgText = originalMessage.msg; + const msgText = originalMessage?.attachments?.[0]?.description ?? originalMessage.msg; if (msgText === message.msg && !previewUrls && !message.customFields) { return; } @@ -86,6 +86,13 @@ export async function executeUpdateMessage( } await canSendMessageAsync(message.rid, { uid: user._id, username: user.username ?? undefined, ...user }); + // It is possible to have an empty array as the attachments property, so ensure both things exist + if (originalMessage.attachments && originalMessage.attachments.length > 0 && originalMessage.attachments[0].description !== undefined) { + originalMessage.attachments[0].description = message.msg; + message.attachments = originalMessage.attachments; + message.msg = originalMessage.msg; + } + message.u = originalMessage.u; return updateMessage(message, user, originalMessage, previewUrls); diff --git a/apps/meteor/app/livechat/server/lib/sendTranscript.ts b/apps/meteor/app/livechat/server/lib/sendTranscript.ts index f52ac3f516710..199275f6a516b 100644 --- a/apps/meteor/app/livechat/server/lib/sendTranscript.ts +++ b/apps/meteor/app/livechat/server/lib/sendTranscript.ts @@ -108,7 +108,7 @@ export async function sendTranscript({ const messageType = MessageTypes.getType(message); - const messageContent = messageType?.system + let messageContent = messageType?.system ? DOMPurify.sanitize(` ${messageType.text(i18n.cloneInstance({ interpolation: { escapeValue: false } }).t, message)}}`) : escapeHtml(message.msg); @@ -116,6 +116,9 @@ export async function sendTranscript({ let filesHTML = ''; if (message.attachments && message.attachments?.length > 0) { + messageContent = message.attachments[0].description || ''; + escapeHtml(messageContent); + for await (const attachment of message.attachments) { if (!isFileAttachment(attachment)) { continue; diff --git a/apps/meteor/app/slackbridge/server/RocketAdapter.ts b/apps/meteor/app/slackbridge/server/RocketAdapter.ts index 5c46d75368dda..100e6991c3b08 100644 --- a/apps/meteor/app/slackbridge/server/RocketAdapter.ts +++ b/apps/meteor/app/slackbridge/server/RocketAdapter.ts @@ -203,11 +203,14 @@ export default class RocketAdapter { if (rocketMessage.file.name) { let fileName = rocketMessage.file.name; - const text = rocketMessage.msg; + let text = rocketMessage.msg; const attachment = this.getMessageAttachment(rocketMessage); if (attachment) { fileName = Meteor.absoluteUrl(attachment.title_link); + if (!text) { + text = attachment.description; + } } await slack.postMessage(slack.getSlackChannel(rocketMessage.rid), { ...rocketMessage, msg: `${text} ${fileName}` }); diff --git a/apps/meteor/app/ui/client/lib/ChatMessages.ts b/apps/meteor/app/ui/client/lib/ChatMessages.ts index 70b64201979ba..a6febf3fdfef9 100644 --- a/apps/meteor/app/ui/client/lib/ChatMessages.ts +++ b/apps/meteor/app/ui/client/lib/ChatMessages.ts @@ -120,7 +120,7 @@ export class ChatMessages implements ChatAPI { }, editMessage: async (message: IMessage, { cursorAtStart = false }: { cursorAtStart?: boolean } = {}) => { this.composer?.uploads.clear(); - const text = (await this.data.getDraft(message._id)) || message.msg; + const text = (await this.data.getDraft(message._id)) || message.attachments?.[0]?.description || message.msg; await this.currentEditingMessage.stop(); diff --git a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx index 2776c1ea81d81..564e926985a50 100644 --- a/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/DefaultAttachment.tsx @@ -97,7 +97,7 @@ const DefaultAttachment = (attachment: DefaultAttachmentProps): ReactElement => /> )} {attachment.image_url && ( - + )} {/* DEPRECATED */} {isActionAttachment(attachment) && } diff --git a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx index 9fa94126127e8..227874cfb8eaf 100644 --- a/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/AudioAttachment.tsx @@ -4,13 +4,17 @@ import { useMediaUrl } from '@rocket.chat/ui-contexts'; import { useMemo } from 'react'; import { useReloadOnError } from './hooks/useReloadOnError'; +import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; +import MessageContentBody from '../../../MessageContentBody'; const AudioAttachment = ({ title, audio_url: url, audio_type: type, audio_size: size, + description, + descriptionMd, title_link: link, title_link_download: hasDownload, collapsed, @@ -21,6 +25,7 @@ const AudioAttachment = ({ return ( <> + {descriptionMd ? : } diff --git a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx index 2e0fb7107d983..d2dbd4f888257 100644 --- a/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/GenericFileAttachment.tsx @@ -13,7 +13,9 @@ import { useTranslation } from 'react-i18next'; import { getFileExtension } from '../../../../../../lib/utils/getFileExtension'; import { forAttachmentDownload, registerDownloadForUid } from '../../../../../hooks/useDownloadFromServiceWorker'; +import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; +import MessageContentBody from '../../../MessageContentBody'; import AttachmentSize from '../structure/AttachmentSize'; const openDocumentViewer = window.RocketChatDesktop?.openDocumentViewer; @@ -22,6 +24,8 @@ type GenericFileAttachmentProps = MessageAttachmentBase; const GenericFileAttachment = ({ title, + description, + descriptionMd, title_link: link, title_link_download: hasDownload, size, @@ -68,6 +72,7 @@ const GenericFileAttachment = ({ return ( <> + {descriptionMd ? : } + {descriptionMd ? : } diff --git a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx index 1fdcedb220f2d..4768e01d41cda 100644 --- a/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/file/VideoAttachment.tsx @@ -5,13 +5,17 @@ import { useMemo } from 'react'; import { useReloadOnError } from './hooks/useReloadOnError'; import { userAgentMIMETypeFallback } from '../../../../../lib/utils/userAgentMIMETypeFallback'; +import MarkdownText from '../../../../MarkdownText'; import MessageCollapsible from '../../../MessageCollapsible'; +import MessageContentBody from '../../../MessageContentBody'; const VideoAttachment = ({ title, video_url: url, video_type: type, video_size: size, + description, + descriptionMd, title_link: link, title_link_download: hasDownload, collapsed, @@ -22,6 +26,7 @@ const VideoAttachment = ({ return ( <> + {descriptionMd ? : } diff --git a/apps/meteor/client/components/message/toolbar/useCopyAction.ts b/apps/meteor/client/components/message/toolbar/useCopyAction.ts index b8275144abca1..1a03dac99936d 100644 --- a/apps/meteor/client/components/message/toolbar/useCopyAction.ts +++ b/apps/meteor/client/components/message/toolbar/useCopyAction.ts @@ -6,8 +6,8 @@ import type { MessageActionConfig } from '../../../../app/ui-utils/client/lib/Me const getMainMessageText = (message: IMessage): IMessage => { const newMessage = { ...message }; - newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.title || ''; - newMessage.md = newMessage.md || undefined; + newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.description || newMessage.attachments?.[0]?.title || ''; + newMessage.md = newMessage.md || newMessage.attachments?.[0]?.descriptionMd || undefined; return { ...newMessage }; }; diff --git a/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx b/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx index dbbb962032907..ba281c5a1f5a2 100644 --- a/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/useReportMessageAction.tsx @@ -7,8 +7,8 @@ import ReportMessageModal from '../../../views/room/modals/ReportMessageModal'; const getMainMessageText = (message: IMessage): IMessage => { const newMessage = { ...message }; - newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.title || ''; - newMessage.md = newMessage.md || undefined; + newMessage.msg = newMessage.msg || newMessage.attachments?.[0]?.description || newMessage.attachments?.[0]?.title || ''; + newMessage.md = newMessage.md || newMessage.attachments?.[0]?.descriptionMd || undefined; return { ...newMessage }; }; diff --git a/apps/meteor/client/hooks/useDecryptedMessage.spec.ts b/apps/meteor/client/hooks/useDecryptedMessage.spec.ts index 3103e708910f6..5b35e8d6e3352 100644 --- a/apps/meteor/client/hooks/useDecryptedMessage.spec.ts +++ b/apps/meteor/client/hooks/useDecryptedMessage.spec.ts @@ -53,7 +53,7 @@ describe('useDecryptedMessage', () => { it('should handle E2EE messages with attachments', async () => { (isE2EEMessage as jest.MockedFunction).mockReturnValue(true); (e2e.decryptMessage as jest.Mock).mockResolvedValue({ - attachments: [{ title: 'Attachment title' }], + attachments: [{ description: 'Attachment description' }], }); const message = { msg: 'Encrypted message with attachment' }; @@ -63,6 +63,7 @@ describe('useDecryptedMessage', () => { expect(result.current).toBe('E2E_message_encrypted_placeholder'); }); + expect(result.current).toBe('Attachment description'); expect(e2e.decryptMessage).toHaveBeenCalledWith(message); }); diff --git a/apps/meteor/client/hooks/useDecryptedMessage.ts b/apps/meteor/client/hooks/useDecryptedMessage.ts index 771665dc0b631..e560aacc5b111 100644 --- a/apps/meteor/client/hooks/useDecryptedMessage.ts +++ b/apps/meteor/client/hooks/useDecryptedMessage.ts @@ -18,11 +18,14 @@ export const useDecryptedMessage = (message: IMessage): string => { e2e.decryptMessage(message).then((decryptedMsg) => { if (decryptedMsg.msg) { setDecryptedMessage(decryptedMsg.msg); - return; } - if (decryptedMsg.attachments && decryptedMsg.attachments.length > 0) { - setDecryptedMessage(t('Message_with_attachment')); + if (decryptedMsg.attachments && decryptedMsg.attachments?.length > 0) { + if (decryptedMsg.attachments[0].description) { + setDecryptedMessage(decryptedMsg.attachments[0].description); + } else { + setDecryptedMessage(t('Message_with_attachment')); + } } }); }, [message, t, setDecryptedMessage]); diff --git a/apps/meteor/client/lib/chats/ChatAPI.ts b/apps/meteor/client/lib/chats/ChatAPI.ts index db2e61ad640af..fed86c105029e 100644 --- a/apps/meteor/client/lib/chats/ChatAPI.ts +++ b/apps/meteor/client/lib/chats/ChatAPI.ts @@ -122,7 +122,7 @@ export type UploadsAPI = { cancel(id: Upload['id']): void; removeUpload(id: Upload['id']): void; editUploadFileName: (id: Upload['id'], fileName: string) => void; - editUploadDescription: (id: Upload['id'], description: string) => void; + editUploadAltText: (id: Upload['id'], altText: string) => void; send(file: File, encrypted?: never): Promise; send(file: File, encrypted: EncryptedFileUploadContent): Promise; }; diff --git a/apps/meteor/client/lib/chats/Upload.ts b/apps/meteor/client/lib/chats/Upload.ts index 6916d90b9101c..7e48a9733dc12 100644 --- a/apps/meteor/client/lib/chats/Upload.ts +++ b/apps/meteor/client/lib/chats/Upload.ts @@ -6,7 +6,7 @@ export type NonEncryptedUpload = { readonly url?: string; readonly percentage: number; readonly error?: Error; - readonly description?: string; + readonly altText?: string; }; export type EncryptedUpload = NonEncryptedUpload & { diff --git a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts index 10ac33f930e52..2c3cb00ec41b6 100644 --- a/apps/meteor/client/lib/chats/flows/processMessageUploads.ts +++ b/apps/meteor/client/lib/chats/flows/processMessageUploads.ts @@ -59,7 +59,7 @@ const getAttachmentForFile = async (fileToUpload: EncryptedUpload): Promise { + editUploadAltText = (uploadId: Upload['id'], altText: string) => { this.set( this.uploads.map((upload) => { if (upload.id !== uploadId) { @@ -72,9 +72,9 @@ class UploadsStore extends Emitter<{ update: void; [x: `cancelling-${Upload['id' return { ...upload, - description, + altText, ...(isEncryptedUpload(upload) && { - metadataForEncryption: { ...upload.metadataForEncryption, description }, + metadataForEncryption: { ...upload.metadataForEncryption, altText }, }), }; }), diff --git a/apps/meteor/client/lib/normalizeThreadMessage.tsx b/apps/meteor/client/lib/normalizeThreadMessage.tsx index 7f13803d2f80a..1efa853f3679e 100644 --- a/apps/meteor/client/lib/normalizeThreadMessage.tsx +++ b/apps/meteor/client/lib/normalizeThreadMessage.tsx @@ -25,7 +25,11 @@ export function normalizeThreadMessage({ ...message }: Readonly attachment.title); + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return <>{attachment.description}; + } if (attachment?.title) { return <>{attachment.title}; diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts index 0105608949b7f..e48eb15f885cf 100644 --- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts @@ -178,6 +178,47 @@ describe('parseMessageTextToAstMarkdown', () => { }); it('should return correct attachment translated parsed md when translate is active', () => { + const attachmentTranslatedMessage = { + ...translatedMessage, + attachments: [ + { + description: 'description', + translations: { + en: 'description translated', + }, + }, + ], + }; + const attachmentTranslatedMessageParsed = { + ...translatedMessage, + md: translatedMessageParsed, + attachments: [ + { + description: 'description', + translations: { + en: 'description translated', + }, + md: [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'description translated', + }, + ], + }, + ], + }, + ], + }; + + expect(parseMessageTextToAstMarkdown(attachmentTranslatedMessage, parseOptions, enabledAutoTranslatedOptions)).toStrictEqual( + attachmentTranslatedMessageParsed, + ); + }); + + it('should return correct attachment quote translated parsed md when translate is active', () => { const attachmentTranslatedMessage = { ...translatedMessage, attachments: [ @@ -337,7 +378,7 @@ describe('parseMessageAttachments', () => { const attachmentMessage = [ { - text: 'message **bold** _italic_ and ~strike~', + description: 'message **bold** _italic_ and ~strike~', md: messageParserTokenMessage, }, ]; @@ -359,18 +400,46 @@ describe('parseMessageAttachments', () => { autoTranslateLanguage: 'en', }; - it('should return correct attachment text parsed md when translate is active and auto translate language is undefined', () => { - const textAttachment = [ + it('should return correct attachment description translated parsed md when translate is active', () => { + const descriptionAttachment = [ { ...attachmentMessage[0], - text: 'attachment not translated', + description: 'attachment not translated', translationProvider: 'provider', translations: { en: 'attachment translated', }, }, ]; - const textAttachmentParsed: Root = [ + const descriptionAttachmentParsed: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'attachment translated', + }, + ], + }, + ]; + + expect(parseMessageAttachments(descriptionAttachment, parseOptions, enabledAutoTranslatedOptions)[0].md).toStrictEqual( + descriptionAttachmentParsed, + ); + }); + + it('should return correct attachment description parsed md when translate is active and auto translate language is undefined', () => { + const descriptionAttachment = [ + { + ...attachmentMessage[0], + description: 'attachment not translated', + translationProvider: 'provider', + translations: { + en: 'attachment translated', + }, + }, + ]; + const descriptionAttachmentParsed: Root = [ { type: 'PARAGRAPH', value: [ @@ -383,11 +452,39 @@ describe('parseMessageAttachments', () => { ]; expect( - parseMessageAttachments(textAttachment, parseOptions, { + parseMessageAttachments(descriptionAttachment, parseOptions, { ...enabledAutoTranslatedOptions, autoTranslateLanguage: undefined, })[0].md, - ).toStrictEqual(textAttachmentParsed); + ).toStrictEqual(descriptionAttachmentParsed); + }); + + it('should return correct attachment text translated parsed md when translate is active', () => { + const textAttachment = [ + { + ...attachmentMessage[0], + text: 'attachment not translated', + translationProvider: 'provider', + translations: { + en: 'attachment translated', + }, + }, + ]; + const textAttachmentParsed: Root = [ + { + type: 'PARAGRAPH', + value: [ + { + type: 'PLAIN_TEXT', + value: 'attachment translated', + }, + ], + }, + ]; + + expect(parseMessageAttachments(textAttachment, parseOptions, enabledAutoTranslatedOptions)[0].md).toStrictEqual( + textAttachmentParsed, + ); }); it('should return correct attachment text translated parsed md when translate is active and has multiple texts', () => { diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts index 55d393cee38ed..df84785e26351 100644 --- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts +++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts @@ -1,5 +1,12 @@ import type { IMessage, ITranslatedMessage, MessageAttachment } from '@rocket.chat/core-typings'; -import { isE2EEMessage, isQuoteAttachment, isTranslatedAttachment, isTranslatedMessage } from '@rocket.chat/core-typings'; +import { + isFileAttachment, + isE2EEMessage, + isQuoteAttachment, + isTranslatedAttachment, + isTranslatedMessage, + isEncryptedMessageAttachment, +} from '@rocket.chat/core-typings'; import type { Options, Root } from '@rocket.chat/message-parser'; import { parse } from '@rocket.chat/message-parser'; @@ -51,7 +58,7 @@ export const parseMessageAttachment = ( autoTranslateOptions: { autoTranslateLanguage?: string; translated: boolean }, ): T => { const { translated, autoTranslateLanguage } = autoTranslateOptions; - if (!attachment.text) { + if (!attachment.text && !attachment.description) { return attachment; } @@ -62,8 +69,16 @@ export const parseMessageAttachment = ( const text = (isTranslatedAttachment(attachment) && autoTranslateLanguage && attachment?.translations?.[autoTranslateLanguage]) || attachment.text || + attachment.description || ''; + if (isFileAttachment(attachment) && attachment.description) { + attachment.descriptionMd = + translated || isEncryptedMessageAttachment(attachment) + ? textToMessageToken(text, parseOptions) + : (attachment.descriptionMd ?? textToMessageToken(text, parseOptions)); + } + return { ...attachment, md: translated ? textToMessageToken(text, parseOptions) : (attachment.md ?? textToMessageToken(text, parseOptions)), diff --git a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts index 9584c13531439..184145a6c4506 100644 --- a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts +++ b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.spec.ts @@ -48,7 +48,7 @@ describe('normalizeMessagePreview', () => { }); describe('when message has attachments', () => { - it('should return attachment title when description is available', () => { + it('should return attachment description when available', () => { const message = createFakeMessageWithAttachment({ msg: '', attachments: [ @@ -60,10 +60,10 @@ describe('normalizeMessagePreview', () => { }); const result = normalizeMessagePreview(message, mockT); - expect(result).toBe('Attachment title'); + expect(result).toBe('Attachment description'); }); - it('should return attachment title when message is not provided', () => { + it('should return attachment title when description is not available', () => { const message = createFakeMessageWithAttachment({ msg: '', attachments: [ @@ -112,7 +112,7 @@ describe('normalizeMessagePreview', () => { expect(result).toBe('Second attachment title'); }); - it('should find first attachment title', () => { + it('should find first attachment description', () => { const message = createFakeMessageWithAttachment({ msg: '', attachments: [ @@ -129,7 +129,21 @@ describe('normalizeMessagePreview', () => { }); const result = normalizeMessagePreview(message, mockT); - expect(result).toBe('Third attachment title'); + expect(result).toBe('Second attachment description'); + }); + + it('should escape HTML in attachment description', () => { + const message = createFakeMessageWithAttachment({ + msg: '', + attachments: [ + { + description: '', + }, + ], + }); + const result = normalizeMessagePreview(message, mockT); + + expect(result).toBe('<script>alert("xss")</script>'); }); it('should escape HTML in attachment title', () => { diff --git a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts index 0fbefea5fa48d..53bf5bf2d4056 100644 --- a/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts +++ b/apps/meteor/client/lib/utils/normalizeMessagePreview/normalizeMessagePreview.ts @@ -11,7 +11,11 @@ export const normalizeMessagePreview = (message: IMessage, t: TFunction): string } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title); + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return escapeHTML(attachment.description); + } if (attachment?.title) { return escapeHTML(attachment.title); diff --git a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx index 94313e6925432..0bcbbccece740 100644 --- a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx +++ b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx @@ -31,7 +31,11 @@ export const useMessageBody = (message: IMessage | undefined): string | Root => } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title); + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return attachment.description; + } if (attachment?.title) { return attachment.title; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx index 6ceece6a89f59..fcd87b9d3e37a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFileItem.tsx @@ -7,7 +7,7 @@ import { isPreviewableImage } from '../../../../lib/utils/isPreviewableImage'; export type MessageComposerFileItemProps = { upload: Upload; onRemove: (id: string) => void; - onEdit: (id: Upload['id'], fileName: string, description?: string) => void; + onEdit: (id: Upload['id'], fileName: string, altText?: string) => void; onCancel: (id: Upload['id']) => void; disabled: boolean; shouldPreview?: boolean; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx index 84f0056513d77..c8e6d60f30ed0 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerFiles.tsx @@ -11,10 +11,10 @@ const MessageComposerFiles = () => { const { uploads, uploadsStore, isProcessingUploads, hasUploads } = useFileUpload(); const handleEdit = useCallback( - (id: Upload['id'], fileName: string, description?: string) => { + (id: Upload['id'], fileName: string, altText?: string) => { uploadsStore?.editUploadFileName(id, fileName); - if (description !== undefined) { - uploadsStore?.editUploadDescription(id, description); + if (altText !== undefined) { + uploadsStore?.editUploadAltText(id, altText); } }, [uploadsStore], diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx index d24e22b54c404..d68119b58dc6b 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageComposerGenericFile.tsx @@ -39,13 +39,13 @@ const MessageComposerGenericFile = ({ setModal( { - onEdit(upload.id, name, description); + onSubmit={(name, altText) => { + onEdit(upload.id, name, altText); setModal(null); chat?.composer?.focus(); }} fileName={upload.file.name} - fileDescription={upload.description} + fileAltText={upload.altText} file={upload.file} onClose={() => setModal(null)} />, @@ -86,7 +86,7 @@ const MessageComposerGenericFile = ({ fileTitle={upload.file.name} fileSubtitle={`${fileSize} - ${fileExtension}`} previewUrl={shouldPreview ? previewUrl : undefined} - alt={upload.description} + alt={upload.altText} fileFormat={getFileExtension(upload.file.name)} showPreview={shouldPreview} actionIcon={actionIcon} diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts b/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts index 291475075f4e4..f7b53352df5a1 100644 --- a/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/useDownloadExportMutation.ts @@ -39,6 +39,7 @@ export const useDownloadExportMutation = () => { ...('image_type' in attachment && { image_type: attachment.image_type }), ...('image_size' in attachment && { image_size: attachment.image_size }), ...('type' in attachment && { type: attachment.type }), + description: attachment.description, })) ?? [], }), ); diff --git a/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx b/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx index 383388f0c316f..716f7f3c8bd19 100644 --- a/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx +++ b/apps/meteor/client/views/room/contextualBar/ExportMessages/useExportMessagesAsPDFMutation.tsx @@ -116,6 +116,7 @@ export const useExportMessagesAsPDFMutation = () => { {parseMessage(message)} {message.attachments?.map((attachment: MessageAttachmentDefault, index) => ( + {attachment.description && {attachment.description}} {attachment.image_url && } {attachment.title} diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts index c052942a9567d..4ac708211cf47 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useNormalizedThreadTitleHtml.ts @@ -33,7 +33,11 @@ export const useNormalizedThreadTitleHtml = (mainMessage: IThreadMainMessage) => } if (message.attachments) { - const attachment = message.attachments.find((attachment) => attachment.title); + const attachment = message.attachments.find((attachment) => attachment.title || attachment.description); + + if (attachment?.description) { + return escapeHTML(attachment.description); + } if (attachment?.title) { return escapeHTML(attachment.title); diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx index 6522679debb0f..35af01a288aa6 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx @@ -1,5 +1,3 @@ -import type { ReactElement } from 'react'; - import GenericPreview from './GenericPreview'; import MediaPreview from './MediaPreview'; import { MAX_FILE_SIZE_PREVIEW } from '../../../../lib/constants'; @@ -41,14 +39,14 @@ const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefine type FilePreviewProps = { file: File; - description?: string; + altText?: string; }; -const FilePreview = ({ file, description }: FilePreviewProps): ReactElement => { +const FilePreview = ({ file, altText }: FilePreviewProps) => { const fileType = getFileType(file.type); if (shouldShowMediaPreview(file, fileType)) { - return ; + return ; } return ; diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index ba84cb05a7da0..f970f92cbdcb9 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -19,7 +19,7 @@ import { FieldGroup, FieldDescription, } from '@rocket.chat/fuselage-forms'; -import type { ReactElement, ComponentProps } from 'react'; +import type { ComponentProps } from 'react'; import { memo, useCallback, useId } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -30,13 +30,13 @@ import { getMimeTypeFromFileName } from '../../../../../app/utils/lib/mimeTypes' type FileUploadModalProps = { onClose: () => void; - onSubmit: (name: string, description?: string) => void; + onSubmit: (name: string, altText?: string) => void; file: File; fileName: string; - fileDescription?: string; + fileAltText?: string; }; -const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubmit }: FileUploadModalProps): ReactElement => { +const FileUploadModal = ({ onClose, file, fileName, fileAltText = '', onSubmit }: FileUploadModalProps) => { const { t } = useTranslation(); const fileUploadFormId = useId(); const isImage = file.type.startsWith('image/'); @@ -45,7 +45,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm control, handleSubmit, formState: { errors, isDirty, isSubmitting }, - } = useForm({ mode: 'onBlur', defaultValues: { name: fileName, description: fileDescription } }); + } = useForm({ mode: 'onBlur', defaultValues: { name: fileName, altText: fileAltText } }); const validateFileName = useCallback( (fieldValue: string) => { @@ -63,12 +63,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm ) => ( - onSubmit(name, description?.trim() || undefined))} - {...props} - /> + onSubmit(name, altText?.trim()))} {...props} /> )} > @@ -78,7 +73,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm - + @@ -101,7 +96,7 @@ const FileUploadModal = ({ onClose, file, fileName, fileDescription = '', onSubm {t('Alternative_text')} {t('Alt_text_description')} - } /> + } /> )} diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx index 64a18f422787c..635c7d4429e2b 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/ImagePreview.tsx @@ -1,5 +1,4 @@ import { Box } from '@rocket.chat/fuselage'; -import type { ReactElement } from 'react'; import { useState } from 'react'; import GenericPreview from './GenericPreview'; @@ -8,10 +7,10 @@ import PreviewSkeleton from './PreviewSkeleton'; type ImagePreviewProps = { url: string; file: File; - alt?: string; + altText?: string; }; -const ImagePreview = ({ url, file, alt = '' }: ImagePreviewProps): ReactElement => { +const ImagePreview = ({ url, file, altText = '' }: ImagePreviewProps) => { const [error, setError] = useState(false); const [loading, setLoading] = useState(true); @@ -31,7 +30,7 @@ const ImagePreview = ({ url, file, alt = '' }: ImagePreviewProps): ReactElement { +const MediaPreview = ({ file, fileType, altText }: MediaPreviewProps) => { const [loaded, url] = useFileAsDataURL(file); const { t } = useTranslation(); @@ -33,7 +32,7 @@ const MediaPreview = ({ file, fileType, description }: MediaPreviewProps): React } if (fileType === FilePreviewType.IMAGE) { - return ; + return ; } if (fileType === FilePreviewType.VIDEO) { diff --git a/apps/meteor/definition/IRoomTypeConfig.ts b/apps/meteor/definition/IRoomTypeConfig.ts index 2edb7cef27038..bdb03a3d946b0 100644 --- a/apps/meteor/definition/IRoomTypeConfig.ts +++ b/apps/meteor/definition/IRoomTypeConfig.ts @@ -1,4 +1,14 @@ -import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast, ISubscription, IOmnichannelRoom } from '@rocket.chat/core-typings'; +import type { + IRoom, + RoomType, + IUser, + IMessage, + ValueOf, + AtLeast, + ISubscription, + IOmnichannelRoom, + IUpload, +} from '@rocket.chat/core-typings'; import type { Keys as IconName } from '@rocket.chat/icons'; import type { IRouterPaths, RouteName } from '@rocket.chat/ui-contexts'; @@ -88,7 +98,7 @@ export interface IRoomTypeServerDirectives { canBeDeleted: (hasPermission: (permissionId: string, rid?: string) => Promise | boolean, room: IRoom) => Promise; preventRenaming: () => boolean; getDiscussionType: (room?: AtLeast) => Promise; - canAccessUploadedFile: (params: { rc_uid: string; rc_rid: string; rc_token: string }) => Promise; + canAccessUploadedFile: (params: { rc_uid: string; rc_rid: string; rc_token: string }, file?: IUpload) => Promise; getNotificationDetails: ( room: IRoom, sender: AtLeast, diff --git a/apps/meteor/definition/externals/meteor/accounts-base.d.ts b/apps/meteor/definition/externals/meteor/accounts-base.d.ts index 39ea55788e9a7..becb48a4a8137 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -41,7 +41,7 @@ declare module 'meteor/accounts-base' { serviceName: string, serviceData: Record, options: Record, - ): Record; + ): Promise>; function _clearAllLoginTokens(userId: string | null): void; diff --git a/apps/meteor/ee/server/services/package.json b/apps/meteor/ee/server/services/package.json index 3bb9fe3021268..0abac0e3128ff 100644 --- a/apps/meteor/ee/server/services/package.json +++ b/apps/meteor/ee/server/services/package.json @@ -44,7 +44,7 @@ "pino": "10.3.1", "sodium-native": "^4.3.3", "sodium-plus": "^0.9.0", - "ws": "~8.19.0" + "ws": "~8.21.0" }, "devDependencies": { "@rocket.chat/icons": "~0.47.0", diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 1c2439c4c7837..8de98b0718cf2 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -50,6 +50,8 @@ export default { '/app/api/server/helpers/**.spec.ts', '/app/api/server/middlewares/**.spec.ts', '/app/version-check/server/**/*.spec.ts', + '/app/apple/lib/**.spec.ts', + '/app/apple/server/**.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], }, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index f2785c504c45f..78f217dd99b53 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -216,7 +216,7 @@ "gravatar": "^1.8.2", "he": "^1.2.0", "highlight.js": "11.8.0", - "hono": "4.12.18", + "hono": "4.12.23", "http-proxy-agent": "^7.0.2", "human-interval": "^2.0.1", "i18next-http-backend": "^3.0.6", diff --git a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts index d15ac6dbbc909..b2480acff247d 100644 --- a/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts +++ b/apps/meteor/server/features/EmailInbox/EmailInbox_Outgoing.ts @@ -142,7 +142,11 @@ slashCommands.add({ return; } - const emailText = message?.msg || ''; + const emailText = + message?.attachments + ?.map((a) => a.description) + .filter(Boolean) + .join('\n\n') || ''; void sendEmail( inbox, diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts index 37fbe4d710a06..5ae8f45411c26 100644 --- a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'; import type { IMessage, IRoom, IUser, MessageAttachment, FileProp, RoomType, IExportOperation } from '@rocket.chat/core-typings'; import { Messages } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import { settings } from '../../../app/settings/server'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; @@ -175,9 +176,10 @@ export const exportMessageObject = (type: 'json' | 'html', messageObject: Messag const italicTypes: IMessage['t'][] = ['uj', 'ul', 'au', 'r', 'ru', 'wm', 'livechat-close']; - const message = italicTypes.includes(messageType) ? `${messageObject.msg}` : messageObject.msg; + const safeMsg = escapeHTML(messageObject.msg ?? ''); + const message = italicTypes.includes(messageType) ? `${safeMsg}` : safeMsg; - file.push(`

${messageObject.username} (${timestamp}):
`); + file.push(`

${escapeHTML(messageObject.username ?? '')} (${timestamp}):
`); file.push(message); for (const messageFile of messageFiles) { @@ -187,7 +189,7 @@ export const exportMessageObject = (type: 'json' | 'html', messageObject: Messag const description = attachment?.title || i18n.t('Message_Attachments'); const assetUrl = `./assets/${messageFile._id}-${messageFile.name}`; - const link = `
${description}`; + const link = `
${escapeHTML(description)}`; file.push(link); } } @@ -281,7 +283,14 @@ export const exportRoomMessagesToFile = async function ( if (exportOpRoomData.status === 'pending') { exportOpRoomData.status = 'exporting'; if (exportType === 'html') { - await writeFile(filePath, '', { encoding: 'utf8' }); + await writeFile( + filePath, + [ + '', + ``, + ].join('\n'), + { encoding: 'utf8' }, + ); } } diff --git a/apps/meteor/server/lib/dataExport/processDataDownloads.ts b/apps/meteor/server/lib/dataExport/processDataDownloads.ts index 41f8c693459f3..5c31fcb09c8a1 100644 --- a/apps/meteor/server/lib/dataExport/processDataDownloads.ts +++ b/apps/meteor/server/lib/dataExport/processDataDownloads.ts @@ -4,6 +4,7 @@ import { access, mkdir, rm, writeFile } from 'node:fs/promises'; import type { IExportOperation, IUser, RoomType } from '@rocket.chat/core-typings'; import { Avatars, ExportOperations, UserDataFiles, Subscriptions } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import moment from 'moment'; import { FileUpload } from '../../../app/file-upload/server'; @@ -79,15 +80,16 @@ const generateUserFile = async (exportOperation: IExportOperation, userData?: IU stream.write('\n'); stream.write('\n'); + stream.write(`\n`); for (const [key, value] of Object.entries(dataToSave)) { - stream.write(`

${key}:`); + stream.write(`

${escapeHTML(key)}:`); if (typeof value === 'string') { - stream.write(value); + stream.write(escapeHTML(value)); } else if (Array.isArray(value)) { stream.write('
'); for (const item of value) { - stream.write(`${item}
`); + stream.write(`${escapeHTML(String(item))}
`); } } diff --git a/apps/meteor/server/lib/dataExport/sendViaEmail.ts b/apps/meteor/server/lib/dataExport/sendViaEmail.ts index dda337c851e44..834128a6a7f2a 100644 --- a/apps/meteor/server/lib/dataExport/sendViaEmail.ts +++ b/apps/meteor/server/lib/dataExport/sendViaEmail.ts @@ -1,5 +1,6 @@ import type { IMessage, IUser } from '@rocket.chat/core-typings'; import { Messages, Users } from '@rocket.chat/models'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import moment from 'moment'; import * as Mailer from '../../../app/mailer/server/api'; @@ -67,9 +68,9 @@ export async function sendViaEmail( ) .map((message: IMessage) => { const dateTime = moment(message.ts).locale(lang).format('L LT'); - return `

${ - message.u.username - } ${dateTime}
${Message.parse(message, data.language)}

`; + return `

${escapeHTML( + message.u.username ?? '', + )} ${dateTime}
${Message.parse(message, data.language)}

`; }) .join(''); diff --git a/apps/meteor/server/lib/rooms/roomCoordinator.ts b/apps/meteor/server/lib/rooms/roomCoordinator.ts index 941883b132f2e..b258ef2513d5f 100644 --- a/apps/meteor/server/lib/rooms/roomCoordinator.ts +++ b/apps/meteor/server/lib/rooms/roomCoordinator.ts @@ -1,5 +1,5 @@ import { getUserDisplayName } from '@rocket.chat/core-typings'; -import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast } from '@rocket.chat/core-typings'; +import type { IRoom, RoomType, IUser, IMessage, ValueOf, AtLeast, IUpload } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { settings } from '../../../app/settings/server'; @@ -33,7 +33,7 @@ class RoomCoordinatorServer extends RoomCoordinator { async getDiscussionType(): Promise { return 'p'; }, - async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }): Promise { + async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }, _file?: IUpload): Promise { return false; }, async getNotificationDetails( diff --git a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts index 350b8d0714f8e..7c1bc3c965cee 100644 --- a/apps/meteor/server/lib/rooms/roomTypes/livechat.ts +++ b/apps/meteor/server/lib/rooms/roomTypes/livechat.ts @@ -24,11 +24,17 @@ roomCoordinator.add(LivechatRoomType, { }, async roomName(room, _userId?) { - return room.name || room.fname || (room as any).label; + return (room.name || room.fname || (room as any).label) as string; }, - async canAccessUploadedFile({ rc_token: token, rc_rid: rid }) { - return token && rid && !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token)); + async canAccessUploadedFile({ rc_token: token, rc_rid: rid }, file) { + if (!token || !rid) { + return false; + } + if (file?.rid && file.rid !== rid) { + return false; + } + return !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token)); }, async getNotificationDetails(room, _sender, notificationMessage, userId) { diff --git a/apps/meteor/server/methods/deleteFileMessage.ts b/apps/meteor/server/methods/deleteFileMessage.ts index 7fbf8650bd2a5..39cfca1ae5dad 100644 --- a/apps/meteor/server/methods/deleteFileMessage.ts +++ b/apps/meteor/server/methods/deleteFileMessage.ts @@ -1,5 +1,6 @@ +import { Upload } from '@rocket.chat/core-services'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Messages } from '@rocket.chat/models'; +import { Messages, Users, Uploads } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { DeleteResult } from 'mongodb'; @@ -16,14 +17,40 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async deleteFileMessage(fileID) { + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteFileMessage', + }); + } check(fileID, String); const msg = await Messages.getMessageByFileId(fileID); - const userId = Meteor.userId(); - if (msg && userId) { + + if (msg) { return deleteMessageValidatingPermission(msg, userId); } + const user = await Users.findOneById(userId, { projection: { username: 1 } }); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'deleteFileMessage', + }); + } + + const file = await Uploads.findOneById(fileID, { projection: { userId: 1, rid: 1, expiresAt: 1, uploadedAt: 1 } }); + if (!file) { + throw new Meteor.Error('error-invalid-file', 'Invalid file', { + method: 'deleteFileMessage', + }); + } + + if (!(await Upload.canDeleteFile(user, file, null))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { + method: 'deleteFileMessage', + }); + } + return FileUpload.getStore('Uploads').deleteById(fileID); }, }); diff --git a/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts b/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts index b2b47ad0b9228..088aff0c8b052 100644 --- a/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts +++ b/apps/meteor/server/services/messages/hooks/BeforeSaveMarkdownParser.ts @@ -38,6 +38,10 @@ export class BeforeSaveMarkdownParser { if (message.msg) { message.md = parse(message.msg, config); } + + if (message.attachments?.[0]?.description) { + message.attachments[0].descriptionMd = parse(message.attachments[0].description, config); + } } catch (e) { console.error(e); // errors logged while the parser is at experimental stage } diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 0f3f7de85abfd..1196c95e24a49 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -686,4 +686,51 @@ describe('miscellaneous', () => { .end(done); }); }); + + describe('/fingerprint', () => { + let unauthorizedUser: TestUser; + let unauthorizedUserCredentials: Credentials; + + before(async () => { + unauthorizedUser = await createUser(); + unauthorizedUserCredentials = await doLogin(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteUser(unauthorizedUser); + }); + + it('should return 401 when called without authentication', async () => { + const res = await request.post(api('fingerprint')).send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(401); + expect(res.body).to.have.property('status', 'error'); + }); + + it('should return 403 when a user without the manage-cloud permission tries to acknowledge a deployment configuration change', async () => { + const res = await request + .post(api('fingerprint')) + .set(unauthorizedUserCredentials) + .send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return 403 when a user without the manage-cloud permission tries to deregister the workspace as a new workspace', async () => { + const res = await request.post(api('fingerprint')).set(unauthorizedUserCredentials).send({ setDeploymentAs: 'new-workspace' }); + + expect(res.status).to.equal(403); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + + it('should return 200 when a user with the manage-cloud permission acknowledges a deployment configuration change', async () => { + const res = await request.post(api('fingerprint')).set(credentials).send({ setDeploymentAs: 'updated-configuration' }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('success', true); + }); + }); }); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index eaddd626f94ea..45c51cead9770 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -485,7 +485,7 @@ describe('[Rooms]', () => { expect(res.body.message.files).to.be.an('array').of.length(2); expect(res.body.message.files[0]).to.have.property('type', 'image/png'); expect(res.body.message.files[0]).to.have.property('name', '1024x1024.png'); - expect(res.body.message.attachments[0]).to.have.property('description', 'some_file_description'); + expect(res.body.message.attachments[0]).to.have.property('image_alt', 'some_file_description'); }); }); diff --git a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts index 5fb3696f34fc1..d9c346d45c92e 100644 --- a/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts +++ b/apps/meteor/tests/unit/server/lib/dataExport/exportRoomMessagesToFile.spec.ts @@ -114,6 +114,75 @@ describe('Export - exportMessageObject', () => { }); }); +describe('Export - exportMessageObject HTML escaping (XSS prevention)', () => { + const ts = new Date('2020-01-01T00:00:00.000Z'); + + it('should escape HTML in the message body when exporting as html', async () => { + const result = await exportMessageObject('html', { + msg: '', + username: 'attacker', + ts, + }); + + expect(result).to.contain('<img src=x onerror="alert(1)">'); + expect(result).to.not.contain(' { + const result = await exportMessageObject('html', { + msg: 'hello', + username: '', + ts, + }); + + expect(result).to.contain('<script>alert(1)</script>'); + expect(result).to.not.contain('