CVE-2026-48616
Description
Rocket.Chat fails to verify room ID authorization for livechat file downloads, allowing unauthenticated enumeration and download of all uploaded files.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Rocket.Chat fails to verify room ID authorization for livechat file downloads, allowing unauthenticated enumeration and download of all uploaded files.
Vulnerability
In Rocket.Chat versions before 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, the endpoint /file-upload/:fileId/:name authorizes livechat access using query parameters rc_room_type=l, rc_rid, and rc_token. However, the authorization logic does not verify that the provided rc_rid matches the requested file's room ID (rid). Additionally, :fileId is a predictable sequential MongoDB ObjectID, and :name can be any arbitrary string. This allows an unauthenticated attacker to enumerate and download any uploaded file without proper authorization [1].
Exploitation
An attacker does not need authentication or any special network position. By sending a request to /file-upload/:fileId/:name with any livechat room ID and token (which can be obtained from a public livechat widget), and iterating through predictable file IDs (sequential MongoDB IDs), the attacker can download files that belong to other rooms. The server does not validate that the rc_rid token corresponds to the room owning the file [1].
Impact
Successful exploitation leads to unauthorized disclosure of all uploaded files, including sensitive information contained in file attachments. The attacker gains read access to files from any room, potentially exposing confidential data. No privilege escalation or remote code execution is described [1].
Mitigation
Rocket.Chat has addressed this vulnerability in the following 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. The fix was merged in pull request #40889 [1]. Users are strongly advised to upgrade to the latest patched version. There are no known workarounds for unpatched versions.
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>=7.10.13,<8.5.1+ 1 more
- (no CPE)range: >=7.10.13,<8.5.1
- (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
Root cause
"Missing cross-check between the `rc_rid` query parameter and the file's stored `rid` in the livechat file authorization path allows cross-room file access."
Attack vector
An unauthenticated attacker can enumerate all uploaded files by guessing or brute-forcing sequential MongoDB ObjectIDs as `:fileId` in the `/file-upload/:fileId/:name` endpoint. The endpoint accepts any `:name` value and, when `rc_room_type=l` is supplied, authorizes the request solely based on the livechat room credentials (`rc_rid` + `rc_token`) without verifying that the file actually belongs to that room. This allows an attacker who possesses a valid visitor token for any room to download files from any other room, and even an attacker without any valid token can attempt to guess file IDs since the authorization check is bypassed when the room check is insufficient.
Affected code
The vulnerability is in the file-upload authorization path in `apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts` (and the corresponding production code it tests). The `requestCanAccessFiles` function authorizes livechat file downloads using `rc_room_type=l` with `rc_rid` and `rc_token` query parameters, but it does not verify that the `rc_rid` matches the `rid` stored on the requested file record. Additionally, `:fileId` is a predictable sequential MongoDB ObjectID and `:name` is not validated, enabling unauthenticated enumeration of all uploaded files.
What the fix does
The patch adds a new test suite `livechat room-based authorization (rc_room_type=l)` to `FileUpload.spec.ts` that validates the fix. The key test case (`should deny access when livechat credentials are valid but file belongs to a different room`) confirms that the authorization logic now compares the `rc_rid` query parameter against the file's stored `rid` field. If they do not match, access is denied even if the livechat token is valid. The patch also introduces a `getRequestUserId` test suite and a `UserDataFiles.onRead` test suite to strengthen authentication checks for user data file exports, ensuring that only the file owner can access their own exports.
Preconditions
- configThe server must have `FileUpload_ProtectFiles` enabled (the default in affected versions).
- authThe attacker needs a valid livechat visitor token (`rc_token`) for any room, or can attempt to guess sequential MongoDB ObjectIDs without any authentication.
- networkThe attacker must be able to send HTTP GET requests to the `/file-upload/:fileId/:name` endpoint.
- inputThe attacker must guess or enumerate the sequential MongoDB ObjectID used as `:fileId`.
Generated on Jun 17, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.