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
16 changes: 16 additions & 0 deletions .changeset/strict-ajv-coercion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@rocket.chat/rest-typings": minor
"@rocket.chat/meteor": patch
---

Splits the single AJV validator instance into two: `ajv` (coerceTypes: false) for request **body** validation and `ajvQuery` (coerceTypes: true) for **query parameter** validation.

**Why this matters:** Previously, a single AJV instance with `coerceTypes: true` was used everywhere. This silently accepted values with wrong types — for example, sending `{ "rid": 12345 }` (number) where a string was expected would pass validation because `12345` was coerced to `"12345"`. With this change, body validation is now strict: the server will reject payloads with incorrect types instead of silently coercing them.

**What may break for API consumers:**

- **Numeric values sent as strings in POST/PUT/PATCH bodies** (e.g., `{ "count": "10" }` instead of `{ "count": 10 }`) will now be rejected. Ensure JSON bodies use proper types.
- **Boolean values sent as strings in bodies** (e.g., `{ "readThreads": "true" }` instead of `{ "readThreads": true }`) will now be rejected.
- **`null` values where a string is expected** (e.g., `{ "name": null }` for a `type: 'string'` field without `nullable: true`) will no longer be coerced to `""`.

**No change for query parameters:** GET query params (e.g., `?count=10&offset=0`) continue to be coerced via `ajvQuery`, since HTTP query strings are always strings.
2 changes: 1 addition & 1 deletion apps/meteor/.mocharc.api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
module.exports = /** @satisfies {import('mocha').MochaOptions} */ ({
...require('./.mocharc.base.json'), // see https://github.com/mochajs/mocha/issues/3916
timeout: 10000,
bail: true,
bail: false,
retries: 0,
file: 'tests/end-to-end/teardown.ts',
spec: ['tests/end-to-end/api/*.ts', 'tests/end-to-end/api/helpers/**/*', 'tests/end-to-end/api/methods/**/*', 'tests/end-to-end/apps/*'],
Expand Down
6 changes: 4 additions & 2 deletions apps/meteor/app/api/server/ajv.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { schemas } from '@rocket.chat/core-typings';
import { ajv } from '@rocket.chat/rest-typings';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';

const components = schemas.components?.schemas;
if (components) {
for (const key in components) {
if (Object.prototype.hasOwnProperty.call(components, key)) {
ajv.addSchema(components[key], `#/components/schemas/${key}`);
const uri = `#/components/schemas/${key}`;
ajv.addSchema(components[key], uri);
ajvQuery.addSchema(components[key], uri);
}
}
}
5 changes: 3 additions & 2 deletions apps/meteor/app/api/server/v1/call-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CallHistory, MediaCalls } from '@rocket.chat/models';
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
import {
ajv,
ajvQuery,
validateNotFoundErrorResponse,
validateBadRequestErrorResponse,
validateUnauthorizedErrorResponse,
Expand Down Expand Up @@ -61,7 +62,7 @@ const CallHistoryListSchema = {
additionalProperties: false,
};

export const isCallHistoryListProps = ajv.compile<CallHistoryList>(CallHistoryListSchema);
export const isCallHistoryListProps = ajvQuery.compile<CallHistoryList>(CallHistoryListSchema);

const callHistoryListEndpoints = API.v1.get(
'call-history.list',
Expand Down Expand Up @@ -185,7 +186,7 @@ const CallHistoryInfoSchema = {
],
};

export const isCallHistoryInfoProps = ajv.compile<CallHistoryInfo>(CallHistoryInfoSchema);
export const isCallHistoryInfoProps = ajvQuery.compile<CallHistoryInfo>(CallHistoryInfoSchema);

const callHistoryInfoEndpoints = API.v1.get(
'call-history.info',
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/app/api/server/v1/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Apps } from '@rocket.chat/apps';
import type { SlashCommand } from '@rocket.chat/core-typings';
import { Messages } from '@rocket.chat/models';
import { Random } from '@rocket.chat/random';
import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
import objectPath from 'object-path';

import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom';
Expand All @@ -24,7 +24,7 @@ const CommandsGetParamsSchema = {
additionalProperties: false,
};

const isCommandsGetParams = ajv.compile<CommandsGetParams>(CommandsGetParamsSchema);
const isCommandsGetParams = ajvQuery.compile<CommandsGetParams>(CommandsGetParamsSchema);

const commandsEndpoints = API.v1.get(
'commands.get',
Expand Down
4 changes: 2 additions & 2 deletions apps/meteor/app/api/server/v1/custom-user-status.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ICustomUserStatus } from '@rocket.chat/core-typings';
import { CustomUserStatus } from '@rocket.chat/models';
import { ajv, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
import { ajv, ajvQuery, validateUnauthorizedErrorResponse, validateBadRequestErrorResponse } from '@rocket.chat/rest-typings';
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
import { escapeRegExp } from '@rocket.chat/string-helpers';
import { Match, check } from 'meteor/check';
Expand Down Expand Up @@ -46,7 +46,7 @@ const CustomUserStatusListSchema = {
additionalProperties: false,
};

const isCustomUserStatusListProps = ajv.compile<CustomUserStatusListProps>(CustomUserStatusListSchema);
const isCustomUserStatusListProps = ajvQuery.compile<CustomUserStatusListProps>(CustomUserStatusListSchema);

const customUserStatusEndpoints = API.v1.get(
'custom-user-status.list',
Expand Down
6 changes: 4 additions & 2 deletions apps/meteor/app/api/server/v1/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { IRoom, ISubscription, IUser } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import {
ajv,
ajvQuery,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
Expand Down Expand Up @@ -164,7 +165,9 @@ const ise2eGetUsersOfRoomWithoutKeyParamsGET = ajv.compile<e2eGetUsersOfRoomWith

const ise2eUpdateGroupKeyParamsPOST = ajv.compile<e2eUpdateGroupKeyParamsPOST>(e2eUpdateGroupKeyParamsPOSTSchema);

const isE2EFetchUsersWaitingForGroupKeyProps = ajv.compile<E2EFetchUsersWaitingForGroupKeyProps>(E2EFetchUsersWaitingForGroupKeySchema);
const isE2EFetchUsersWaitingForGroupKeyProps = ajvQuery.compile<E2EFetchUsersWaitingForGroupKeyProps>(
E2EFetchUsersWaitingForGroupKeySchema,
);

const isE2EProvideUsersGroupKeyProps = ajv.compile<E2EProvideUsersGroupKeyProps>(E2EProvideUsersGroupKeySchema);

Expand Down Expand Up @@ -457,7 +460,6 @@ const e2eEndpoints = API.v1
},
},
async function action() {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { public_key, private_key, force } = this.bodyParams;

await setUserPublicAndPrivateKeysMethod(this.userId, {
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/app/api/server/v1/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ const spotlightResponseSchema = ajv.compile<{
statusText: { type: 'string' },
avatarETag: { type: 'string' },
},
required: ['_id', 'name', 'username', 'status', 'statusText'],
required: ['_id', 'name', 'username', 'status'],
additionalProperties: true,
},
},
Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/app/api/server/v1/oauthapps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { IOAuthApps } from '@rocket.chat/core-typings';
import { OAuthApps } from '@rocket.chat/models';
import {
ajv,
ajvQuery,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
Expand Down Expand Up @@ -114,14 +115,14 @@ const oauthAppsGetParamsSchema = {
],
};

const isOauthAppsGetParams = ajv.compile<OauthAppsGetParams>(oauthAppsGetParamsSchema);
const isOauthAppsGetParams = ajvQuery.compile<OauthAppsGetParams>(oauthAppsGetParamsSchema);

const oauthAppsEndpoints = API.v1
.get(
'oauth-apps.list',
{
authRequired: true,
query: ajv.compile<{ uid?: string }>({
query: ajvQuery.compile<{ uid?: string }>({
type: 'object',
properties: {
uid: {
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/api/server/v1/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { IPermission } from '@rocket.chat/core-typings';
import { Permissions, Roles } from '@rocket.chat/models';
import {
ajv,
ajvQuery,
validateUnauthorizedErrorResponse,
validateBadRequestErrorResponse,
validateForbiddenErrorResponse,
Expand Down Expand Up @@ -57,7 +58,7 @@ const permissionUpdatePropsSchema = {
additionalProperties: false,
};

const isPermissionsListAll = ajv.compile<PermissionsListAllProps>(permissionListAllSchema);
const isPermissionsListAll = ajvQuery.compile<PermissionsListAllProps>(permissionListAllSchema);

const isBodyParamsValidPermissionUpdate = ajv.compile<PermissionsUpdateProps>(permissionUpdatePropsSchema);

Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/app/api/server/v1/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { IRole, IUserInRole } from '@rocket.chat/core-typings';
import { Roles, Users } from '@rocket.chat/models';
import {
ajv,
ajvQuery,
isRoleAddUserToRoleProps,
isRoleDeleteProps,
isRoleRemoveUserFromRoleProps,
Expand All @@ -25,7 +26,7 @@ import { API } from '../api';
import { getPaginationItems } from '../helpers/getPaginationItems';
import { getUserFromParams } from '../helpers/getUserFromParams';

const rolesSyncQuerySchema = ajv.compile<{ updatedSince?: string }>({
const rolesSyncQuerySchema = ajvQuery.compile<{ updatedSince?: string }>({
type: 'object',
properties: { updatedSince: { type: 'string' } },
additionalProperties: false,
Expand Down
5 changes: 3 additions & 2 deletions apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Messages, Rooms, Users, Uploads, Subscriptions } from '@rocket.chat/mod
import type { Notifications } from '@rocket.chat/rest-typings';
import {
ajv,
ajvQuery,
isGETRoomsNameExists,
isRoomsImagesProps,
isRoomsMuteUnmuteUserProps,
Expand Down Expand Up @@ -1042,7 +1043,7 @@ export const roomEndpoints = API.v1
'rooms.roles',
{
authRequired: true,
query: ajv.compile<{
query: ajvQuery.compile<{
rid: string;
}>(isRoomGetRolesPropsSchema),
response: {
Expand Down Expand Up @@ -1089,7 +1090,7 @@ export const roomEndpoints = API.v1
{
authRequired: true,
permissionsRequired: ['view-room-administration'],
query: ajv.compile<{
query: ajvQuery.compile<{
filter?: string;
offset?: number;
count?: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ export const upsertPermissions = async (): Promise<void> => {
level: CONSTANTS.SETTINGS_LEVEL as 'settings' | undefined,
// copy those setting-properties which are needed to properly publish the setting-based permissions
settingId: setting._id,
group: setting.group,
section: setting.section ?? undefined,
// TODO: migrate settings with group and section with null to undefined
...(setting.group && { group: setting.group }),
...(setting.section && { section: setting.section }),
sorter: setting.sorter,
roles: [],
};
Expand Down
15 changes: 12 additions & 3 deletions apps/meteor/app/livechat/server/api/v1/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,18 @@ API.v1.addRoute(
} = this.bodyParams;

const settingsIds = [
typeof LivechatWebhookUrl !== 'undefined' && { _id: 'Livechat_webhookUrl', value: trim(LivechatWebhookUrl) },
typeof LivechatSecretToken !== 'undefined' && { _id: 'Livechat_secret_token', value: trim(LivechatSecretToken) },
typeof LivechatHttpTimeout !== 'undefined' && { _id: 'Livechat_http_timeout', value: LivechatHttpTimeout },
typeof LivechatWebhookUrl !== 'undefined' && {
_id: 'Livechat_webhookUrl',
value: trim(String(LivechatWebhookUrl ?? '')),
},
typeof LivechatSecretToken !== 'undefined' && {
_id: 'Livechat_secret_token',
value: trim(String(LivechatSecretToken ?? '')),
},
typeof LivechatHttpTimeout !== 'undefined' && {
_id: 'Livechat_http_timeout',
value: Number(LivechatHttpTimeout ?? 0),
},
typeof LivechatWebhookOnStart !== 'undefined' && { _id: 'Livechat_webhook_on_start', value: !!LivechatWebhookOnStart },
typeof LivechatWebhookOnClose !== 'undefined' && { _id: 'Livechat_webhook_on_close', value: !!LivechatWebhookOnClose },
typeof LivechatWebhookOnChatTaken !== 'undefined' && { _id: 'Livechat_webhook_on_chat_taken', value: !!LivechatWebhookOnChatTaken },
Expand Down
6 changes: 3 additions & 3 deletions apps/meteor/client/providers/AvatarUrlProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ type AvatarUrlProviderProps = {

const AvatarUrlProvider = ({ children }: AvatarUrlProviderProps) => {
const contextValue = useMemo(() => {
function getUserPathAvatar(username: string, etag?: string): string;
function getUserPathAvatar({ userId, etag }: { userId: string; etag?: string }): string;
function getUserPathAvatar({ username, etag }: { username: string; etag?: string }): string;
function getUserPathAvatar(username: string, etag?: string | null): string;
function getUserPathAvatar({ userId, etag }: { userId: string; etag?: string | null }): string;
function getUserPathAvatar({ username, etag }: { username: string; etag?: string | null }): string;
function getUserPathAvatar(...args: any): string {
if (typeof args[0] === 'string') {
const [username, etag] = args;
Expand Down
4 changes: 4 additions & 0 deletions apps/meteor/client/views/banners/UiKitBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ const UiKitBanner = ({ initialView }: UiKitBannerProps) => {
});
});

// TODO: check why we are not considering TextObject as title
if (view.title && typeof view.title !== 'string') {
return null;
}
return (
<Banner icon={icon} inline={view.inline} title={view.title} variant={view.variant} closeable onClose={handleClose}>
<UiKitContext.Provider value={contextValue}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useOutlookOpenCall } from './hooks/useOutlookOpenCall';
type OutlookCalendarEventModalProps = ComponentProps<typeof GenericModal> & {
id?: string;
subject?: string;
meetingUrl?: string;
meetingUrl?: string | null;
description?: string;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useUser } from '@rocket.chat/ui-contexts';

import { useVideoConfOpenCall } from '../../room/contextualBar/VideoConference/hooks/useVideoConfOpenCall';

export const useOutlookOpenCall = (meetingUrl?: string) => {
export const useOutlookOpenCall = (meetingUrl?: string | null) => {
const user = useUser();
const handleOpenCall = useVideoConfOpenCall();
const userDisplayName = useUserDisplayName({ name: user?.name, username: user?.username });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const VideoConfListItem = ({
data-tooltip={user.username}
key={user.username}
username={user.username}
etag={user.avatarETag}
etag={user.avatarETag ?? undefined}
size='x28'
/>
),
Expand Down
8 changes: 4 additions & 4 deletions apps/meteor/ee/server/api/abac/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IAbacAttribute, IAbacAttributeDefinition, IAuditServerActor, IRoom, IServerEvents } from '@rocket.chat/core-typings';
import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings';
import { ajv } from '@rocket.chat/rest-typings';
import { ajv, ajvQuery } from '@rocket.chat/rest-typings';

const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$';
const MAX_ROOM_ATTRIBUTE_VALUES = 10;
Expand Down Expand Up @@ -64,7 +64,7 @@ const GetAbacAttributesQuery = {
additionalProperties: false,
};

export const GETAbacAttributesQuerySchema = ajv.compile<{ key?: string; values?: string; offset: number; count?: number }>(
export const GETAbacAttributesQuerySchema = ajvQuery.compile<{ key?: string; values?: string; offset: number; count?: number }>(
GetAbacAttributesQuery,
);

Expand Down Expand Up @@ -182,7 +182,7 @@ const GetAbacAuditEventsQuerySchemaObject = {
additionalProperties: false,
};

export const GETAbacAuditEventsQuerySchema = ajv.compile<
export const GETAbacAuditEventsQuerySchema = ajvQuery.compile<
PaginatedRequest<{
start?: string;
end?: string;
Expand Down Expand Up @@ -355,7 +355,7 @@ const GETAbacRoomsListQuerySchema = {

type GETAbacRoomsListQuery = PaginatedRequest<{ filter?: string; filterType?: 'all' | 'roomName' | 'attribute' | 'value' }>;

export const GETAbacRoomsListQueryValidator = ajv.compile<GETAbacRoomsListQuery>(GETAbacRoomsListQuerySchema);
export const GETAbacRoomsListQueryValidator = ajvQuery.compile<GETAbacRoomsListQuery>(GETAbacRoomsListQuerySchema);

export const GETAbacRoomsResponseSchema = {
type: 'object',
Expand Down
12 changes: 6 additions & 6 deletions apps/meteor/ee/server/api/audit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IUser, IRoom } from '@rocket.chat/core-typings';
import { Rooms, AuditLog, ServerEvents } from '@rocket.chat/models';
import { isServerEventsAuditSettingsProps, ajv } from '@rocket.chat/rest-typings';
import { isServerEventsAuditSettingsProps, ajv, ajvQuery } from '@rocket.chat/rest-typings';
import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings';
import { convertSubObjectsIntoPaths } from '@rocket.chat/tools';

Expand All @@ -26,7 +26,7 @@ const auditRoomMembersSchema = {
additionalProperties: false,
};

export const isAuditRoomMembersProps = ajv.compile<AuditRoomMembersParams>(auditRoomMembersSchema);
export const isAuditRoomMembersProps = ajvQuery.compile<AuditRoomMembersParams>(auditRoomMembersSchema);

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down Expand Up @@ -154,11 +154,11 @@ API.v1.get(
async function action() {
const { start, end, settingId, actor } = this.queryParams;

if (start && isNaN(Date.parse(start as string))) {
if (start && isNaN(Date.parse(start))) {
return API.v1.failure('The "start" query parameter must be a valid date.');
}

if (end && isNaN(Date.parse(end as string))) {
if (end && isNaN(Date.parse(end))) {
return API.v1.failure('The "end" query parameter must be a valid date.');
}

Expand All @@ -171,8 +171,8 @@ API.v1.get(
...(settingId && { 'data.key': 'id', 'data.value': settingId }),
...(actor && convertSubObjectsIntoPaths({ actor })),
ts: {
$gte: start ? new Date(start as string) : new Date(0),
$lte: end ? new Date(end as string) : new Date(),
$gte: start ? new Date(start) : new Date(0),
$lte: end ? new Date(end) : new Date(),
},
t: 'settings.changed',
},
Expand Down
Loading
Loading