CVE-2026-48929
Description
Rocket.Chat's deleteFileMessage Meteor method allows unauthenticated attackers to delete any uploaded file by ID, leading to data loss.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Rocket.Chat's deleteFileMessage Meteor method allows unauthenticated attackers to delete any uploaded file by ID, leading to data loss.
Vulnerability
The deleteFileMessage Meteor method in Rocket.Chat versions prior to 8.5.1, 8.4.4, 8.3.6, 8.2.6, 8.1.6, 8.0.7, 7.13.9, and 7.10.13 lacks an authentication check when called via an unauthenticated DDP WebSocket connection. The method uses Meteor.userId() which returns null, causing the authorization check to be skipped. Execution then calls FileUpload.getStore('Uploads').deleteById(fileID), permanently removing the file from storage and the database.
Exploitation
An attacker does not need authentication or any prior access. They establish an unauthenticated DDP WebSocket connection to the vulnerable Rocket.Chat server. File IDs are discoverable from public channel message payloads or download URLs. The attacker calls the deleteFileMessage method with the known file ID, and the method deletes the file without any authorization check.
Impact
Successful exploitation allows an unauthenticated attacker to permanently delete any uploaded file on the server. This can lead to irreversible data loss, including attachments in public channels and potentially private channels if file IDs are leaked. The primary impact is on availability (data loss) and possibly integrity if critical files are removed.
Mitigation
The fix is included in Rocket.Chat versions 8.5.1, 8.4.4, 8.3.6, 8.2.6, 8.1.6, 8.0.7, 7.13.9, and 7.10.13, as referenced in the pull request [1]. Users should upgrade to at least these versions. No workarounds are mentioned in the available references; upgrading is the recommended mitigation.
AI Insight generated on Jun 17, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2<8.5.1, <8.4.4, <8.3.6, <8.2.6, <8.1.6, <8.0.7, <7.13.9, <7.10.13+ 1 more
- (no CPE)range: <8.5.1, <8.4.4, <8.3.6, <8.2.6, <8.1.6, <8.0.7, <7.13.9, <7.10.13
- (no CPE)range: <8.5.1, <8.4.4, <8.3.6, <8.2.6, <8.1.6, <8.0.7, <7.13.9, <7.10.13
Patches
11a9ced99bc89fix: imported fixes 06-11-26 (#40889)
16 files changed · +883 −87
apps/meteor/app/apple/lib/handleIdentityToken.spec.ts+133 −0 added@@ -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'); + }); +});
apps/meteor/app/apple/lib/handleIdentityToken.ts+144 −31 modified@@ -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<boolean> { - 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<AppleJWK[]> { + 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<AppleJWTPayload | null> { + 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<Record<string, any>> { + 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;
apps/meteor/app/apple/server/AppleCustomOAuth.ts+4 −1 modified@@ -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<string>('Accounts_OAuth_Apple_id') || ''; + + const serviceData = await handleIdentityToken(identityToken, clientId); if (usrObj?.name) { serviceData.name = `${usrObj.name.firstName}${usrObj.name.middleName ? ` ${usrObj.name.middleName}` : ''}${
apps/meteor/app/apple/server/appleOauthRegisterService.ts+78 −33 modified@@ -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<string, any>) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + +function generateAppleClientSecret(header: Record<string, any>, payload: Record<string, any>, 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'), + }, + }, + ); + } }, );
apps/meteor/app/apple/server/loginHandler.spec.ts+124 −0 added@@ -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<typeof Accounts.registerLoginHandler>[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', ''); + }); +});
apps/meteor/app/apple/server/loginHandler.ts+6 −9 modified@@ -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<string>('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'),
apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts+180 −0 modified@@ -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; + }); }); });
apps/meteor/app/file-upload/server/lib/FileUpload.ts+29 −2 modified@@ -98,7 +98,9 @@ const defaults: Record<string, () => Partial<StoreOptions>> = { }, 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<string | undefined> { + 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<string, string | undefined>; + + 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')) {
apps/meteor/definition/externals/meteor/accounts-base.d.ts+1 −1 modified@@ -41,7 +41,7 @@ declare module 'meteor/accounts-base' { serviceName: string, serviceData: Record<string, unknown>, options: Record<string, unknown>, - ): Record<string, unknown>; + ): Promise<Record<string, unknown>>; function _clearAllLoginTokens(userId: string | null): void;
apps/meteor/definition/IRoomTypeConfig.ts+12 −2 modified@@ -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> | boolean, room: IRoom) => Promise<boolean>; preventRenaming: () => boolean; getDiscussionType: (room?: AtLeast<IRoom, 'teamId'>) => Promise<RoomType>; - canAccessUploadedFile: (params: { rc_uid: string; rc_rid: string; rc_token: string }) => Promise<boolean>; + canAccessUploadedFile: (params: { rc_uid: string; rc_rid: string; rc_token: string }, file?: IUpload) => Promise<boolean>; getNotificationDetails: ( room: IRoom, sender: AtLeast<IUser, '_id' | 'name' | 'username'>,
apps/meteor/jest.config.ts+2 −0 modified@@ -50,6 +50,8 @@ export default { '<rootDir>/app/api/server/helpers/**.spec.ts', '<rootDir>/app/api/server/middlewares/**.spec.ts', '<rootDir>/app/version-check/server/**/*.spec.ts', + '<rootDir>/app/apple/lib/**.spec.ts', + '<rootDir>/app/apple/server/**.spec.ts', ], coveragePathIgnorePatterns: ['/node_modules/'], },
apps/meteor/server/lib/rooms/roomCoordinator.ts+2 −2 modified@@ -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<RoomType> { return 'p'; }, - async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }): Promise<boolean> { + async canAccessUploadedFile(_params: { rc_uid: string; rc_rid: string; rc_token: string }, _file?: IUpload): Promise<boolean> { return false; }, async getNotificationDetails(
apps/meteor/server/lib/rooms/roomTypes/livechat.ts+9 −3 modified@@ -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) {
apps/meteor/server/methods/deleteFileMessage.ts+30 −3 modified@@ -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<ServerMethods>({ 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); }, });
apps/meteor/tests/unit/server/methods/deleteFileMessage.spec.ts+124 −0 added@@ -0,0 +1,124 @@ +import { MeteorError } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const checkMock = sinon.stub(); +const meteorUserIdMock = sinon.stub(); +const meteorMethodsMock = sinon.stub(); +const deleteMessageValidatingPermissionMock = sinon.stub(); +const canDeleteFileMock = sinon.stub(); +const deleteByIdMock = sinon.stub(); +const fileUploadGetStoreMock = sinon.stub().returns({ deleteById: deleteByIdMock }); + +const modelsMock = { + Messages: { + getMessageByFileId: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + }, + Uploads: { + findOneById: sinon.stub(), + }, +}; + +p.noCallThru().load('../../../../server/methods/deleteFileMessage', { + 'meteor/meteor': { + Meteor: { + userId: meteorUserIdMock, + Error: MeteorError, + methods: meteorMethodsMock, + }, + }, + 'meteor/check': { + check: checkMock, + }, + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': { + Upload: { canDeleteFile: canDeleteFileMock }, + }, + '../../app/file-upload/server': { + FileUpload: { getStore: fileUploadGetStoreMock }, + }, + '../../app/lib/server/functions/deleteMessage': { + deleteMessageValidatingPermission: deleteMessageValidatingPermissionMock, + }, +}); + +const deleteFileMessageMethod = meteorMethodsMock.firstCall.args[0].deleteFileMessage; + +describe('deleteFileMessage', () => { + beforeEach(() => { + checkMock.resetHistory(); + meteorUserIdMock.reset(); + deleteMessageValidatingPermissionMock.reset(); + canDeleteFileMock.reset(); + deleteByIdMock.reset(); + fileUploadGetStoreMock.resetHistory(); + modelsMock.Messages.getMessageByFileId.reset(); + modelsMock.Users.findOneById.reset(); + modelsMock.Uploads.findOneById.reset(); + }); + + it('should throw if user is not authenticated', async () => { + meteorUserIdMock.returns(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid user'); + }); + + it('should delete message validating permission if file has an associated message', async () => { + meteorUserIdMock.returns('user123'); + const mockMsg = { _id: 'msg123', file: { _id: 'file123' } }; + modelsMock.Messages.getMessageByFileId.resolves(mockMsg); + deleteMessageValidatingPermissionMock.resolves(); + + await deleteFileMessageMethod('file123'); + + expect(checkMock.calledOnceWith('file123', String)).to.be.true; + expect(deleteMessageValidatingPermissionMock.calledOnceWith(mockMsg, 'user123')).to.be.true; + expect(modelsMock.Users.findOneById.called).to.be.false; + }); + + it('should throw if it is an orphan file but user is not found in DB', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid user'); + }); + + it('should throw if it is an orphan file but file is not found in DB', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves(null); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Invalid file'); + }); + + it('should not delete orphan file if user does not have permissions', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves({ _id: 'file123', userId: 'user123' }); + canDeleteFileMock.resolves(false); + + await expect(deleteFileMessageMethod('file123')).to.be.rejectedWith('Not authorized'); + }); + + it('should delete orphan file if user has permissions', async () => { + meteorUserIdMock.returns('user123'); + modelsMock.Messages.getMessageByFileId.resolves(null); + modelsMock.Users.findOneById.resolves({ _id: 'user123', username: 'test' }); + modelsMock.Uploads.findOneById.resolves({ _id: 'file123', userId: 'user123' }); + canDeleteFileMock.resolves(true); + deleteByIdMock.resolves(); + + await deleteFileMessageMethod('file123'); + + expect(fileUploadGetStoreMock.calledOnceWith('Uploads')).to.be.true; + expect(deleteByIdMock.calledOnceWith('file123')).to.be.true; + }); +});
.changeset/rich-bananas-shine.md+5 −0 added@@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
2News mentions
0No linked articles in our index yet.