diff --git a/.changeset/add-users-rest-param-validations.md b/.changeset/add-users-rest-param-validations.md new file mode 100644 index 0000000000000..76739971782aa --- /dev/null +++ b/.changeset/add-users-rest-param-validations.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Migrated `users.getAvatar`, `users.deleteOwnAccount`, `users.resetAvatar`, and `users.forgotPassword` to explicit `Ajv` schema validation. Similarly, `groups.list` and `groups.listAll` were migrated to ensure strict parameter validation. diff --git a/.gitignore b/.gitignore index 8ee032f2d8c32..7ba2202078894 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ storybook-static development/tempo-data/ .env + +# Antigravity local folders +.agent/ +.optimization/ \ No newline at end of file diff --git a/apps/meteor/app/api/server/v1/groups.ts b/apps/meteor/app/api/server/v1/groups.ts index 2437813860836..75dd5dd530c16 100644 --- a/apps/meteor/app/api/server/v1/groups.ts +++ b/apps/meteor/app/api/server/v1/groups.ts @@ -1,7 +1,7 @@ import { Team, isMeteorError } from '@rocket.chat/core-services'; import type { IIntegration, IUser, IRoom, RoomType, UserStatus } from '@rocket.chat/core-typings'; import { Integrations, Messages, Rooms, Subscriptions, Uploads, Users } from '@rocket.chat/models'; -import { isGroupsOnlineProps, isGroupsMessagesProps, isGroupsFilesProps } from '@rocket.chat/rest-typings'; +import { isGroupsOnlineProps, isGroupsMessagesProps, isGroupsFilesProps, isGroupsListProps } from '@rocket.chat/rest-typings'; import { isTruthy } from '@rocket.chat/tools'; import { check, Match } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -81,12 +81,12 @@ async function findPrivateGroupByIdOrName({ userId, }: { params: - | { - roomId?: string; - } - | { - roomName?: string; - }; + | { + roomId?: string; + } + | { + roomName?: string; + }; userId: string; checkedArchived?: boolean; }): Promise<{ @@ -653,7 +653,7 @@ API.v1.addRoute( // List Private Groups a user has access to API.v1.addRoute( 'groups.list', - { authRequired: true }, + { authRequired: true, validateParams: isGroupsListProps }, { async get() { const { offset, count } = await getPaginationItems(this.queryParams); @@ -692,7 +692,7 @@ API.v1.addRoute( API.v1.addRoute( 'groups.listAll', - { authRequired: true, permissionsRequired: ['view-room-administration'] }, + { authRequired: true, permissionsRequired: ['view-room-administration'], validateParams: isGroupsListProps }, { async get() { const { offset, count } = await getPaginationItems(this.queryParams); diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index d9e37b1f7b8b2..2130870223308 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -21,6 +21,10 @@ import { ajv, validateBadRequestErrorResponse, validateUnauthorizedErrorResponse, + isUsersGetAvatarProps, + isUsersDeleteOwnAccountProps, + isUsersResetAvatarProps, + isUsersForgotPasswordProps, } from '@rocket.chat/rest-typings'; import { getLoginExpirationInMs, wrapExceptions } from '@rocket.chat/tools'; import { Accounts } from 'meteor/accounts-base'; @@ -83,7 +87,10 @@ import { findPaginatedUsersByStatus, findUsersToAutocomplete, getInclusiveFields API.v1.addRoute( 'users.getAvatar', - { authRequired: true }, + { + authRequired: true, + validateParams: isUsersGetAvatarProps, + }, { async get() { const user = await getUserFromParams(this.queryParams); @@ -171,9 +178,9 @@ API.v1.addRoute( const twoFactorOptions = !userData.typedPassword ? null : { - twoFactorCode: userData.typedPassword, - twoFactorMethod: 'password', - }; + twoFactorCode: userData.typedPassword, + twoFactorMethod: 'password', + }; await executeSaveUserProfile.call(this, this.user, userData, this.bodyParams.customFields, twoFactorOptions); @@ -361,19 +368,18 @@ API.v1.addRoute( API.v1.addRoute( 'users.deleteOwnAccount', - { authRequired: true }, + { + authRequired: true, + validateParams: isUsersDeleteOwnAccountProps, + }, { async post() { - const { password } = this.bodyParams; - if (!password) { - return API.v1.failure('Body parameter "password" is required.'); - } + const { password, confirmRelinquish = false } = this.bodyParams; + if (!settings.get('Accounts_AllowDeleteOwnAccount')) { throw new Meteor.Error('error-not-allowed', 'Not allowed'); } - const { confirmRelinquish = false } = this.bodyParams; - await deleteUserOwnAccount(this.userId, password, confirmRelinquish); return API.v1.success(); @@ -536,13 +542,18 @@ API.v1.addRoute( const limit = count !== 0 ? [ - { - $limit: count, - }, - ] + { + $limit: count, + }, + ] : []; - const result = await Users.col + const [ + { + sortedResults: users, + totalCount: [{ total } = { total: 0 }], + } = { sortedResults: [], totalCount: [] }, + ] = await Users.col .aggregate<{ sortedResults: IUser[]; totalCount: { total: number }[] }>([ { $match: nonEmptyQuery, @@ -574,11 +585,6 @@ API.v1.addRoute( ]) .toArray(); - const { - sortedResults: users, - totalCount: [{ total } = { total: 0 }], - } = result[0]; - return API.v1.success({ users, count: users.length, @@ -729,7 +735,10 @@ API.v1.addRoute( API.v1.addRoute( 'users.resetAvatar', - { authRequired: true }, + { + authRequired: true, + validateParams: isUsersResetAvatarProps, + }, { async post() { const user = await getUserFromParams(this.bodyParams); @@ -900,7 +909,10 @@ API.v1.addRoute( API.v1.addRoute( 'users.forgotPassword', - { authRequired: false }, + { + authRequired: false, + validateParams: isUsersForgotPasswordProps, + }, { async post() { const isPasswordResetEnabled = settings.get('Accounts_PasswordReset'); @@ -910,9 +922,6 @@ API.v1.addRoute( } const { email } = this.bodyParams; - if (!email) { - return API.v1.failure("The 'email' param is required"); - } await sendForgotPasswordEmail(email.toLowerCase()); return API.v1.success(); @@ -1558,5 +1567,5 @@ type UsersEndpoints = ExtractRoutesFromAPI; declare module '@rocket.chat/rest-typings' { // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface - interface Endpoints extends UsersEndpoints {} + interface Endpoints extends UsersEndpoints { } } diff --git a/packages/rest-typings/src/v1/Ajv.ts b/packages/rest-typings/src/v1/Ajv.ts index 0c2def3db310f..c79ba17edf417 100644 --- a/packages/rest-typings/src/v1/Ajv.ts +++ b/packages/rest-typings/src/v1/Ajv.ts @@ -14,7 +14,7 @@ addFormats(ajv); ajv.addFormat('basic_email', /^[^@]+@[^@]+$/); ajv.addFormat( 'rfc_email', - /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, ); ajv.addKeyword({ keyword: 'isNotEmpty', diff --git a/packages/rest-typings/src/v1/groups/GroupsListProps.ts b/packages/rest-typings/src/v1/groups/GroupsListProps.ts index 10bc322f8e0be..f80f1c7d00af1 100644 --- a/packages/rest-typings/src/v1/groups/GroupsListProps.ts +++ b/packages/rest-typings/src/v1/groups/GroupsListProps.ts @@ -1,6 +1,38 @@ import type { PaginatedRequest } from '../../helpers/PaginatedRequest'; import { ajv } from '../Ajv'; -export type GroupsListProps = PaginatedRequest; -const groupsListPropsSchema = {}; +export type GroupsListProps = PaginatedRequest<{ name?: string }>; + +const groupsListPropsSchema = { + type: 'object', + properties: { + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + sort: { + type: 'string', + nullable: true, + }, + fields: { + type: 'string', + nullable: true, + }, + query: { + type: 'string', + nullable: true, + }, + name: { + type: 'string', + nullable: true, + }, + }, + required: [], + additionalProperties: false, +}; + export const isGroupsListProps = ajv.compile(groupsListPropsSchema); diff --git a/packages/rest-typings/src/v1/users.ts b/packages/rest-typings/src/v1/users.ts index 565620d31ba5e..a636b5e3e230d 100644 --- a/packages/rest-typings/src/v1/users.ts +++ b/packages/rest-typings/src/v1/users.ts @@ -9,6 +9,9 @@ import type { UserLogoutParamsPOST } from './users/UserLogoutParamsPOST'; import type { UserRegisterParamsPOST } from './users/UserRegisterParamsPOST'; import type { UserSetActiveStatusParamsPOST } from './users/UserSetActiveStatusParamsPOST'; import type { UsersAutocompleteParamsGET } from './users/UsersAutocompleteParamsGET'; +import type { UsersDeleteOwnAccountParamsPOST } from './users/UsersDeleteOwnAccountParamsPOST'; +import type { UsersForgotPasswordParamsPOST } from './users/UsersForgotPasswordParamsPOST'; +import type { UsersGetAvatarParamsGET } from './users/UsersGetAvatarParamsGET'; import type { UsersInfoParamsGet } from './users/UsersInfoParamsGet'; import type { UsersListStatusParamsGET } from './users/UsersListStatusParamsGET'; import type { UsersListTeamsParamsGET } from './users/UsersListTeamsParamsGET'; @@ -18,6 +21,48 @@ import type { UsersSetPreferencesParamsPOST } from './users/UsersSetPreferencePa import type { UsersUpdateOwnBasicInfoParamsPOST } from './users/UsersUpdateOwnBasicInfoParamsPOST'; import type { UsersUpdateParamsPOST } from './users/UsersUpdateParamsPOST'; +export const isUsersGetAvatarProps = ajv.compile({ + oneOf: [ + { + type: 'object', + properties: { userId: { type: 'string' } }, + required: ['userId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { username: { type: 'string' } }, + required: ['username'], + additionalProperties: false, + }, + { + type: 'object', + properties: { user: { type: 'string' } }, + required: ['user'], + additionalProperties: false, + }, + ], +}); + +export const isUsersDeleteOwnAccountProps = ajv.compile({ + type: 'object', + properties: { + password: { type: 'string', minLength: 1 }, + confirmRelinquish: { type: 'boolean' }, + }, + required: ['password'], + additionalProperties: false, +}); + +export const isUsersForgotPasswordProps = ajv.compile({ + type: 'object', + properties: { + email: { type: 'string', minLength: 1 }, + }, + required: ['email'], + additionalProperties: false, +}); + type UsersInfo = { userId?: IUser['_id']; username?: IUser['username'] }; const UsersInfoSchema = { @@ -356,7 +401,7 @@ export type UsersEndpoints = { }; '/v1/users.getAvatar': { - GET: (params: { userId?: string; username?: string; user?: string }) => void; + GET: (params: UsersGetAvatarParamsGET) => void; }; '/v1/users.updateOwnBasicInfo': { @@ -380,3 +425,6 @@ export * from './users/UserRegisterParamsPOST'; export * from './users/UserLogoutParamsPOST'; export * from './users/UsersListTeamsParamsGET'; export * from './users/UsersAutocompleteParamsGET'; +export * from './users/UsersGetAvatarParamsGET'; +export * from './users/UsersDeleteOwnAccountParamsPOST'; +export * from './users/UsersForgotPasswordParamsPOST'; diff --git a/packages/rest-typings/src/v1/users/UsersDeleteOwnAccountParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersDeleteOwnAccountParamsPOST.ts new file mode 100644 index 0000000000000..ab8bc6077e4d6 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersDeleteOwnAccountParamsPOST.ts @@ -0,0 +1,4 @@ +export type UsersDeleteOwnAccountParamsPOST = { + password: string; + confirmRelinquish?: boolean; +}; diff --git a/packages/rest-typings/src/v1/users/UsersForgotPasswordParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersForgotPasswordParamsPOST.ts new file mode 100644 index 0000000000000..4d489327da555 --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersForgotPasswordParamsPOST.ts @@ -0,0 +1,3 @@ +export type UsersForgotPasswordParamsPOST = { + email: string; +}; diff --git a/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts b/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts new file mode 100644 index 0000000000000..bd2a2227609da --- /dev/null +++ b/packages/rest-typings/src/v1/users/UsersGetAvatarParamsGET.ts @@ -0,0 +1,4 @@ +export type UsersGetAvatarParamsGET = + | { userId: string; username?: never; user?: never } + | { username: string; userId?: never; user?: never } + | { user: string; userId?: never; username?: never };