diff --git a/.changeset/heavy-lions-attend.md b/.changeset/heavy-lions-attend.md new file mode 100644 index 0000000000000..169f319e5aa8b --- /dev/null +++ b/.changeset/heavy-lions-attend.md @@ -0,0 +1,15 @@ +--- +'@rocket.chat/federation-matrix': patch +'@rocket.chat/meteor': patch +--- + +Fixes federation endpoints rejecting requests that are valid per the Matrix specification: + +- `publicRooms` (GET and POST) required params/fields the spec marks optional +- `query/profile` rejected spec-valid profile fields such as `m.tz` +- `get_missing_events` required the optional `limit` field and bounded it +- `make_join` returned 500 instead of 400 `M_INCOMPATIBLE_ROOM_VERSION` for unsupported room versions +- `backfill` rejected spec-valid `limit` values +- `send` rejected an entire transaction when a single PDU didn't match a fixed event shape, instead of reporting failures per PDU + +Also links every federation endpoint to its definition in the Matrix specification. diff --git a/ee/packages/federation-matrix/src/api/.well-known/server.ts b/ee/packages/federation-matrix/src/api/.well-known/server.ts index 262afa5d3249e..10ae86f1b2e73 100644 --- a/ee/packages/federation-matrix/src/api/.well-known/server.ts +++ b/ee/packages/federation-matrix/src/api/.well-known/server.ts @@ -1,8 +1,8 @@ -import { Router } from '@rocket.chat/http-router'; -import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; import { createHash } from 'node:crypto'; import { federationSDK } from '@rocket.chat/federation-sdk'; +import { Router } from '@rocket.chat/http-router'; +import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; const WellKnownServerResponseSchema = { type: 'object', @@ -20,6 +20,8 @@ const isWellKnownServerResponseProps = ajv.compile(WellKnownServerResponseSchema // TODO: After changing the domain setting this route is still reporting the old domain until the server is restarted // TODO: this is wrong, is siteurl !== domain this path should return 404. this path is to discover the final address, domain being the "proxy" and siteurl the final destination, if domain is different, well-known should be served there, not here. export const getWellKnownRoutes = () => { + // GET /.well-known/matrix/server + // https://spec.matrix.org/v1.19/server-server-api/#getwell-knownmatrixserver return new Router('/matrix').get( '/server', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.spec.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.spec.ts new file mode 100644 index 0000000000000..53180a6b405b6 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.spec.ts @@ -0,0 +1,144 @@ +import { FederationMatrix } from '@rocket.chat/core-services'; +import { federationSDK } from '@rocket.chat/federation-sdk'; +import { Users } from '@rocket.chat/models'; + +import { getMatrixInviteRoutes } from './invite'; + +jest.mock('@rocket.chat/core-services', () => ({ + FederationMatrix: { + canUserAccessFederation: jest.fn(), + }, +})); + +jest.mock('@rocket.chat/models', () => ({ + Users: { + findOneByUsername: jest.fn(), + }, +})); + +jest.mock('@rocket.chat/federation-sdk', () => ({ + federationSDK: { + verifyRequestSignature: jest.fn(), + getConfig: jest.fn(), + processInvite: jest.fn(), + }, + NotAllowedError: class NotAllowedError extends Error {}, + errCodes: { + M_UNAUTHORIZED: { errcode: 'M_UNAUTHORIZED', error: 'Unauthorized', status: 401 }, + M_UNKNOWN: { errcode: 'M_UNKNOWN', error: 'Unknown error' }, + }, +})); + +const mockVerifyRequestSignature = federationSDK.verifyRequestSignature as jest.MockedFunction; +const mockGetConfig = federationSDK.getConfig as jest.MockedFunction; +const mockProcessInvite = federationSDK.processInvite as jest.MockedFunction; +const mockFindOneByUsername = Users.findOneByUsername as jest.MockedFunction; +const mockCanUserAccessFederation = FederationMatrix.canUserAccessFederation as jest.MockedFunction< + typeof FederationMatrix.canUserAccessFederation +>; + +const OUR_SERVER_NAME = 'rocketchat.local'; + +const buildInviteEvent = (stateKey: string) => ({ + type: 'm.room.member', + state_key: stateKey, + sender: '@attacker:attacker.com', + room_id: '!room:attacker.com', + origin_server_ts: 1600000000000, + depth: 1, + prev_events: [], + auth_events: [], + content: { membership: 'invite' }, +}); + +const sendInvite = async (event: unknown) => + getMatrixInviteRoutes() + .getHonoRouter() + .request('/v2/invite/!room:attacker.com/$event', { + method: 'PUT', + headers: { + 'Authorization': 'X-Matrix origin="attacker.com"', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + room_version: '10', + event, + invite_room_state: [{ type: 'm.room.create', state_key: '', content: { creator: '@attacker:attacker.com' } }], + }), + }); + +describe('PUT /_matrix/federation/v2/invite/:roomId/:eventId', () => { + beforeEach(() => { + jest.clearAllMocks(); + + mockVerifyRequestSignature.mockResolvedValue({ origin: 'attacker.com' } as any); + mockGetConfig.mockImplementation((key) => (key === 'serverName' ? OUR_SERVER_NAME : undefined) as any); + mockFindOneByUsername.mockResolvedValue({ _id: 'victimId', username: 'victim' } as any); + mockCanUserAccessFederation.mockResolvedValue(true); + mockProcessInvite.mockImplementation(async (event: any) => ({ event }) as any); + }); + + it('should reject an invite whose state_key belongs to another homeserver', async () => { + const response = await sendInvite(buildInviteEvent(`@victim:attacker.com`)); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + errcode: 'M_UNKNOWN', + error: 'The invite event must be for a user of this server', + }); + expect(mockFindOneByUsername).not.toHaveBeenCalled(); + expect(mockProcessInvite).not.toHaveBeenCalled(); + }); + + it('should reject an invite whose state_key is not a valid user ID', async () => { + const response = await sendInvite(buildInviteEvent('victim')); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + errcode: 'M_UNKNOWN', + error: 'The invite event state_key is not a valid user ID', + }); + expect(mockFindOneByUsername).not.toHaveBeenCalled(); + expect(mockProcessInvite).not.toHaveBeenCalled(); + }); + + it('should reject an invite whose state_key has an empty localpart', async () => { + const response = await sendInvite(buildInviteEvent(`@:${OUR_SERVER_NAME}`)); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + errcode: 'M_UNKNOWN', + error: 'The invite event state_key is not a valid user ID', + }); + expect(mockFindOneByUsername).not.toHaveBeenCalled(); + expect(mockProcessInvite).not.toHaveBeenCalled(); + }); + + it('should reject an invite addressed to a user that does not exist on this server', async () => { + mockFindOneByUsername.mockResolvedValue(null); + + const response = await sendInvite(buildInviteEvent(`@ghost:${OUR_SERVER_NAME}`)); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + errcode: 'M_FORBIDDEN', + error: 'User does not have permission to access federation', + }); + expect(mockProcessInvite).not.toHaveBeenCalled(); + }); + + it('should accept a state_key whose localpart contains characters the spec allows', async () => { + const response = await sendInvite(buildInviteEvent(`@victim+1/2:${OUR_SERVER_NAME}`)); + + expect(response.status).toBe(200); + expect(mockFindOneByUsername).toHaveBeenCalledWith('victim+1/2'); + }); + + it('should process an invite addressed to a user of this server', async () => { + const response = await sendInvite(buildInviteEvent(`@victim:${OUR_SERVER_NAME}`)); + + expect(response.status).toBe(200); + expect(mockFindOneByUsername).toHaveBeenCalledWith('victim'); + expect(mockProcessInvite).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index b63edfdeefc0a..10adb621a14f5 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -4,6 +4,7 @@ import { Router } from '@rocket.chat/http-router'; import { Users } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { getUsernameServername } from '../../helpers/getUsernameServername'; import { logger } from '../logger'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; @@ -130,10 +131,26 @@ const ProcessInviteResponseSchema = { const isProcessInviteResponseProps = ajv.compile(ProcessInviteResponseSchema); export const getMatrixInviteRoutes = () => { + // PUT /_matrix/federation/v2/invite/{roomId}/{eventId} + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2inviteroomideventid return new Router('/federation').put( '/v2/invite/:roomId/:eventId', { - body: ajv.compile({ type: 'object' }), // TODO: add schema from room package. + // TODO: add schema from room package. `event` is a PDU whose format varies by room + // version, so it stays unconstrained here; room_version and event are required per spec. + body: ajv.compile({ + type: 'object', + properties: { + room_version: { type: 'string' }, + event: { type: 'object' }, + invite_room_state: { + type: 'array', + items: { type: 'object' }, + nullable: true, + }, + }, + required: ['room_version', 'event'], + }), params: isProcessInviteParamsProps, response: { 200: isProcessInviteResponseProps, @@ -146,30 +163,72 @@ export const getMatrixInviteRoutes = () => { const { roomId, eventId } = c.req.param(); const { event, room_version: roomVersion, invite_room_state: strippedStateEvents } = await c.req.json(); - const userToCheck = event.state_key as string; + const userToCheck = event.state_key; - if (!userToCheck) { - throw new Error('join event has missing state key, unable to determine user to join'); + // matches Synapse: the PDU itself stays unvalidated, but an event that is not an invite + // membership event cannot be processed, so reject it instead of failing later + if (typeof userToCheck !== 'string' || !userToCheck || event.type !== 'm.room.member' || event.content?.membership !== 'invite') { + return { + body: { + errcode: 'M_UNKNOWN', + error: 'The event was not an m.room.member invite event', + }, + statusCode: 400, + }; } + // spec: servers SHOULD return M_INVALID_PARAM if m.room.create is missing from invite_room_state if (!strippedStateEvents?.some((e: any) => e.type === 'm.room.create')) { return { body: { - errcode: 'M_MISSING_PARAM', + errcode: 'M_INVALID_PARAM', error: 'Missing invite_room_state: m.room.create event is required', }, statusCode: 400, }; } - const [username /* domain */] = userToCheck.split(':'); + // spec grammar is `@localpart:server_name`, where localpart is non-empty. deliberately + // not `validateFederatedUsername`, which is stricter than the spec and would reject + // legal localparts containing `/` or `+` + if (!/^@[A-Za-z0-9_=/.+-]+:.+$/.test(userToCheck)) { + return { + body: { + errcode: 'M_UNKNOWN', + error: 'The invite event state_key is not a valid user ID', + }, + statusCode: 400, + }; + } - // TODO: check domain + // an invite addressed to a user of another homeserver would create a local subscription + // that can never be accepted, since the remote server never invited our copy of that user + const [username, , isLocalUser] = getUsernameServername(userToCheck, federationSDK.getConfig('serverName')); - const ourUser = await Users.findOneByUsername(username.slice(1)); + if (!isLocalUser) { + return { + body: { + errcode: 'M_UNKNOWN', + error: 'The invite event must be for a user of this server', + }, + statusCode: 400, + }; + } + const ourUser = await Users.findOneByUsername(username); + + // same response as the federation permission check below, so an unauthorized remote + // server cannot use the invite endpoint to probe which local users exist if (!ourUser) { - throw new Error('user not found not processing invite'); + logger.info({ msg: 'Invite for unknown local user, rejecting invite to room', userId: userToCheck, roomId }); + + return { + body: { + errcode: 'M_FORBIDDEN', + error: 'User does not have permission to access federation', + }, + statusCode: 403, + }; } // check federation permission before processing the invite diff --git a/ee/packages/federation-matrix/src/api/_matrix/key/server.ts b/ee/packages/federation-matrix/src/api/_matrix/key/server.ts index 89dc236f65606..541a622671ec0 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/key/server.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/key/server.ts @@ -33,6 +33,8 @@ const ServerKeyResponseSchema = { const isServerKeyResponseProps = ajv.compile(ServerKeyResponseSchema); export const getKeyServerRoutes = () => { + // GET /_matrix/key/v2/server + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixkeyv2server return new Router('/key').get( '/v2/server', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts index 911d2be81e78d..f37336a80b342 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/make-leave.ts @@ -56,6 +56,8 @@ const isMakeLeaveErrorResponseProps = ajv.compile({ }); export const getMatrixMakeLeaveRoutes = () => { + // GET /_matrix/federation/v1/make_leave/{roomId}/{userId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_leaveroomiduserid return new Router('/federation').get( '/v1/make_leave/:roomId/:userId', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index f9ed40a9ebc2a..1f71098e6cb29 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -74,76 +74,82 @@ async function getMediaFile(mediaId: string, serverName: string): Promise<{ file } export const getMatrixMediaRoutes = () => { - return new Router('/federation') - .get( - '/v1/media/download/:mediaId', - { - params: isMediaDownloadParamsProps, - response: { - 200: isBufferResponseProps, - 401: isErrorResponseProps, - 403: isErrorResponseProps, - 404: isErrorResponseProps, - 429: isErrorResponseProps, - 500: isErrorResponseProps, + return ( + new Router('/federation') + // GET /_matrix/federation/v1/media/download/{mediaId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediadownloadmediaid + .get( + '/v1/media/download/:mediaId', + { + params: isMediaDownloadParamsProps, + response: { + 200: isBufferResponseProps, + 401: isErrorResponseProps, + 403: isErrorResponseProps, + 404: isErrorResponseProps, + 429: isErrorResponseProps, + 500: isErrorResponseProps, + }, + tags: ['Federation', 'Media'], }, - tags: ['Federation', 'Media'], - }, - canAccessResourceMiddleware('media'), - async (c) => { - try { - const { mediaId } = c.req.param(); - const serverName = federationSDK.getConfig('serverName'); - - // TODO: Add file streaming support - const result = await getMediaFile(mediaId, serverName); - if (!result) { + canAccessResourceMiddleware('media'), + async (c) => { + try { + const { mediaId } = c.req.param(); + const serverName = federationSDK.getConfig('serverName'); + + // TODO: Add file streaming support + const result = await getMediaFile(mediaId, serverName); + if (!result) { + return { + statusCode: 404, + body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + }; + } + + const { file, buffer } = result; + + const mimeType = file.type || 'application/octet-stream'; + const fileName = file.name || mediaId; + + const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); + + return { + statusCode: 200, + headers: { + ...SECURITY_HEADERS, + 'content-type': multipartResponse.contentType, + 'content-length': String(multipartResponse.body.length), + }, + body: multipartResponse.body, + }; + } catch (error) { return { - statusCode: 404, - body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + statusCode: 500, + body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, }; } - - const { file, buffer } = result; - - const mimeType = file.type || 'application/octet-stream'; - const fileName = file.name || mediaId; - - const multipartResponse = createMultipartResponse(buffer, mimeType, fileName); - - return { - statusCode: 200, - headers: { - ...SECURITY_HEADERS, - 'content-type': multipartResponse.contentType, - 'content-length': String(multipartResponse.body.length), - }, - body: multipartResponse.body, - }; - } catch (error) { - return { - statusCode: 500, - body: { errcode: 'M_UNKNOWN', error: 'Internal server error' }, - }; - } - }, - ) - .get( - '/v1/media/thumbnail/:mediaId', - { - params: isMediaDownloadParamsProps, - response: { - 404: isErrorResponseProps, }, - tags: ['Federation', 'Media'], - }, - canAccessResourceMiddleware('media'), - async (_c) => ({ - statusCode: 404, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on the homeserver side', + ) + // GET /_matrix/federation/v1/media/thumbnail/{mediaId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1mediathumbnailmediaid + .get( + '/v1/media/thumbnail/:mediaId', + { + params: isMediaDownloadParamsProps, + response: { + 404: isErrorResponseProps, + }, + tags: ['Federation', 'Media'], }, - }), - ); + canAccessResourceMiddleware('media'), + async (_c) => ({ + statusCode: 404, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on the homeserver side', + }, + }), + ) + ); }; diff --git a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts index c9603c03125cc..3a8c9cffe6782 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts @@ -33,14 +33,14 @@ const QueryProfileQuerySchema = { properties: { user_id: UsernameSchema, field: { + // open string, not an enum: the spec defines displayname, avatar_url and m.tz, + // and servers MAY allow arbitrary additional profile fields type: 'string', - enum: ['displayname', 'avatar_url'], description: 'Profile field to query', nullable: true, }, }, required: ['user_id'], - additionalProperties: false, }; const isQueryProfileQueryProps = ajvQuery.compile(QueryProfileQuerySchema); @@ -157,20 +157,13 @@ const MakeJoinQuerySchema = { type: 'object', properties: { ver: { - anyOf: [ - { - type: 'string', - description: 'Supported room version', - }, - { - type: 'array', - items: { - type: 'string', - }, - minItems: 0, - description: 'Supported room versions', - }, - ], + // a string branch here would be redundant: ajvQuery coerces a single `?ver=` into a + // one-element array, and in a `oneOf` both branches would match and fail validation + type: 'array', + items: { + type: 'string', + }, + description: 'Room versions supported by the sending server', }, }, }; @@ -261,6 +254,26 @@ const MakeJoinResponseSchema = { const isMakeJoinResponseProps = ajv.compile(MakeJoinResponseSchema); +const MakeJoinIncompatibleVersionResponseSchema = { + type: 'object', + properties: { + errcode: { + type: 'string', + const: 'M_INCOMPATIBLE_ROOM_VERSION', + }, + error: { + type: 'string', + }, + room_version: { + type: 'string', + description: 'The version of the room', + }, + }, + required: ['errcode', 'error', 'room_version'], +}; + +const isMakeJoinIncompatibleVersionResponseProps = ajv.compile(MakeJoinIncompatibleVersionResponseSchema); + const GetMissingEventsParamsSchema = { type: 'object', properties: { @@ -289,13 +302,18 @@ const GetMissingEventsBodySchema = { description: 'Latest events', }, limit: { - type: 'number', - minimum: 1, - maximum: 100, + // optional per spec (defaults to 10) and unbounded; the handler applies the default and a cap + type: 'integer', description: 'Maximum number of events to return', + nullable: true, + }, + min_depth: { + type: 'integer', + description: 'Minimum depth of events to retrieve', + nullable: true, }, }, - required: ['earliest_events', 'latest_events', 'limit'], + required: ['earliest_events', 'latest_events'], }; const isGetMissingEventsBodyProps = ajv.compile(GetMissingEventsBodySchema); @@ -347,174 +365,219 @@ const EventAuthResponseSchema = { const isEventAuthResponseProps = ajv.compile(EventAuthResponseSchema); export const getMatrixProfilesRoutes = () => { - return new Router('/federation') - .use(isAuthenticatedMiddleware()) - .get( - '/v1/query/profile', - { - query: isQueryProfileQueryProps, - response: { - 200: isQueryProfileResponseProps, + return ( + new Router('/federation') + .use(isAuthenticatedMiddleware()) + // GET /_matrix/federation/v1/query/profile + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1queryprofile + .get( + '/v1/query/profile', + { + query: isQueryProfileQueryProps, + response: { + 200: isQueryProfileResponseProps, + }, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - const { user_id: userId, field } = c.req.query(); + async (c) => { + const { user_id: userId, field } = c.req.query(); + + const response = await federationSDK.queryProfile(userId); + + if (!response) { + return { + body: { + errcode: 'M_NOT_FOUND', + error: `User ${userId} not found`, + }, + statusCode: 404, + }; + } + + if (field) { + return { + body: { + [field]: response[field as 'displayname' | 'avatar_url'] || null, + }, + statusCode: 200, + }; + } - const response = await federationSDK.queryProfile(userId); - - if (!response) { return { body: { - errcode: 'M_NOT_FOUND', - error: `User ${userId} not found`, + displayname: response.displayname, + avatar_url: response.avatar_url, }, - statusCode: 404, + statusCode: 200, }; - } + }, + ) + // POST /_matrix/federation/v1/user/keys/query + // https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1userkeysquery + .post( + '/v1/user/keys/query', + { + body: isQueryKeysBodyProps, + response: { + 200: isQueryKeysResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const body = await c.req.json(); + + const response = await federationSDK.queryKeys(body.device_keys); - if (field) { + return { + body: response, + statusCode: 200, + }; + }, + ) + // GET /_matrix/federation/v1/user/devices/{userId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1userdevicesuserid + .get( + '/v1/user/devices/:userId', + { + params: isGetDevicesParamsProps, + response: { + 200: isGetDevicesResponseProps, + }, + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { return { body: { - [field]: response[field as 'displayname' | 'avatar_url'] || null, + devices: [], + stream_id: 0, + user_id: c.req.param('userId'), }, statusCode: 200, }; - } - - return { - body: { - displayname: response.displayname, - avatar_url: response.avatar_url, + }, + ) + // GET /_matrix/federation/v1/make_join/{roomId}/{userId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1make_joinroomiduserid + .get( + '/v1/make_join/:roomId/:userId', + { + params: isMakeJoinParamsProps, + query: isMakeJoinQueryProps, + response: { + 200: isMakeJoinResponseProps, + 400: isMakeJoinIncompatibleVersionResponseProps, }, - statusCode: 200, - }; - }, - ) - .post( - '/v1/user/keys/query', - { - body: isQueryKeysBodyProps, - response: { - 200: isQueryKeysResponseProps, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - const body = await c.req.json(); - - const response = await federationSDK.queryKeys(body.device_keys); - - return { - body: response, - statusCode: 200, - }; - }, - ) - .get( - '/v1/user/devices/:userId', - { - params: isGetDevicesParamsProps, - response: { - 200: isGetDevicesResponseProps, + canAccessResourceMiddleware('room'), + async (c) => { + const { roomId, userId } = c.req.param(); + const url = new URL(c.req.url); + const verParams = url.searchParams.getAll('ver'); + + try { + const response = await federationSDK.makeJoin( + roomIdSchema.parse(roomId), + userIdSchema.parse(userId), + // spec: "The room versions the sending server has support for. Defaults to [1]." + verParams.length > 0 ? (verParams as RoomVersion[]) : ['1'], + ); + + return { + body: { + room_version: response.room_version, + event: response.event, + }, + statusCode: 200, + }; + } catch (error) { + // the SDK throws when the room's version is not in the requested `ver` list + const incompatibleVersion = error instanceof Error && error.message.match(/^Unsupported room version: (.+)$/); + if (incompatibleVersion) { + return { + body: { + errcode: 'M_INCOMPATIBLE_ROOM_VERSION', + error: 'Your homeserver does not support the features required to join this room', + room_version: incompatibleVersion[1], + }, + statusCode: 400, + }; + } + + throw error; + } }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - return { - body: { - devices: [], - stream_id: 0, - user_id: c.req.param('userId'), + ) + // POST /_matrix/federation/v1/get_missing_events/{roomId} + // https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1get_missing_eventsroomid + .post( + '/v1/get_missing_events/:roomId', + { + params: isGetMissingEventsParamsProps, + body: isGetMissingEventsBodyProps, + response: { + 200: isGetMissingEventsResponseProps, }, - statusCode: 200, - }; - }, - ) - .get( - '/v1/make_join/:roomId/:userId', - { - params: isMakeJoinParamsProps, - query: isMakeJoinQueryProps, - response: { - 200: isMakeJoinResponseProps, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - canAccessResourceMiddleware('room'), - async (c) => { - const { roomId, userId } = c.req.param(); - const url = new URL(c.req.url); - const verParams = url.searchParams.getAll('ver'); - - const response = await federationSDK.makeJoin( - roomIdSchema.parse(roomId), - userIdSchema.parse(userId), - verParams.length > 0 ? (verParams as RoomVersion[]) : ['1'], - ); - - return { - body: { - room_version: response.room_version, - event: response.event, - }, - statusCode: 200, - }; - }, - ) - .post( - '/v1/get_missing_events/:roomId', - { - params: isGetMissingEventsParamsProps, - body: isGetMissingEventsBodyProps, - response: { - 200: isGetMissingEventsResponseProps, + canAccessResourceMiddleware('room'), + async (c) => { + const { roomId } = c.req.param(); + const body = await c.req.json(); + + const limit = Math.min(body.limit ?? 10, 100); + const minDepth = body.min_depth ?? 0; + + // this will be handled by the federation-sdk on next versions so this can be removed + if (limit < 1) { + return { + body: { events: [] }, + statusCode: 200, + }; + } + + const response = await federationSDK.getMissingEvents( + roomIdSchema.parse(roomId), + body.earliest_events, + body.latest_events, + limit, + minDepth, + ); + + return { + body: response, + statusCode: 200, + }; }, - tags: ['Federation'], - license: ['federation'], - }, - canAccessResourceMiddleware('room'), - async (c) => { - const { roomId } = c.req.param(); - const body = await c.req.json(); - - const response = await federationSDK.getMissingEvents( - roomIdSchema.parse(roomId), - body.earliest_events, - body.latest_events, - body.limit, - ); - - return { - body: response, - statusCode: 200, - }; - }, - ) - .get( - '/v1/event_auth/:roomId/:eventId', - { - params: isEventAuthParamsProps, - response: { - 200: isEventAuthResponseProps, + ) + // GET /_matrix/federation/v1/event_auth/{roomId}/{eventId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1event_authroomideventid + .get( + '/v1/event_auth/:roomId/:eventId', + { + params: isEventAuthParamsProps, + response: { + 200: isEventAuthResponseProps, + }, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - canAccessResourceMiddleware('room'), - async (c) => { - const { roomId, eventId } = c.req.param(); + canAccessResourceMiddleware('room'), + async (c) => { + const { roomId, eventId } = c.req.param(); - const response = await federationSDK.eventAuth(roomIdSchema.parse(roomId), eventIdSchema.parse(eventId)); + const response = await federationSDK.eventAuth(roomIdSchema.parse(roomId), eventIdSchema.parse(eventId)); - return { - body: response, - statusCode: 200, - }; - }, - ); + return { + body: response, + statusCode: 200, + }; + }, + ) + ); }; diff --git a/ee/packages/federation-matrix/src/api/_matrix/rooms.ts b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts index f494506b38009..38aaa52301f24 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/rooms.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts @@ -4,19 +4,29 @@ import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; +// All query params are optional per spec. const PublicRoomsQuerySchema = { type: 'object', properties: { + limit: { + // spec default is 0, meaning no limit; negatives are meaningless and Synapse rejects them + type: 'integer', + minimum: 0, + description: 'Maximum number of rooms to return', + }, + since: { + type: 'string', + description: 'Pagination token from a previous call', + }, include_all_networks: { type: 'boolean', description: 'Include all networks (ignored)', }, - limit: { - type: 'number', - description: 'Maximum number of rooms to return', + third_party_instance_id: { + type: 'string', + description: 'Specific third-party network to request (ignored)', }, }, - required: ['include_all_networks', 'limit'], }; const isPublicRoomsQueryProps = ajvQuery.compile(PublicRoomsQuerySchema); @@ -86,17 +96,29 @@ const PublicRoomsResponseSchema = { const isPublicRoomsResponseProps = ajv.compile(PublicRoomsResponseSchema); +// All body fields are optional per spec: "Options for which rooms to return, or empty object to use defaults." const PublicRoomsPostBodySchema = { type: 'object', properties: { - include_all_networks: { + limit: { + type: 'integer', + minimum: 0, + description: 'Maximum number of rooms to return', + nullable: true, + }, + since: { type: 'string', + description: 'Pagination token from a previous request', + nullable: true, + }, + include_all_networks: { + type: 'boolean', description: 'Include all networks (ignored)', nullable: true, }, - limit: { - type: 'number', - description: 'Maximum number of rooms to return', + third_party_instance_id: { + type: 'string', + description: 'Specific third-party network to request (ignored)', nullable: true, }, filter: { @@ -116,93 +138,99 @@ const PublicRoomsPostBodySchema = { nullable: true, }, }, + nullable: true, }, }, - required: ['filter'], }; const isPublicRoomsPostBodyProps = ajv.compile(PublicRoomsPostBodySchema); export const getMatrixRoomsRoutes = () => { - return new Router('/federation') - .use(isAuthenticatedMiddleware()) - .get( - '/v1/publicRooms', - { - query: isPublicRoomsQueryProps, - response: { - 200: isPublicRoomsResponseProps, - }, - tags: ['Federation'], - license: ['federation'], - }, - async () => { - const defaultObj = { - join_rule: 'public', - guest_can_join: false, // trying to reduce required endpoint hits - world_readable: false, // ^^^ - avatar_url: '', // ?? don't have any yet - }; - - const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); - - return { - body: { - chunk: publicRooms.map((room) => ({ - ...defaultObj, - ...room, - })), + return ( + new Router('/federation') + .use(isAuthenticatedMiddleware()) + // GET /_matrix/federation/v1/publicRooms + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1publicrooms + .get( + '/v1/publicRooms', + { + query: isPublicRoomsQueryProps, + response: { + 200: isPublicRoomsResponseProps, }, - statusCode: 200, - }; - }, - ) - .post( - '/v1/publicRooms', - { - body: isPublicRoomsPostBodyProps, - response: { - 200: isPublicRoomsResponseProps, + tags: ['Federation'], + license: ['federation'], }, - tags: ['Federation'], - license: ['federation'], - }, - async (c) => { - const body = await c.req.json(); - - const defaultObj = { - join_rule: 'public', - guest_can_join: false, // trying to reduce required endpoint hits - world_readable: false, // ^^^ - avatar_url: '', // ?? don't have any yet - }; - - const { filter } = body; - - const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); - - return { - body: { - chunk: publicRooms - .filter((r) => { - if (filter.generic_search_term) { - return r.name.toLowerCase().includes(filter.generic_search_term.toLowerCase()); - } - - // Today only one room type is supported (https://spec.matrix.org/v1.15/client-server-api/#types) - // TODO: https://rocketchat.atlassian.net/browse/FDR-152 -> Implement logic to handle custom room types - // if (filter.room_types) { - // } - - return true; - }) - .map((room) => ({ + async () => { + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce required endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; + + const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); + + return { + body: { + chunk: publicRooms.map((room) => ({ ...defaultObj, ...room, })), + }, + statusCode: 200, + }; + }, + ) + // POST /_matrix/federation/v1/publicRooms + // https://spec.matrix.org/v1.19/server-server-api/#post_matrixfederationv1publicrooms + .post( + '/v1/publicRooms', + { + body: isPublicRoomsPostBodyProps, + response: { + 200: isPublicRoomsResponseProps, }, - statusCode: 200, - }; - }, - ); + tags: ['Federation'], + license: ['federation'], + }, + async (c) => { + const body = await c.req.json(); + + const defaultObj = { + join_rule: 'public', + guest_can_join: false, // trying to reduce required endpoint hits + world_readable: false, // ^^^ + avatar_url: '', // ?? don't have any yet + }; + + const { filter } = body; + + const publicRooms = await federationSDK.getAllPublicRoomIdsAndNames(); + + return { + body: { + chunk: publicRooms + .filter((r) => { + if (filter?.generic_search_term) { + return r.name.toLowerCase().includes(filter.generic_search_term.toLowerCase()); + } + + // Today only one room type is supported (https://spec.matrix.org/v1.15/client-server-api/#types) + // TODO: https://rocketchat.atlassian.net/browse/FDR-152 -> Implement logic to handle custom room types + // if (filter.room_types) { + // } + + return true; + }) + .map((room) => ({ + ...defaultObj, + ...room, + })), + }, + statusCode: 200, + }; + }, + ) + ); }; diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-join.ts b/ee/packages/federation-matrix/src/api/_matrix/send-join.ts index f127559ef3c4e..74e4c8d293752 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/send-join.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/send-join.ts @@ -117,6 +117,8 @@ const SendJoinResponseSchema = { const isSendJoinResponseProps = ajv.compile(SendJoinResponseSchema); export const getMatrixSendJoinRoutes = () => { + // PUT /_matrix/federation/v2/send_join/{roomId}/{eventId} + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_joinroomideventid return new Router('/federation').put( '/v2/send_join/:roomId/:stateKey', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts index 72e2c76c55528..1e202fb8bf616 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/send-leave.ts @@ -58,6 +58,8 @@ const isSendLeaveErrorResponseProps = ajv.compile({ }); export const getMatrixSendLeaveRoutes = () => { + // PUT /_matrix/federation/v2/send_leave/{roomId}/{eventId} + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv2send_leaveroomideventid return new Router('/federation').put( '/v2/send_leave/:roomId/:eventId', { diff --git a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts index 3881118cfd87b..60333ffa174cb 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts @@ -3,6 +3,7 @@ import { federationSDK } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; +import { logger } from '../logger'; import { canAccessResourceMiddleware } from '../middlewares/canAccessResource'; import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; @@ -58,88 +59,6 @@ const GetEventResponseSchema = { const isGetEventResponseProps = ajv.compile(GetEventResponseSchema); -const EventHashSchema = { - type: 'object', - properties: { - sha256: { - type: 'string', - description: 'SHA256 hash of the event', - }, - }, - required: ['sha256'], -}; - -const EventSignatureSchema = { - type: 'object', - description: 'Event signatures by server and key ID', -}; - -const EventBaseSchema = { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Event type', - }, - content: { - type: 'object', - description: 'Event content', - }, - sender: { - type: 'string', - pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$', - description: 'Matrix user ID in format @user:server.com', - }, - room_id: { - type: 'string', - pattern: '^![A-Za-z0-9_=\\/.+-]+:(.+)$', - description: 'Matrix room ID in format !room:server.com', - }, - origin_server_ts: { - type: 'number', - minimum: 0, - description: 'Unix timestamp in milliseconds', - }, - depth: { - type: 'number', - minimum: 0, - description: 'Event depth', - }, - prev_events: { - type: 'array', - items: { - type: 'string', - }, - description: 'Previous events in the room', - }, - auth_events: { - type: 'array', - items: { - type: 'string', - }, - description: 'Authorization events', - }, - origin: { - type: 'string', - description: 'Origin server', - }, - hashes: { - ...EventHashSchema, - nullable: true, - }, - signatures: { - ...EventSignatureSchema, - nullable: true, - }, - unsigned: { - type: 'object', - description: 'Unsigned data', - nullable: true, - }, - }, - required: ['type', 'content', 'sender', 'room_id', 'origin_server_ts', 'depth', 'prev_events', 'auth_events'], -}; - const SendTransactionBodySchema = { type: 'object', properties: { @@ -154,7 +73,12 @@ const SendTransactionBodySchema = { }, pdus: { type: 'array', - items: EventBaseSchema, + items: { + // deliberately unconstrained, matching the spec: the PDU format varies by room + // version, and a malformed PDU must be reported per-PDU in the 200 response's + // `pdus` map instead of failing the whole transaction with a 400 + type: 'object', + }, description: 'Persistent data units (PDUs) to process', default: [], }, @@ -181,12 +105,8 @@ const SendTransactionResponseSchema = { type: 'object', description: 'Processing results for each PDU', }, - edus: { - type: 'object', - description: 'Processing results for each EDU', - }, }, - required: ['pdus', 'edus'], + required: ['pdus'], }; const isSendTransactionResponseProps = ajv.compile(SendTransactionResponseSchema); @@ -194,14 +114,14 @@ const isSendTransactionResponseProps = ajv.compile(SendTransactionResponseSchema const ErrorResponseSchema = { type: 'object', properties: { - error: { + errcode: { type: 'string', }, - details: { - type: 'object', + error: { + type: 'string', }, }, - required: ['error', 'details'], + required: ['errcode', 'error'], }; const isErrorResponseProps = ajv.compile(ErrorResponseSchema); @@ -221,13 +141,22 @@ const isGetStateIdsParamsProps = ajv.compile(GetStateIdsParamsSchema); const GetStateIdsResponseSchema = { type: 'object', properties: { - stateIds: { + auth_chain_ids: { + type: 'array', + items: { + type: 'string', + }, + description: 'Auth chain event IDs, recursively', + }, + pdu_ids: { type: 'array', items: { type: 'string', }, + description: 'Event IDs of the fully resolved room state at the given event', }, }, + required: ['auth_chain_ids', 'pdu_ids'], }; const isGetStateIdsResponseProps = ajv.compile(GetStateIdsResponseSchema); @@ -246,10 +175,22 @@ const isGetStateParamsProps = ajv.compile<{ const GetStateResponseSchema = { type: 'object', properties: { - state: { - type: 'object', + auth_chain: { + type: 'array', + items: { + type: 'object', + }, + description: 'Auth chain events, recursively', + }, + pdus: { + type: 'array', + items: { + type: 'object', + }, + description: 'The fully resolved room state at the given event', }, }, + required: ['auth_chain', 'pdus'], }; const isGetStateResponseProps = ajv.compile(GetStateResponseSchema); @@ -273,9 +214,9 @@ const BackfillQuerySchema = { type: 'object', properties: { limit: { - type: 'number', + // unbounded above per spec; the handler caps it. Synapse rejects 0 and negatives + type: 'integer', minimum: 1, - maximum: 100, description: 'Maximum number of events to retrieve', }, v: { @@ -287,7 +228,6 @@ const BackfillQuerySchema = { }, }, required: ['limit', 'v'], - additionalProperties: false, }; const isBackfillQueryProps = ajvQuery.compile<{ @@ -309,7 +249,10 @@ const BackfillResponseSchema = { }, pdus: { type: 'array', - items: EventBaseSchema, + items: { + // spec: backfill responses "MUST NOT be validated" against PDU restrictions + type: 'object', + }, description: 'Events in reverse chronological order', }, }, @@ -319,10 +262,11 @@ const BackfillResponseSchema = { const isBackfillResponseProps = ajv.compile(BackfillResponseSchema); export const getMatrixTransactionsRoutes = () => { - // PUT /_matrix/federation/v1/send/{txnId} return ( new Router('/federation') .use(isAuthenticatedMiddleware()) + // PUT /_matrix/federation/v1/send/{txnId} + // https://spec.matrix.org/v1.19/server-server-api/#put_matrixfederationv1sendtxnid .put( '/v1/send/:txnId', { @@ -331,6 +275,8 @@ export const getMatrixTransactionsRoutes = () => { response: { 200: isSendTransactionResponseProps, 400: isErrorResponseProps, + 429: isErrorResponseProps, + 500: isErrorResponseProps, }, tags: ['Federation'], license: ['federation'], @@ -346,22 +292,40 @@ export const getMatrixTransactionsRoutes = () => { return { statusCode: 429, body: { - errorcode: 'M_UNKNOWN', + errcode: 'M_UNKNOWN', error: 'Too many concurrent transactions', }, }; } + if (error.message === 'too-many-events') { + return { + statusCode: 400, + body: { + errcode: 'M_UNKNOWN', + error: 'Too many PDUs or EDUs', + }, + }; + } + + // a 200 tells the origin the transaction was delivered and it will never resend + // these PDUs. Per-PDU failures are already handled by the SDK, so anything + // reaching here is a server-side failure and must be retryable - Synapse only + // backs off and redelivers on 5xx + logger.error({ msg: 'Error processing incoming transaction', err: error }); + return { - statusCode: 400, - body: {}, + statusCode: 500, + body: { + errcode: 'M_UNKNOWN', + error: 'Failed to process transaction', + }, }; } return { body: { pdus: {}, - edus: {}, }, statusCode: 200, }; @@ -369,6 +333,7 @@ export const getMatrixTransactionsRoutes = () => { ) // GET /_matrix/federation/v1/state_ids/{roomId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1state_idsroomid .get( '/v1/state_ids/:roomId', { @@ -400,6 +365,8 @@ export const getMatrixTransactionsRoutes = () => { }; }, ) + // GET /_matrix/federation/v1/state/{roomId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1stateroomid .get( '/v1/state/:roomId', { @@ -430,6 +397,7 @@ export const getMatrixTransactionsRoutes = () => { }, ) // GET /_matrix/federation/v1/event/{eventId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1eventeventid .get( '/v1/event/:eventId', { @@ -464,6 +432,7 @@ export const getMatrixTransactionsRoutes = () => { }, ) // GET /_matrix/federation/v1/backfill/{roomId} + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1backfillroomid .get( '/v1/backfill/:roomId', { @@ -478,7 +447,20 @@ export const getMatrixTransactionsRoutes = () => { canAccessResourceMiddleware('room'), async (c) => { const roomId = c.req.param('roomId'); - const limit = Number(c.req.query('limit') || 100); + const limit = Math.min(Number(c.req.query('limit') || 100), 100); + + // this will be handled by the federation-sdk on next versions so this can be removed + if (limit < 1) { + return { + body: { + origin: federationSDK.getConfig('serverName'), + origin_server_ts: Date.now(), + pdus: [], + }, + statusCode: 200, + }; + } + const eventIds = c.req.queries('v'); if (!eventIds?.length) { return { diff --git a/ee/packages/federation-matrix/src/api/_matrix/versions.ts b/ee/packages/federation-matrix/src/api/_matrix/versions.ts index f24fd15c3847f..d1d21bf689dda 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/versions.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/versions.ts @@ -25,6 +25,8 @@ const GetVersionsResponseSchema = { const isGetVersionsResponseProps = ajv.compile(GetVersionsResponseSchema); export const getFederationVersionsRoutes = (version: string) => { + // GET /_matrix/federation/v1/version + // https://spec.matrix.org/v1.19/server-server-api/#get_matrixfederationv1version return new Router('/federation').get( '/v1/version', {