diff --git a/ee/packages/federation-matrix/package.json b/ee/packages/federation-matrix/package.json index b101e813214a0..8f205eb552916 100644 --- a/ee/packages/federation-matrix/package.json +++ b/ee/packages/federation-matrix/package.json @@ -38,7 +38,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "^0.31.25", - "@rocket.chat/federation-sdk": "0.1.23", + "@rocket.chat/federation-sdk": "0.1.25", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/license": "workspace:^", "@rocket.chat/models": "workspace:^", diff --git a/ee/packages/federation-matrix/src/api/_matrix/invite.ts b/ee/packages/federation-matrix/src/api/_matrix/invite.ts index ce72f74418366..6a8966ee26216 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/invite.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/invite.ts @@ -14,6 +14,7 @@ import { Rooms, Users } from '@rocket.chat/models'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; import { createOrUpdateFederatedUser, getUsernameServername } from '../../FederationMatrix'; +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; const EventBaseSchema = { type: 'object', @@ -181,7 +182,7 @@ async function joinRoom({ const isDM = inviteEvent.getContent().is_direct; if (!isDM && !matrixRoom.isPublic() && !matrixRoom.isInviteOnly()) { - throw new Error('room is neither public, private, nor direct message - rocketchat is unable to join for now'); + throw new Error('room is neither direct message - rocketchat is unable to join for now'); } // need both the sender and the participating user to exist in the room @@ -320,7 +321,7 @@ export const acceptInvite = async ( }; export const getMatrixInviteRoutes = (services: HomeserverServices) => { - const { invite, state, room } = services; + const { invite, state, room, federationAuth } = services; return new Router('/federation').put( '/v2/invite/:roomId/:eventId', @@ -333,6 +334,7 @@ export const getMatrixInviteRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, + isAuthenticatedMiddleware(federationAuth), async (c) => { const { roomId, eventId } = c.req.param(); const { event, room_version: roomVersion } = await c.req.json(); @@ -353,7 +355,13 @@ export const getMatrixInviteRoutes = (services: HomeserverServices) => { throw new Error('user not found not processing invite'); } - const inviteEvent = await invite.processInvite(event, roomIdSchema.parse(roomId), eventIdSchema.parse(eventId), roomVersion); + const inviteEvent = await invite.processInvite( + event, + roomIdSchema.parse(roomId), + eventIdSchema.parse(eventId), + roomVersion, + c.get('authenticatedServer'), + ); setTimeout( () => { diff --git a/ee/packages/federation-matrix/src/api/_matrix/media.ts b/ee/packages/federation-matrix/src/api/_matrix/media.ts index ac22bf76defa4..b357ee1875410 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/media.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/media.ts @@ -6,7 +6,7 @@ import { Router } from '@rocket.chat/http-router'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; import { MatrixMediaService } from '../../services/MatrixMediaService'; -import { canAccessMedia } from '../middlewares'; +import { canAccessResourceMiddleware } from '../middlewares/canAccessResource'; const MediaDownloadParamsSchema = { type: 'object', @@ -75,79 +75,76 @@ async function getMediaFile(mediaId: string, serverName: string): Promise<{ file export const getMatrixMediaRoutes = (homeserverServices: HomeserverServices) => { const { config, federationAuth } = homeserverServices; - const router = new Router('/federation'); - - router.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( + '/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'], - }, - canAccessMedia(federationAuth), - async (c) => { - try { - const { mediaId } = c.req.param(); - const { serverName } = config; - - // TODO: Add file streaming support - const result = await getMediaFile(mediaId, serverName); - if (!result) { + canAccessResourceMiddleware(federationAuth, 'media'), + async (c) => { + try { + const { mediaId } = c.req.param(); + const { serverName } = config; + + // 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: 404, - body: { errcode: 'M_NOT_FOUND', error: 'Media not found' }, + 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' }, }; } - - 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' }, - }; - } - }, - ); - - router.get( - '/v1/media/thumbnail/:mediaId', - { - params: isMediaDownloadParamsProps, - response: { - 404: isErrorResponseProps, }, - tags: ['Federation', 'Media'], - }, - async () => ({ - statusCode: 404, - body: { - errcode: 'M_UNRECOGNIZED', - error: 'This endpoint is not implemented on the homeserver side', + ) + .get( + '/v1/media/thumbnail/:mediaId', + { + params: isMediaDownloadParamsProps, + response: { + 404: isErrorResponseProps, + }, + tags: ['Federation', 'Media'], }, - }), - ); - - return router; + canAccessResourceMiddleware(federationAuth, '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 c62cb2fa69df3..f09efc4073bb1 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/profiles.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/profiles.ts @@ -2,6 +2,9 @@ import { eventIdSchema, roomIdSchema, userIdSchema, type HomeserverServices, typ import { Router } from '@rocket.chat/http-router'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { canAccessResourceMiddleware } from '../middlewares/canAccessResource'; +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; + const UsernameSchema = { type: 'string', pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$', @@ -350,9 +353,10 @@ const EventAuthResponseSchema = { const isEventAuthResponseProps = ajv.compile(EventAuthResponseSchema); export const getMatrixProfilesRoutes = (services: HomeserverServices) => { - const { profile } = services; + const { profile, federationAuth } = services; return new Router('/federation') + .use(isAuthenticatedMiddleware(federationAuth)) .get( '/v1/query/profile', { @@ -414,14 +418,13 @@ export const getMatrixProfilesRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, - async (c) => { - const { userId } = c.req.param(); - - const response = await profile.getDevices(userId); - + async (_c) => { return { - body: response, - statusCode: 200, + body: { + errcode: 'M_UNRECOGNIZED', + error: 'This endpoint is not implemented on the homeserver side', + }, + statusCode: 501, }; }, ) @@ -436,6 +439,7 @@ export const getMatrixProfilesRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, + canAccessResourceMiddleware(federationAuth, 'room'), async (c) => { const { roomId, userId } = c.req.param(); const url = new URL(c.req.url); @@ -467,6 +471,7 @@ export const getMatrixProfilesRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, + canAccessResourceMiddleware(federationAuth, 'room'), async (c) => { const { roomId } = c.req.param(); const body = await c.req.json(); @@ -489,6 +494,7 @@ export const getMatrixProfilesRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, + canAccessResourceMiddleware(federationAuth, 'room'), async (c) => { const { roomId, eventId } = c.req.param(); diff --git a/ee/packages/federation-matrix/src/api/_matrix/rooms.ts b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts index ab6d05f29a58b..cdb9b146afd10 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/rooms.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/rooms.ts @@ -2,6 +2,8 @@ import type { HomeserverServices } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; + const PublicRoomsQuerySchema = { type: 'object', properties: { @@ -122,9 +124,10 @@ const PublicRoomsPostBodySchema = { const isPublicRoomsPostBodyProps = ajv.compile(PublicRoomsPostBodySchema); export const getMatrixRoomsRoutes = (services: HomeserverServices) => { - const { state } = services; + const { state, federationAuth } = services; return new Router('/federation') + .use(isAuthenticatedMiddleware(federationAuth)) .get( '/v1/publicRooms', { 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 e8ee53c4b964d..191c070b90754 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/send-join.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/send-join.ts @@ -2,6 +2,8 @@ import type { HomeserverServices, EventID } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; +import { canAccessResourceMiddleware } from '../middlewares/canAccessResource'; + const UsernameSchema = { type: 'string', pattern: '^@[A-Za-z0-9_=\\/.+-]+:(.+)$', @@ -222,7 +224,7 @@ const SendJoinResponseSchema = { const isSendJoinResponseProps = ajv.compile(SendJoinResponseSchema); export const getMatrixSendJoinRoutes = (services: HomeserverServices) => { - const { sendJoin } = services; + const { sendJoin, federationAuth } = services; return new Router('/federation').put( '/v2/send_join/:roomId/:stateKey', @@ -235,6 +237,7 @@ export const getMatrixSendJoinRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, + canAccessResourceMiddleware(federationAuth, 'room'), async (c) => { const { roomId, stateKey } = c.req.param(); const body = await c.req.json(); diff --git a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts index 683a189f63d02..5e12c781797f4 100644 --- a/ee/packages/federation-matrix/src/api/_matrix/transactions.ts +++ b/ee/packages/federation-matrix/src/api/_matrix/transactions.ts @@ -2,7 +2,8 @@ import type { HomeserverServices, EventID } from '@rocket.chat/federation-sdk'; import { Router } from '@rocket.chat/http-router'; import { ajv } from '@rocket.chat/rest-typings/dist/v1/Ajv'; -import { canAccessEvent } from '../middlewares'; +import { canAccessResourceMiddleware } from '../middlewares/canAccessResource'; +import { isAuthenticatedMiddleware } from '../middlewares/isAuthenticated'; const SendTransactionParamsSchema = { type: 'object', @@ -319,6 +320,7 @@ export const getMatrixTransactionsRoutes = (services: HomeserverServices) => { // PUT /_matrix/federation/v1/send/{txnId} return ( new Router('/federation') + .use(isAuthenticatedMiddleware(federationAuth)) .put( '/v1/send/:txnId', { @@ -365,7 +367,6 @@ export const getMatrixTransactionsRoutes = (services: HomeserverServices) => { ) // GET /_matrix/federation/v1/state_ids/{roomId} - .get( '/v1/state_ids/:roomId', { @@ -374,6 +375,7 @@ export const getMatrixTransactionsRoutes = (services: HomeserverServices) => { 200: isGetStateIdsResponseProps, }, }, + canAccessResourceMiddleware(federationAuth, 'room'), async (c) => { const roomId = c.req.param('roomId'); const eventId = c.req.query('event_id'); @@ -404,6 +406,7 @@ export const getMatrixTransactionsRoutes = (services: HomeserverServices) => { 200: isGetStateResponseProps, }, }, + canAccessResourceMiddleware(federationAuth, 'room'), async (c) => { const roomId = c.req.param('roomId'); const eventId = c.req.query('event_id'); @@ -435,7 +438,7 @@ export const getMatrixTransactionsRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, - canAccessEvent(federationAuth), + canAccessResourceMiddleware(federationAuth, 'event'), async (c) => { const eventData = await event.getEventById(c.req.param('eventId') as EventID); if (!eventData) { @@ -470,6 +473,7 @@ export const getMatrixTransactionsRoutes = (services: HomeserverServices) => { tags: ['Federation'], license: ['federation'], }, + canAccessResourceMiddleware(federationAuth, 'room'), async (c) => { const roomId = c.req.param('roomId'); const limit = Number(c.req.query('limit') || 100); diff --git a/ee/packages/federation-matrix/src/api/middlewares.ts b/ee/packages/federation-matrix/src/api/middlewares.ts deleted file mode 100644 index 1b1f9579d201c..0000000000000 --- a/ee/packages/federation-matrix/src/api/middlewares.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { errCodes } from '@rocket.chat/federation-sdk'; -import type { EventAuthorizationService, EventID } from '@rocket.chat/federation-sdk'; -import type { Context, Next } from 'hono'; - -export const canAccessMedia = (federationAuth: EventAuthorizationService) => async (c: Context, next: Next) => { - try { - const url = new URL(c.req.url); - const path = url.search ? `${c.req.path}${url.search}` : c.req.path; - - const verificationResult = await federationAuth.canAccessMediaFromAuthorizationHeader( - c.req.param('mediaId'), - c.req.header('Authorization') || '', - c.req.method, - path, - undefined, - ); - - if (!verificationResult.authorized) { - return c.json( - { - errcode: errCodes[verificationResult.errorCode].errcode, - error: errCodes[verificationResult.errorCode].error, - }, - errCodes[verificationResult.errorCode].status, - ); - } - - return next(); - } catch (error) { - return c.json(errCodes.M_UNKNOWN, 500); - } -}; - -export const canAccessEvent = (federationAuth: EventAuthorizationService) => async (c: Context, next: Next) => { - try { - const url = new URL(c.req.url); - const path = url.search ? `${c.req.path}${url.search}` : c.req.path; - - const verificationResult = await federationAuth.canAccessEventFromAuthorizationHeader( - c.req.param('eventId') as EventID, - c.req.header('Authorization') || '', - c.req.method, - path, - undefined, - ); - - if (!verificationResult.authorized) { - return c.json( - { - errcode: errCodes[verificationResult.errorCode].errcode, - error: errCodes[verificationResult.errorCode].error, - }, - errCodes[verificationResult.errorCode].status, - ); - } - - return next(); - } catch (error) { - return c.json(errCodes.M_UNKNOWN, 500); - } -}; diff --git a/ee/packages/federation-matrix/src/api/middlewares/canAccessResource.ts b/ee/packages/federation-matrix/src/api/middlewares/canAccessResource.ts new file mode 100644 index 0000000000000..cc816bc96210e --- /dev/null +++ b/ee/packages/federation-matrix/src/api/middlewares/canAccessResource.ts @@ -0,0 +1,57 @@ +import { errCodes } from '@rocket.chat/federation-sdk'; +import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import { every } from 'hono/combine'; +import { createMiddleware } from 'hono/factory'; + +import { isAuthenticatedMiddleware } from './isAuthenticated'; + +function extractEntityId( + params: { eventId?: string; mediaId?: string; roomId?: string }, + entityType: 'event' | 'media' | 'room', +): string | null { + if (entityType === 'room') { + return params.roomId ?? null; + } + + if (entityType === 'media') { + return params.mediaId ?? null; + } + + if (entityType === 'event') { + return params.eventId ?? null; + } + + return null; +} + +const canAccessResource = (federationAuth: EventAuthorizationService, entityType: 'event' | 'media' | 'room') => + createMiddleware(async (c, next) => { + try { + const mediaId = c.req.param('mediaId'); + const eventId = c.req.param('eventId'); + const roomId = c.req.param('roomId'); + + const resourceId = extractEntityId({ mediaId, eventId, roomId }, entityType); + if (!resourceId) { + return c.json({ errcode: 'M_INVALID_PARAM', error: `Missing required ${entityType} identifier` }, 400); + } + + const resourceAccess = await federationAuth.canAccessResource(entityType, resourceId, c.get('authenticatedServer')); + if (!resourceAccess) { + return c.json( + { + errcode: 'M_FORBIDDEN', + error: 'Access denied to resource', + }, + 403, + ); + } + + return next(); + } catch (error) { + return c.json(errCodes.M_UNKNOWN, 500); + } + }); + +export const canAccessResourceMiddleware = (federationAuth: EventAuthorizationService, entityType: 'event' | 'media' | 'room') => + every(isAuthenticatedMiddleware(federationAuth), canAccessResource(federationAuth, entityType)); diff --git a/ee/packages/federation-matrix/src/api/middlewares/isAuthenticated.ts b/ee/packages/federation-matrix/src/api/middlewares/isAuthenticated.ts new file mode 100644 index 0000000000000..e69ca5c758f82 --- /dev/null +++ b/ee/packages/federation-matrix/src/api/middlewares/isAuthenticated.ts @@ -0,0 +1,40 @@ +import { errCodes } from '@rocket.chat/federation-sdk'; +import type { EventAuthorizationService } from '@rocket.chat/federation-sdk'; +import type { Context } from 'hono'; +import { createMiddleware } from 'hono/factory'; + +export const isAuthenticatedMiddleware = (federationAuth: EventAuthorizationService) => + createMiddleware(async (c: Context, next) => { + try { + const { method } = c.req; + const body = c.req.raw.body ? await c.req.raw.clone().json() : undefined; + const url = new URL(c.req.url); + const path = url.pathname + url.search; + const authHeader = c.req.header('Authorization') || ''; + if (!authHeader) { + return c.json( + { + errcode: 'M_UNAUTHORIZED', + error: 'Missing Authorization header', + }, + 401, + ); + } + + const verificationResult = await federationAuth.verifyRequestSignature(authHeader, method, path, body); + if (!verificationResult) { + return c.json( + { + errcode: errCodes.M_UNAUTHORIZED.errcode, + error: errCodes.M_UNAUTHORIZED.error, + }, + errCodes.M_UNAUTHORIZED.status, + ); + } + + c.set('authenticatedServer', verificationResult); + return next(); + } catch (error) { + return c.json(errCodes.M_UNKNOWN, 500); + } + }); diff --git a/packages/core-services/package.json b/packages/core-services/package.json index 2c830c16d67ac..e8691ecaeca0e 100644 --- a/packages/core-services/package.json +++ b/packages/core-services/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/federation-sdk": "0.1.23", + "@rocket.chat/federation-sdk": "0.1.25", "@rocket.chat/http-router": "workspace:^", "@rocket.chat/icons": "^0.43.0", "@rocket.chat/media-signaling": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 3314c5aeff368..de2a6b6f1dc27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7279,7 +7279,7 @@ __metadata: "@rocket.chat/apps-engine": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.1.23" + "@rocket.chat/federation-sdk": "npm:0.1.25" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/icons": "npm:^0.43.0" "@rocket.chat/jest-presets": "workspace:~" @@ -7490,7 +7490,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": "npm:^0.31.25" "@rocket.chat/eslint-config": "workspace:^" - "@rocket.chat/federation-sdk": "npm:0.1.23" + "@rocket.chat/federation-sdk": "npm:0.1.25" "@rocket.chat/http-router": "workspace:^" "@rocket.chat/license": "workspace:^" "@rocket.chat/models": "workspace:^" @@ -7515,9 +7515,9 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/federation-sdk@npm:0.1.23": - version: 0.1.23 - resolution: "@rocket.chat/federation-sdk@npm:0.1.23" +"@rocket.chat/federation-sdk@npm:0.1.25": + version: 0.1.25 + resolution: "@rocket.chat/federation-sdk@npm:0.1.25" dependencies: "@datastructures-js/priority-queue": "npm:^6.3.3" "@noble/ed25519": "npm:^3.0.0" @@ -7530,7 +7530,7 @@ __metadata: zod: "npm:^3.22.4" peerDependencies: typescript: ~5.9.2 - checksum: 10/8d475d7f7d30cb8dc5db5239c13d0ee39a11fc24b5e9ece460334abdfb8b5fde0321dd6bef16470b22d09a4451327e3ab71052b09db895a103477c57e8458f2c + checksum: 10/94446bf69983916584a8d202883a6bc9a5c97f8e3662c44361ba7d886e29a57c1385237cca2c4e4a62333813a28659ea94437b45cb1ecd311f6efaeb44f8f840 languageName: node linkType: hard @@ -9695,7 +9695,7 @@ __metadata: peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.2.3 - "@rocket.chat/ui-contexts": 23.0.0-rc.2 + "@rocket.chat/ui-contexts": 23.0.0-rc.3 "@tanstack/react-query": "*" react: "*" react-hook-form: "*"