Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bump-patch-1781187995605.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Bump @rocket.chat/meteor version.
5 changes: 5 additions & 0 deletions .changeset/fast-apes-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Escapes HTML tags in exported data
5 changes: 5 additions & 0 deletions .changeset/rich-bananas-shine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates)
5 changes: 5 additions & 0 deletions .changeset/salty-suits-strive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes missing permission check on the `POST /api/v1/fingerprint` endpoint
7 changes: 7 additions & 0 deletions .changeset/slick-hats-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@rocket.chat/federation-matrix': patch
'@rocket.chat/core-typings': patch
'@rocket.chat/meteor': patch
---

Fixes an issue where `description` was incorrectly being used as alternative text for image attachments
59 changes: 54 additions & 5 deletions .github/actions/update-version-durability/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .github/actions/update-version-durability/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"dependencies": {
"@actions/core": "^1.10.1",
"@octokit/rest": "^21.0.0",
"axios": "^1.7.2",
"axios": "^1.16.0",
"beauty-html": "^1.3.1",
"colors": "^1.4.0",
"diff": "^5.1.0",
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/app/api/server/v1/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isMeteorCall,
meSuccessResponseSchema,
validateUnauthorizedErrorResponse,
validateForbiddenErrorResponse,
validateBadRequestErrorResponse,
} from '@rocket.chat/rest-typings';
import type { MeApiSuccessResponse } from '@rocket.chat/rest-typings';
Expand Down Expand Up @@ -795,10 +796,12 @@ API.v1.post(
'fingerprint',
{
authRequired: true,
permissionsRequired: ['manage-cloud'],
body: isFingerprintProps,
response: {
200: fingerprintResponseSchema,
401: validateUnauthorizedErrorResponse,
403: validateForbiddenErrorResponse,
400: validateBadRequestErrorResponse,
},
},
Expand Down
133 changes: 133 additions & 0 deletions apps/meteor/app/apple/lib/handleIdentityToken.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Comment on lines +32 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test case may not be testing audience validation as intended.

This test uses 'dummySignature' without mocking the JWKS endpoint. The handleIdentityToken function will fail at signature verification before reaching audience validation, so the error "identityToken is not a valid Apple JWT or has expired" is thrown due to signature failure, not audience mismatch.

To properly test audience validation, the JWKS mock should be set up to return the test public key so signature verification passes, then audience validation fails.

🧪 Proposed fix to properly test audience validation
 	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({
+		const headerB64 = toBase64Url({ alg: 'RS256', kid: 'mock-key-id' });
+		const payloadB64 = toBase64Url({
 			iss: 'https://appleid.apple.com',
 			aud: 'wrong.client.id',
 			exp: Math.floor(Date.now() / 1000) + 3600,
 			sub: 'user123',
 		});

-		const mockToken = `${header}.${payload}.dummySignature`;
+		const signatureBytes = sign('RSA-SHA256', Buffer.from(`${headerB64}.${payloadB64}`), privateKey);
+		const signatureB64 = signatureBytes.toString('base64url');
+		const mockToken = `${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' } }),
+		);

 		await expect(handleIdentityToken(mockToken, mockClientId)).rejects.toThrow('identityToken is not a valid Apple JWT or has expired');
 	});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/meteor/app/apple/lib/handleIdentityToken.spec.ts` around lines 32 - 44,
The test currently fails signature verification before audience checks; to fix
it, update the test for handleIdentityToken so the JWKS endpoint is mocked to
return a public key whose kid matches 'mock-key-id' and ensure the token is
signed with the corresponding private key (instead of using 'dummySignature') so
signature verification passes, then assert that handleIdentityToken rejects due
to the wrong aud (mockClientId) — reference the mockToken construction,
toBase64Url usage, and handleIdentityToken invocation when locating where to add
the JWKS mock and valid signature generation.


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');
});
});
Loading
Loading