From 58f5db93afbb53a942dc82b44ec14b7d9d1aecc6 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Thu, 11 Jun 2026 16:17:29 +0200 Subject: [PATCH 1/2] fix: imported fixes 06-11-26 (#40889) Co-authored-by: Matheus Cardoso --- .changeset/rich-bananas-shine.md | 5 + .../app/apple/lib/handleIdentityToken.spec.ts | 133 +++++++++++++ .../app/apple/lib/handleIdentityToken.ts | 171 ++++++++++++++--- .../app/apple/server/AppleCustomOAuth.ts | 5 +- .../apple/server/appleOauthRegisterService.ts | 111 +++++++---- .../app/apple/server/loginHandler.spec.ts | 124 ++++++++++++ apps/meteor/app/apple/server/loginHandler.ts | 15 +- .../file-upload/server/lib/FileUpload.spec.ts | 180 ++++++++++++++++++ .../app/file-upload/server/lib/FileUpload.ts | 31 ++- apps/meteor/definition/IRoomTypeConfig.ts | 3 +- .../externals/meteor/accounts-base.d.ts | 2 +- apps/meteor/jest.config.ts | 3 + .../server/lib/rooms/roomCoordinator.ts | 4 +- .../server/lib/rooms/roomTypes/livechat.ts | 12 +- .../server/methods/deleteFileMessage.ts | 33 +++- .../server/methods/deleteFileMessage.spec.ts | 124 ++++++++++++ 16 files changed, 874 insertions(+), 82 deletions(-) create mode 100644 .changeset/rich-bananas-shine.md create mode 100644 apps/meteor/app/apple/lib/handleIdentityToken.spec.ts create mode 100644 apps/meteor/app/apple/server/loginHandler.spec.ts create mode 100644 apps/meteor/tests/unit/server/methods/deleteFileMessage.spec.ts 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/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 056777eb11362..ab4a9378b906d 100644 --- a/apps/meteor/app/apple/lib/handleIdentityToken.ts +++ b/apps/meteor/app/apple/lib/handleIdentityToken.ts @@ -1,48 +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' }); - 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/file-upload/server/lib/FileUpload.spec.ts b/apps/meteor/app/file-upload/server/lib/FileUpload.spec.ts index 21a81b4564086..a626879417c00 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 7a6ae71ce6d20..969d50e582a87 100644 --- a/apps/meteor/app/file-upload/server/lib/FileUpload.ts +++ b/apps/meteor/app/file-upload/server/lib/FileUpload.ts @@ -95,7 +95,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; } @@ -435,6 +437,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; @@ -456,7 +483,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/definition/IRoomTypeConfig.ts b/apps/meteor/definition/IRoomTypeConfig.ts index 0a8e7161c6f06..1093376cfd30c 100644 --- a/apps/meteor/definition/IRoomTypeConfig.ts +++ b/apps/meteor/definition/IRoomTypeConfig.ts @@ -8,6 +8,7 @@ import type { 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'; @@ -97,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 875b3cb5291e6..1b3d1a136bbe8 100644 --- a/apps/meteor/definition/externals/meteor/accounts-base.d.ts +++ b/apps/meteor/definition/externals/meteor/accounts-base.d.ts @@ -39,7 +39,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/jest.config.ts b/apps/meteor/jest.config.ts index 5dd247ce4406e..3227dfb2c1c88 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -44,6 +44,9 @@ export default { '/app/api/server/**.spec.ts', '/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/server/lib/rooms/roomCoordinator.ts b/apps/meteor/server/lib/rooms/roomCoordinator.ts index 54e35aafdde50..5ff40dc6ab233 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, IReadReceipt, ValueOf, AtLeast } from '@rocket.chat/core-typings'; +import type { IRoom, RoomType, IUser, IMessage, IReadReceipt, 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 7a00f75796c02..3584b6bc8a2d9 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/tests/unit/server/methods/deleteFileMessage.spec.ts b/apps/meteor/tests/unit/server/methods/deleteFileMessage.spec.ts new file mode 100644 index 0000000000000..68649f5d5aeef --- /dev/null +++ b/apps/meteor/tests/unit/server/methods/deleteFileMessage.spec.ts @@ -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; + }); +}); From 168cdd0f880025672f01362e003bcd36329d82d2 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Thu, 11 Jun 2026 18:24:33 +0200 Subject: [PATCH 2/2] Fix lint, typechecks, and adapt fixes --- apps/meteor/app/apple/lib/handleIdentityToken.ts | 2 -- apps/meteor/server/services/upload/service.ts | 16 +++++++++++++++- .../core-services/src/types/IUploadService.ts | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/apple/lib/handleIdentityToken.ts b/apps/meteor/app/apple/lib/handleIdentityToken.ts index ab4a9378b906d..8e4e5b561b3d9 100644 --- a/apps/meteor/app/apple/lib/handleIdentityToken.ts +++ b/apps/meteor/app/apple/lib/handleIdentityToken.ts @@ -38,8 +38,6 @@ async function getApplePublicKeys(forceRefresh = false): Promise { 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) { diff --git a/apps/meteor/server/services/upload/service.ts b/apps/meteor/server/services/upload/service.ts index 7333918786e96..43e0b1ad4c37a 100644 --- a/apps/meteor/server/services/upload/service.ts +++ b/apps/meteor/server/services/upload/service.ts @@ -1,7 +1,8 @@ import { ServiceClassInternal } from '@rocket.chat/core-services'; import type { ISendFileLivechatMessageParams, ISendFileMessageParams, IUploadFileParams, IUploadService } from '@rocket.chat/core-services'; -import type { IUpload, IUser, FilesAndAttachments } from '@rocket.chat/core-typings'; +import type { IUpload, IUser, FilesAndAttachments, IMessage } from '@rocket.chat/core-typings'; +import { canDeleteMessageAsync } from '../../../app/authorization/server/functions/canDeleteMessage'; import { FileUpload } from '../../../app/file-upload/server'; import { parseFileIntoMessageAttachments, sendFileMessage } from '../../../app/file-upload/server/methods/sendFileMessage'; import { sendFileLivechatMessage } from '../../../app/livechat/server/methods/sendFileLivechatMessage'; @@ -38,4 +39,17 @@ export class UploadService extends ServiceClassInternal implements IUploadServic async parseFileIntoMessageAttachments(file: Partial, roomId: string, user: IUser): Promise { return parseFileIntoMessageAttachments(file, roomId, user); } + + async canDeleteFile(user: IUser, file: IUpload, msg: IMessage | null): Promise { + if (msg) { + return canDeleteMessageAsync(user._id, msg); + } + + if (!file.userId || !file.rid) { + return false; + } + + const msgForValidation = { u: { _id: file.userId }, ts: file.uploadedAt ?? new Date(0), rid: file.rid }; + return canDeleteMessageAsync(user._id, msgForValidation); + } } diff --git a/packages/core-services/src/types/IUploadService.ts b/packages/core-services/src/types/IUploadService.ts index 9e96791207e12..72931fe649b53 100644 --- a/packages/core-services/src/types/IUploadService.ts +++ b/packages/core-services/src/types/IUploadService.ts @@ -27,4 +27,5 @@ export interface IUploadService { getFileBuffer({ file }: { file: IUpload }): Promise; extractMetadata(file: IUpload): Promise<{ height?: number; width?: number; format?: string }>; parseFileIntoMessageAttachments(file: Partial, roomId: string, user: IUser): Promise; + canDeleteFile(user: IUser, file: IUpload, msg: IMessage | null): Promise; }