diff --git a/apps/meteor/app/api/server/api.ts b/apps/meteor/app/api/server/api.ts index 0761dec1e1b1d..b603274ab8597 100644 --- a/apps/meteor/app/api/server/api.ts +++ b/apps/meteor/app/api/server/api.ts @@ -7,8 +7,7 @@ import type { JoinPathPattern, Method } from '@rocket.chat/rest-typings'; import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; import { wrapExceptions } from '@rocket.chat/tools'; import type { ValidateFunction } from 'ajv'; -import express from 'express'; -import type { Request, Response } from 'express'; +import type express from 'express'; import { Accounts } from 'meteor/accounts-base'; import { DDP } from 'meteor/ddp'; import { DDPCommon } from 'meteor/ddp-common'; @@ -42,6 +41,7 @@ import { parseJsonQuery } from './helpers/parseJsonQuery'; import { cors } from './middlewares/cors'; import { loggerMiddleware } from './middlewares/logger'; import { metricsMiddleware } from './middlewares/metrics'; +import { remoteAddressMiddleware } from './middlewares/remoteAddressMiddleware'; import { tracerSpanMiddleware } from './middlewares/tracer'; import type { Route } from './router'; import { Router } from './router'; @@ -105,34 +105,6 @@ const rateLimiterDictionary: Record< } > = {}; -const getRequestIP = (req: Request): string | null => { - const socket = req.socket || (req.connection as any)?.socket; - const remoteAddress = String( - req.headers['x-real-ip'] || (typeof socket !== 'string' && (socket?.remoteAddress || req.connection?.remoteAddress || null)), - ); - const forwardedFor = String(req.headers['x-forwarded-for']); - - if (!socket) { - return remoteAddress || forwardedFor || null; - } - - const httpForwardedCount = parseInt(String(process.env.HTTP_FORWARDED_COUNT)) || 0; - if (httpForwardedCount <= 0) { - return remoteAddress; - } - - if (!forwardedFor || typeof forwardedFor.valueOf() !== 'string') { - return remoteAddress; - } - - const forwardedForIPs = forwardedFor.trim().split(/\s*,\s*/); - if (httpForwardedCount > forwardedForIPs.length) { - return remoteAddress; - } - - return forwardedForIPs[forwardedForIPs.length - httpForwardedCount]; -}; - const generateConnection = ( ipAddress: string, httpHeaders: Record, @@ -220,7 +192,6 @@ export class APIClass< services: 0, inviteToken: 0, }; - this.router = new Router(`/${this.apiPath}`.replace(/\/$/, '').replaceAll('//', '/')); if (useDefaultAuth) { @@ -408,9 +379,12 @@ export class APIClass< rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch); const attemptResult = await rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000); - response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed ?? ''); - response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft); - response.setHeader('X-RateLimit-Reset', new Date().getTime() + attemptResult.timeToReset); + response.headers.set( + 'X-RateLimit-Limit', + String(rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed ?? ''), + ); + response.headers.set('X-RateLimit-Remaining', String(attemptResult.numInvocationsLeft)); + response.headers.set('X-RateLimit-Reset', String(new Date().getTime() + attemptResult.timeToReset)); if (!attemptResult.allowed) { throw new Meteor.Error( @@ -494,8 +468,8 @@ export class APIClass< if (options && (!('twoFactorRequired' in options) || !options.twoFactorRequired)) { return; } - const code = request.headers['x-2fa-code'] ? String(request.headers['x-2fa-code']) : undefined; - const method = request.headers['x-2fa-method'] ? String(request.headers['x-2fa-method']) : undefined; + const code = request.headers.get('x-2fa-code') ? String(request.headers.get('x-2fa-code')) : undefined; + const method = request.headers.get('x-2fa-method') ? String(request.headers.get('x-2fa-method')) : undefined; await checkCodeForUser({ user: userId, @@ -781,14 +755,12 @@ export class APIClass< const api = this; (operations[method as keyof Operations] as Record).action = async function _internalRouteActionHandler() { - this.requestIp = getRequestIP(this.request)!; - if (options.authRequired || options.authOrAnonRequired) { - const user = await api.authenticatedRoute(this.request); + const user = await api.authenticatedRoute.call(this, this.request); this.user = user!; - this.userId = String(this.request.headers['x-user-id']); - this.token = (this.request.headers['x-auth-token'] && - Accounts._hashLoginToken(String(this.request.headers['x-auth-token'])))!; + this.userId = String(this.request.headers.get('x-user-id')); + const authToken = this.request.headers.get('x-auth-token'); + this.token = (authToken && Accounts._hashLoginToken(String(authToken)))!; } if (!this.user && options.authRequired && !options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead')) { @@ -806,7 +778,7 @@ export class APIClass< const objectForRateLimitMatch = { IPAddr: this.requestIp, - route: `/${api.apiPath}${this.request.route.path}${this.request.method.toLowerCase()}`, + route: `/${route}${this.request.method.toLowerCase()}`, }; let result; @@ -932,9 +904,11 @@ export class APIClass< } protected async authenticatedRoute(req: Request): Promise { - const { 'x-user-id': userId } = req.headers; + const headers = Object.fromEntries(req.headers.entries()); - const userToken = String(req.headers['x-auth-token']); + const { 'x-user-id': userId } = headers; + + const userToken = String(headers['x-auth-token']); if (userId && userToken) { return Users.findOne( @@ -973,7 +947,7 @@ export class APIClass< return bodyParams; } - const code = bodyCode || request.headers['x-2fa-code']; + const code = bodyCode || request.headers.get('x-2fa-code'); const auth: Record = { password, @@ -1035,7 +1009,7 @@ export class APIClass< const args = loginCompatibility(this.bodyParams, request); const invocation = new DDPCommon.MethodInvocation({ - connection: generateConnection(getRequestIP(request) || '', this.request.headers), + connection: generateConnection(this.requestIp || '', this.request.headers), }); try { @@ -1227,13 +1201,10 @@ settings.watch('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => API.v1.reloadRoutesToRefreshRateLimiter(); }); -Meteor.startup(() => { - (WebApp.connectHandlers as unknown as ReturnType).use( +export const startRestAPI = () => { + (WebApp.rawConnectHandlers as unknown as ReturnType).use( API.api - .use((_req, res, next) => { - res.removeHeader('X-Powered-By'); - next(); - }) + .use(remoteAddressMiddleware) .use(cors(settings)) .use(loggerMiddleware(logger)) .use(metricsMiddleware(API.v1, settings, metrics.rocketchatRestApi)) @@ -1241,18 +1212,4 @@ Meteor.startup(() => { .use(API.v1.router) .use(API.default.router).router, ); -}); - -(WebApp.connectHandlers as unknown as ReturnType) - .use( - express.json({ - limit: '50mb', - }), - ) - .use( - express.urlencoded({ - extended: true, - limit: '50mb', - }), - ) - .use(express.query({})); +}; diff --git a/apps/meteor/app/api/server/definition.ts b/apps/meteor/app/api/server/definition.ts index e07ab21446adf..d2da73d500d36 100644 --- a/apps/meteor/app/api/server/definition.ts +++ b/apps/meteor/app/api/server/definition.ts @@ -2,7 +2,6 @@ import type { IUser, LicenseModule } from '@rocket.chat/core-typings'; import type { Logger } from '@rocket.chat/logger'; import type { Method, MethodOf, OperationParams, OperationResult, PathPattern, UrlParams } from '@rocket.chat/rest-typings'; import type { ValidateFunction } from 'ajv'; -import type { Request, Response } from 'express'; import type { ITwoFactorOptions } from '../../2fa/server/code'; @@ -12,7 +11,7 @@ export type RedirectStatusCodes = Exclude, Range<300>>; export type AuthorizationStatusCodes = Exclude, Range<400>>; -export type ErrorStatusCodes = Exclude, Range<500>>; +export type ErrorStatusCodes = Exclude, Range<500>>, 509>; export type SuccessResult = { statusCode: TStatusCode; @@ -137,6 +136,7 @@ export type PartialThis = { readonly response: Response; readonly userId: string; readonly bodyParams: Record; + readonly path: string; readonly queryParams: Record; readonly queryOperations?: string[]; readonly queryFields?: string[]; diff --git a/apps/meteor/app/api/server/helpers/getLoggedInUser.ts b/apps/meteor/app/api/server/helpers/getLoggedInUser.ts index 55c7c2d219557..d3fc562eeb20f 100644 --- a/apps/meteor/app/api/server/helpers/getLoggedInUser.ts +++ b/apps/meteor/app/api/server/helpers/getLoggedInUser.ts @@ -1,11 +1,10 @@ import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; -import type { Request } from 'express'; import { Accounts } from 'meteor/accounts-base'; export async function getLoggedInUser(request: Request): Promise | null> { - const token = request.headers['x-auth-token']; - const userId = request.headers['x-user-id']; + const token = request.headers.get('x-auth-token'); + const userId = request.headers.get('x-user-id'); if (!token || !userId || typeof token !== 'string' || typeof userId !== 'string') { return null; } diff --git a/apps/meteor/app/api/server/helpers/isWidget.ts b/apps/meteor/app/api/server/helpers/isWidget.ts index 49fbe84111d7b..258820ff7de5e 100644 --- a/apps/meteor/app/api/server/helpers/isWidget.ts +++ b/apps/meteor/app/api/server/helpers/isWidget.ts @@ -1,7 +1,7 @@ import { parse } from 'cookie'; -export const isWidget = (headers: Record = {}): boolean => { - const { rc_room_type: roomType, rc_is_widget: isWidget } = parse(headers.cookie || ''); +export const isWidget = (headers: Headers): boolean => { + const { rc_room_type: roomType, rc_is_widget: isWidget } = parse(headers.get('cookie') || ''); const isLivechatRoom = roomType && roomType === 'l'; return !!(isLivechatRoom && isWidget === 't'); diff --git a/apps/meteor/app/api/server/helpers/parseJsonQuery.ts b/apps/meteor/app/api/server/helpers/parseJsonQuery.ts index 16f370e15bd40..068e808751e52 100644 --- a/apps/meteor/app/api/server/helpers/parseJsonQuery.ts +++ b/apps/meteor/app/api/server/helpers/parseJsonQuery.ts @@ -25,13 +25,13 @@ export async function parseJsonQuery(api: PartialThis): Promise<{ query: Record; }> { const { - request: { path: route }, userId, queryParams: params, logger, queryFields, queryOperations, response, + request: { route }, } = api; let sort; @@ -60,7 +60,7 @@ export async function parseJsonQuery(api: PartialThis): Promise<{ let fields: Record | undefined; if (params.fields && isUnsafeQueryParamsAllowed) { try { - apiDeprecationLogger.parameter(route, 'fields', '8.0.0', response, messageGenerator); + apiDeprecationLogger.parameter(api.path, 'fields', '8.0.0', response, messageGenerator); fields = JSON.parse(params.fields) as Record; Object.entries(fields).forEach(([key, value]) => { if (value !== 1 && value !== 0) { @@ -99,7 +99,7 @@ export async function parseJsonQuery(api: PartialThis): Promise<{ // Limit the fields by default fields = Object.assign({}, fields, API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { + if (api.path.includes('/v1/users.')) { if (await hasPermissionAsync(userId, 'view-full-other-user-info')) { fields = Object.assign(fields, API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser); } else { @@ -109,7 +109,7 @@ export async function parseJsonQuery(api: PartialThis): Promise<{ let query: Record = {}; if (params.query && isUnsafeQueryParamsAllowed) { - apiDeprecationLogger.parameter(route, 'query', '8.0.0', response, messageGenerator); + apiDeprecationLogger.parameter(api.path, 'query', '8.0.0', response, messageGenerator); try { query = ejson.parse(params.query); query = clean(query, pathAllowConf.def); @@ -125,7 +125,7 @@ export async function parseJsonQuery(api: PartialThis): Promise<{ if (typeof query === 'object') { let nonQueryableFields = Object.keys(API.v1.defaultFieldsToExclude); - if (route.includes('/v1/users.')) { + if (api.path.includes('/v1/users.')) { if (await hasPermissionAsync(userId, 'view-full-other-user-info')) { nonQueryableFields = nonQueryableFields.concat(Object.keys(API.v1.limitedUserFieldsToExcludeIfIsPrivilegedUser)); } else { diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.spec.ts b/apps/meteor/app/api/server/lib/getUploadFormData.spec.ts index dc7afb77bd197..c9cced78f9591 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.spec.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.spec.ts @@ -1,7 +1,4 @@ -import { Readable } from 'stream'; - import { expect } from 'chai'; -import type { Request } from 'express'; import { getUploadFormData } from './getUploadFormData'; @@ -13,7 +10,7 @@ const createMockRequest = ( content: string | Buffer; mimetype?: string; }, -): Readable & { headers: Record } => { +): Request => { const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'; const parts: string[] = []; @@ -33,18 +30,31 @@ const createMockRequest = ( parts.push(`--${boundary}--`); - const mockRequest: any = new Readable({ - read() { - this.push(Buffer.from(parts.join('\r\n'))); - this.push(null); - }, - }); + const buffer = Buffer.from(parts.join('\r\n')); - mockRequest.headers = { - 'content-type': `multipart/form-data; boundary=${boundary}`, + const mockRequest: any = { + headers: { + entries: () => [['content-type', `multipart/form-data; boundary=${boundary}`]], + }, + blob: async () => ({ + stream: () => { + let hasRead = false; + return { + getReader: () => ({ + read: async () => { + if (!hasRead) { + hasRead = true; + return { value: buffer, done: false }; + } + return { done: true }; + }, + }), + }; + }, + }), }; - return mockRequest as Readable & { headers: Record }; + return mockRequest as Request & { headers: Record }; }; describe('getUploadFormData', () => { @@ -59,7 +69,7 @@ describe('getUploadFormData', () => { }, ); - const result = await getUploadFormData({ request: mockRequest as Request }, { field: 'fileField' }); + const result = await getUploadFormData({ request: mockRequest }, { field: 'fileField' }); expect(result).to.deep.include({ fieldname: 'fileField', @@ -86,7 +96,7 @@ describe('getUploadFormData', () => { }, ); - const result = await getUploadFormData({ request: mockRequest as Request }, { field: 'fileField' }); + const result = await getUploadFormData({ request: mockRequest }, { field: 'fileField' }); expect(result).to.deep.include({ fieldname: 'fileField', @@ -114,7 +124,7 @@ describe('getUploadFormData', () => { }, ); - const result = await getUploadFormData({ request: mockRequest as Request }, { fileOptional: true }); + const result = await getUploadFormData({ request: mockRequest }, { fileOptional: true }); expect(result).to.deep.include({ fieldname: 'fileField', @@ -131,7 +141,7 @@ describe('getUploadFormData', () => { const mockRequest = createMockRequest({ fieldName: 'fieldValue' }); try { - await getUploadFormData({ request: mockRequest as Request }, { fileOptional: false }); + await getUploadFormData({ request: mockRequest }, { fileOptional: false }); throw new Error('Expected function to throw'); } catch (error) { expect((error as Error).message).to.equal('[No file uploaded]'); @@ -141,7 +151,7 @@ describe('getUploadFormData', () => { it('should return fields without errors when no file is uploaded but fileOptional is true', async () => { const mockRequest = createMockRequest({ fieldName: 'fieldValue' }); // No file - const result = await getUploadFormData({ request: mockRequest as Request }, { fileOptional: true }); + const result = await getUploadFormData({ request: mockRequest }, { fileOptional: true }); expect(result).to.deep.equal({ fields: { fieldName: 'fieldValue' }, @@ -167,7 +177,7 @@ describe('getUploadFormData', () => { try { await getUploadFormData( - { request: mockRequest as Request }, + { request: mockRequest }, { sizeLimit: 1024 * 1024 }, // 1 MB limit ); throw new Error('Expected function to throw'); diff --git a/apps/meteor/app/api/server/lib/getUploadFormData.ts b/apps/meteor/app/api/server/lib/getUploadFormData.ts index 93ceafdde92f8..bf9b792e9b08e 100644 --- a/apps/meteor/app/api/server/lib/getUploadFormData.ts +++ b/apps/meteor/app/api/server/lib/getUploadFormData.ts @@ -1,9 +1,8 @@ -import type { Readable } from 'stream'; +import { Readable } from 'stream'; import { MeteorError } from '@rocket.chat/core-services'; import type { ValidateFunction } from 'ajv'; import busboy from 'busboy'; -import type { Request } from 'express'; import { getMimeType } from '../../../utils/lib/mimeTypes'; @@ -71,7 +70,7 @@ export async function getUploadFormData< ...(options.sizeLimit && options.sizeLimit > -1 && { fileSize: options.sizeLimit }), }; - const bb = busboy({ headers: request.headers, defParamCharset: 'utf8', limits }); + const bb = busboy({ headers: Object.fromEntries(request.headers.entries()), defParamCharset: 'utf8', limits }); const fields = Object.create(null) as K; let uploadedFile: UploadResultWithOptionalFile | undefined = { @@ -142,8 +141,6 @@ export async function getUploadFormData< } function cleanup() { - request.unpipe(bb); - request.on('readable', request.read.bind(request)); bb.removeAllListeners(); } @@ -167,7 +164,29 @@ export async function getUploadFormData< returnError(); }); - request.pipe(bb); + const webReadableStream = await request.blob().then((blob) => blob.stream()); + + const nodeReadableStream = new Readable({ + async read() { + const reader = webReadableStream.getReader(); + try { + const processChunk = async () => { + const { done, value } = await reader.read(); + if (done) { + this.push(null); + return; + } + this.push(Buffer.from(value)); + await processChunk(); + }; + await processChunk(); + } catch (err: any) { + this.destroy(err); + } + }, + }); + + nodeReadableStream.pipe(bb); return new Promise>((resolve, reject) => { returnResult = resolve; diff --git a/apps/meteor/app/api/server/middlewares/cors.ts b/apps/meteor/app/api/server/middlewares/cors.ts index db6dde775918a..44f7e39acafc0 100644 --- a/apps/meteor/app/api/server/middlewares/cors.ts +++ b/apps/meteor/app/api/server/middlewares/cors.ts @@ -1,4 +1,4 @@ -import type { NextFunction, Request, Response } from 'express'; +import type { MiddlewareHandler } from 'hono'; import type { CachedSettings } from '../../../settings/server/CachedSettings'; @@ -7,55 +7,55 @@ const defaultHeaders = { 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', }; -export const cors = (settings: CachedSettings) => (req: Request, res: Response, next: NextFunction) => { - if (req.method !== 'OPTIONS') { - if (settings.get('API_Enable_CORS')) { - res.setHeader('Vary', 'Origin'); - res.setHeader('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); - res.setHeader('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); +export const cors = + (settings: CachedSettings): MiddlewareHandler => + async (c, next) => { + const { req, res } = c; + if (req.method !== 'OPTIONS') { + if (settings.get('API_Enable_CORS')) { + res.headers.set('Vary', 'Origin'); + res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); + res.headers.set('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); + } + + await next(); + return; } - next(); - return; - } - - // check if a pre-flight request - if (!req.headers['access-control-request-method'] && !req.headers.origin) { - next(); - return; - } - - if (!settings.get('API_Enable_CORS')) { - res.writeHead(405); - res.write('CORS not enabled. Go to "Admin > General > REST Api" to enable it.'); - res.end(); - return; - } - - const CORSOriginSetting = String(settings.get('API_CORS_Origin')); - - if (CORSOriginSetting === '*') { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); - res.setHeader('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); - next(); - return; - } - - const origins = CORSOriginSetting.trim() - .split(',') - .map((origin) => String(origin).trim().toLocaleLowerCase()); - - // if invalid origin reply without required CORS headers - if (!req.headers.origin || !origins.includes(req.headers.origin)) { - res.writeHead(403, 'Forbidden'); - res.end(); - return; - } - - res.setHeader('Vary', 'Origin'); - res.setHeader('Access-Control-Allow-Origin', req.headers.origin); - res.setHeader('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); - res.setHeader('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); - next(); -}; + // check if a pre-flight request + if (!req.header('access-control-request-method') && !req.header('origin')) { + await next(); + return; + } + + if (!settings.get('API_Enable_CORS')) { + return c.body('CORS not enabled. Go to "Admin > General > REST Api" to enable it.', 405); + } + + const CORSOriginSetting = String(settings.get('API_CORS_Origin')); + + if (CORSOriginSetting === '*') { + res.headers.set('Access-Control-Allow-Origin', '*'); + res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); + res.headers.set('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); + await next(); + return; + } + + const origins = CORSOriginSetting.trim() + .split(',') + .map((origin) => String(origin).trim().toLocaleLowerCase()); + + const originHeader = req.header('origin'); + + // if invalid origin reply without required CORS headers + if (!originHeader || !origins.includes(originHeader)) { + return c.body('Invalid origin', 403); + } + + res.headers.set('Vary', 'Origin'); + res.headers.set('Access-Control-Allow-Origin', originHeader); + res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); + res.headers.set('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); + await next(); + }; diff --git a/apps/meteor/app/api/server/middlewares/honoAdapter.ts b/apps/meteor/app/api/server/middlewares/honoAdapter.ts new file mode 100644 index 0000000000000..86d9e83cb3a1b --- /dev/null +++ b/apps/meteor/app/api/server/middlewares/honoAdapter.ts @@ -0,0 +1,31 @@ +import { Readable } from 'stream'; + +import type { Request, Response } from 'express'; +import type { Hono } from 'hono'; + +export const honoAdapter = (hono: Hono) => async (expressReq: Request, res: Response) => { + (expressReq as unknown as any).duplex = 'half'; + + if (Readable.isDisturbed(expressReq)) { + return; + } + + const { body, ...req } = expressReq; + + const honoRes = await hono.request( + expressReq.originalUrl, + { + ...req, + ...(['POST', 'PUT', 'DELETE'].includes(expressReq.method) && { body: expressReq as unknown as ReadableStream }), + headers: new Headers(Object.fromEntries(Object.entries(expressReq.headers)) as Record), + }, + { + incoming: expressReq, + }, + ); + res.status(honoRes.status); + honoRes.headers.forEach((value, key) => res.setHeader(key, value)); + // Converting it to a Buffer because res.send appends always a charset to the Content-Type + // https://github.com/expressjs/express/issues/2238 + res.send(Buffer.from(await honoRes.text())); +}; diff --git a/apps/meteor/app/api/server/middlewares/logger.ts b/apps/meteor/app/api/server/middlewares/logger.ts index 5233435a19a41..a9a733de86c4c 100644 --- a/apps/meteor/app/api/server/middlewares/logger.ts +++ b/apps/meteor/app/api/server/middlewares/logger.ts @@ -1,28 +1,36 @@ import type { Logger } from '@rocket.chat/logger'; -import type { Request, Response, NextFunction } from 'express'; +import type { MiddlewareHandler } from 'hono'; import { getRestPayload } from '../../../../server/lib/logger/logPayloads'; -export const loggerMiddleware = (logger: Logger) => async (req: Request, res: Response, next: NextFunction) => { - const startTime = Date.now(); +export const loggerMiddleware = + (logger: Logger): MiddlewareHandler => + async (c, next) => { + const startTime = Date.now(); + + let payload = {}; + + try { + payload = await c.req.raw.clone().json(); + // eslint-disable-next-line no-empty + } catch {} + + const log = logger.logger.child({ + method: c.req.method, + url: c.req.url, + userId: c.req.header('x-user-id'), + userAgent: c.req.header('user-agent'), + length: c.req.header('content-length'), + host: c.req.header('host'), + referer: c.req.header('referer'), + remoteIP: c.get('remoteAddress'), + ...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method) && getRestPayload(payload)), + }); + + await next(); - const log = logger.logger.child({ - method: req.method, - url: req.url, - userId: req.headers['x-user-id'], - userAgent: req.headers['user-agent'], - length: req.headers['content-length'], - host: req.headers.host, - referer: req.headers.referer, - remoteIP: req.ip, - ...getRestPayload(req.body), - }); - res.once('finish', () => { log.http({ - status: res.statusCode, + status: c.res.status, responseTime: Date.now() - startTime, }); - }); - - next(); -}; + }; diff --git a/apps/meteor/app/api/server/middlewares/metrics.ts b/apps/meteor/app/api/server/middlewares/metrics.ts index 9206a51375001..518febc7132a4 100644 --- a/apps/meteor/app/api/server/middlewares/metrics.ts +++ b/apps/meteor/app/api/server/middlewares/metrics.ts @@ -1,24 +1,23 @@ -import type { Request, Response, NextFunction } from 'express'; +import type { MiddlewareHandler } from 'hono'; import type { Summary } from 'prom-client'; import type { CachedSettings } from '../../../settings/server/CachedSettings'; import type { APIClass } from '../api'; export const metricsMiddleware = - (api: APIClass, settings: CachedSettings, summary: Summary) => async (req: Request, res: Response, next: NextFunction) => { - const { method, path } = req; + (api: APIClass, settings: CachedSettings, summary: Summary): MiddlewareHandler => + async (c, next) => { + const { method, path } = c.req; const rocketchatRestApiEnd = summary.startTimer({ method, version: api.version, - ...(settings.get('Prometheus_API_User_Agent') && { user_agent: req.headers['user-agent'] }), - entrypoint: path.startsWith('method.call') ? decodeURIComponent(req.url.slice(8)) : path, + ...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }), + entrypoint: path.startsWith('method.call') ? decodeURIComponent(c.req.url.slice(8)) : path, }); - res.once('finish', () => { - rocketchatRestApiEnd({ - status: res.statusCode, - }); + await next(); + rocketchatRestApiEnd({ + status: c.res.status, }); - next(); }; diff --git a/apps/meteor/app/api/server/middlewares/remoteAddressMiddleware.ts b/apps/meteor/app/api/server/middlewares/remoteAddressMiddleware.ts new file mode 100644 index 0000000000000..6e129e88f467a --- /dev/null +++ b/apps/meteor/app/api/server/middlewares/remoteAddressMiddleware.ts @@ -0,0 +1,40 @@ +import type { IncomingMessage } from 'http'; + +import type { Context, MiddlewareHandler } from 'hono'; + +type HttpBindings = { + incoming: IncomingMessage; +}; + +const getRemoteAddress = (c: Context) => { + const bindings = (c.env?.server ? c.env.server : c.env) as HttpBindings; + + const forwardedFor = c.req.header('x-forwarded-for'); + const socket = bindings.incoming.socket.remoteAddress || bindings.incoming.connection.remoteAddress; + const remoteAddress = c.req.header('x-real-ip') || socket; + + if (!socket) { + return remoteAddress || forwardedFor; + } + + const httpForwardedCount = parseInt(String(process.env.HTTP_FORWARDED_COUNT)) || 0; + if (httpForwardedCount <= 0) { + return remoteAddress; + } + + if (!forwardedFor || typeof forwardedFor.valueOf() !== 'string') { + return remoteAddress; + } + + const forwardedForIPs = forwardedFor.trim().split(/\s*,\s*/); + if (httpForwardedCount > forwardedForIPs.length) { + return remoteAddress; + } + return forwardedForIPs[forwardedForIPs.length - httpForwardedCount]; +}; + +export const remoteAddressMiddleware: MiddlewareHandler = async function (c, next) { + const remoteAddress = getRemoteAddress(c); + c.set('remoteAddress', remoteAddress); + return next(); +}; diff --git a/apps/meteor/app/api/server/middlewares/tracer.ts b/apps/meteor/app/api/server/middlewares/tracer.ts index e229672598aa1..bc3f03778cda7 100644 --- a/apps/meteor/app/api/server/middlewares/tracer.ts +++ b/apps/meteor/app/api/server/middlewares/tracer.ts @@ -1,32 +1,25 @@ import { tracerSpan } from '@rocket.chat/tracing'; -import type { Request, Response, NextFunction } from 'express'; +import type { MiddlewareHandler } from 'hono'; -export const tracerSpanMiddleware = async (req: Request, res: Response, next: NextFunction) => { - try { - await tracerSpan( - `${req.method} ${req.url}`, - { - attributes: { - url: req.url, - route: req.route?.path, - method: req.method, - userId: req.userId, // Assuming userId is attached to the request object - }, +export const tracerSpanMiddleware: MiddlewareHandler = async (c, next) => { + return tracerSpan( + `${c.req.method} ${c.req.url}`, + { + attributes: { + url: c.req.url, + // route: c.req.route?.path, + method: c.req.method, + userId: (c.req.raw.clone() as any).userId, // Assuming userId is attached to the request object }, - async (span) => { - if (span) { - res.setHeader('X-Trace-Id', span.spanContext().traceId); - } + }, + async (span) => { + if (span) { + c.header('X-Trace-Id', span.spanContext().traceId); + } - next(); + await next(); - await new Promise((resolve) => { - res.once('finish', resolve); - }); - span?.setAttribute('status', res.statusCode); - }, - ); - } catch (error) { - next(error); - } + span?.setAttribute('status', c.res.status); + }, + ); }; diff --git a/apps/meteor/app/api/server/router.spec.ts b/apps/meteor/app/api/server/router.spec.ts index 36997fc23d604..ca5bd8970ae84 100644 --- a/apps/meteor/app/api/server/router.spec.ts +++ b/apps/meteor/app/api/server/router.spec.ts @@ -9,7 +9,10 @@ describe('Router use method', () => { const ajv = new Ajv(); const app = express(); const api = new Router('/api'); - const v1 = new Router('/v1'); + const v1 = new Router('/v1').use(async (x, next) => { + x.header('x-api-version', 'v1'); + await next(); + }); const v2 = new Router('/v2'); const test = new Router('/test').get( '/', @@ -33,27 +36,16 @@ describe('Router use method', () => { }, ); - app.use( - api - .use( - v1 - .use((req, _res, next) => { - (req as any).customProperty = 'customValue'; - next(); - }) - .use(test), - ) - .use(v2.use(test)).router, - ); + app.use(api.use(v1.use(test)).use(v2.use(test)).router); const response1 = await request(app).get('/api/v1/test'); expect(response1.statusCode).toBe(200); - expect(response1.body).toHaveProperty('customProperty', 'customValue'); + expect(response1.headers).toHaveProperty('x-api-version', 'v1'); const response2 = await request(app).get('/api/v2/test'); expect(response2.statusCode).toBe(200); - expect(response2.body).not.toHaveProperty('customProperty', 'customValue'); + expect(response2.headers).not.toHaveProperty('x-api-version'); }); }); diff --git a/apps/meteor/app/api/server/router.ts b/apps/meteor/app/api/server/router.ts index 514e86ecc872d..3d06278d9d9eb 100644 --- a/apps/meteor/app/api/server/router.ts +++ b/apps/meteor/app/api/server/router.ts @@ -1,10 +1,13 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import type { Method } from '@rocket.chat/rest-typings'; import type { AnySchema } from 'ajv'; import express from 'express'; +import type { HonoRequest, MiddlewareHandler } from 'hono'; +import { Hono } from 'hono'; +import qs from 'qs'; // Using qs specifically to keep express compatibility import type { TypedAction, TypedOptions } from './definition'; - -type MiddlewareHandler = (req: express.Request, res: express.Response, next: express.NextFunction) => void; +import { honoAdapter } from './middlewares/honoAdapter'; type MiddlewareHandlerListAndActionHandler = [ ...MiddlewareHandler[], @@ -49,6 +52,18 @@ export type Route = { }[]; tags?: string[]; }; +declare module 'hono' { + interface ContextVariableMap { + 'route': string; + 'bodyParams-override'?: Record; + } +} + +declare global { + interface Request { + route: string; + } +} export class Router< TBasePath extends string, @@ -56,16 +71,14 @@ export class Router< [x: string]: unknown; } = NonNullable, > { - public router; - - private innerRouter: express.Router; + protected innerRouter: Hono<{ + Variables: { + remoteAddress: string; + }; + }>; constructor(readonly base: TBasePath) { - // eslint-disable-next-line new-cap - this.router = express.Router(); - // eslint-disable-next-line new-cap - this.innerRouter = express.Router(); - this.router.use(this.base, this.innerRouter); + this.innerRouter = new Hono(); } public typedRoutes: Record> = {}; @@ -124,6 +137,38 @@ export class Router< }; } + private async parseBodyParams(request: HonoRequest, overrideBodyParams: Record = {}) { + try { + let parsedBody = {}; + const contentType = request.header('content-type'); + + if (contentType?.includes('application/json')) { + parsedBody = await request.raw.clone().json(); + } else if (contentType?.includes('multipart/form-data')) { + parsedBody = await request.raw.clone().formData(); + } else { + parsedBody = await request.raw.clone().text(); + } + // This is necessary to keep the compatibility with the previous version, otherwise the bodyParams will be an empty string when no content-type is sent + if (parsedBody === '') { + return { ...overrideBodyParams }; + } + + if (Array.isArray(parsedBody)) { + return parsedBody; + } + + return { ...parsedBody, ...overrideBodyParams }; + // eslint-disable-next-line no-empty + } catch {} + + return { ...overrideBodyParams }; + } + + private parseQueryParams(request: HonoRequest) { + return qs.parse(request.raw.url.split('?')?.[1] || ''); + } + private method( method: Method, subpath: TSubPathPattern, @@ -139,26 +184,36 @@ export class Router< > { const [middlewares, action] = splitArray(actions); - this.innerRouter[method.toLowerCase() as Lowercase](`/${subpath}`.replace('//', '/'), ...middlewares, async (req, res) => { + this.innerRouter[method.toLowerCase() as Lowercase](`/${subpath}`.replace('//', '/'), ...middlewares, async (c) => { + const { req, res } = c; + req.raw.route = `${c.var.route ?? ''}${subpath}`; if (options.query) { const validatorFn = options.query; - if (typeof options.query === 'function' && !validatorFn(req.query)) { - return res.status(400).json({ - success: false, - errorType: 'error-invalid-params', - error: validatorFn.errors?.map((error: any) => error.message).join('\n '), - }); + if (typeof options.query === 'function' && !validatorFn(req.query())) { + return c.json( + { + success: false, + errorType: 'error-invalid-params', + error: validatorFn.errors?.map((error: any) => error.message).join('\n '), + }, + 400, + ); } } + const bodyParams = await this.parseBodyParams(req, c.var['bodyParams-override']); + if (options.body) { const validatorFn = options.body; - if (typeof options.body === 'function' && !validatorFn((req as any).bodyParams || req.body)) { - return res.status(400).json({ - success: false, - errorType: 'error-invalid-params', - error: validatorFn.errors?.map((error: any) => error.message).join('\n '), - }); + if (typeof options.body === 'function' && !validatorFn((req as any).bodyParams || bodyParams)) { + return c.json( + { + success: false, + errorType: 'error-invalid-params', + error: validatorFn.errors?.map((error: any) => error.message).join('\n '), + }, + 400, + ); } } @@ -168,13 +223,15 @@ export class Router< headers = {}, } = await action.apply( { - urlParams: req.params, - queryParams: req.query, - bodyParams: (req as any).bodyParams || req.body, - request: req, + requestIp: c.get('remoteAddress'), + urlParams: req.param(), + queryParams: this.parseQueryParams(req), + bodyParams, + request: req.raw.clone(), + path: req.path, response: res, } as any, - [req], + [req.raw.clone()], ); if (process.env.NODE_ENV === 'test' || process.env.TEST_MODE) { const responseValidatorFn = options?.response?.[statusCode]; @@ -190,7 +247,7 @@ export class Router< const responseHeaders = Object.fromEntries( Object.entries({ - ...res.header, + ...res.headers, 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Pragma': 'no-cache', @@ -198,15 +255,21 @@ export class Router< }).map(([key, value]) => [key.toLowerCase(), value]), ); - res.writeHead(statusCode, responseHeaders); + const contentType = (responseHeaders['content-type'] || 'application/json') as string; - if (responseHeaders['content-type']?.match(/json|javascript/) !== null) { - body !== undefined && res.write(JSON.stringify(body)); - } else { - body !== undefined && res.write(body); + const isContentLess = (statusCode: number): statusCode is 101 | 204 | 205 | 304 => { + return [101, 204, 205, 304].includes(statusCode); + }; + + if (isContentLess(statusCode)) { + return c.status(statusCode); } - res.end(); + return c.body( + (contentType?.match(/json|javascript/) ? JSON.stringify(body) : body) as any, + statusCode, + responseHeaders as Record, + ); }); this.registerTypedRoutes(method, subpath, options); return this; @@ -272,26 +335,47 @@ export class Router< return this.method('DELETE', subpath, options, ...action); } - use void>(fn: FN): Router; + use(fn: FN): Router; use>( innerRouter: IRouter, ): IRouter extends Router ? Router> : never; - use(innerRouter: any): any { + use(innerRouter: unknown): any { if (innerRouter instanceof Router) { this.typedRoutes = { ...this.typedRoutes, ...Object.fromEntries(Object.entries(innerRouter.typedRoutes).map(([path, routes]) => [`${this.base}${path}`, routes])), }; - this.innerRouter.use(innerRouter.router); + this.innerRouter.route(innerRouter.base, innerRouter.innerRouter); } if (typeof innerRouter === 'function') { - this.innerRouter.use(innerRouter); + this.innerRouter.use(innerRouter as any); } return this as any; } + + get router(): express.Router { + // eslint-disable-next-line new-cap + const router = express.Router(); + const hono = new Hono(); + router.use( + this.base, + honoAdapter( + hono + .use(`${this.base}/*`, (c, next) => { + c.set('route', `${c.var.route || ''}${this.base}`); + return next(); + }) + .route(this.base, this.innerRouter) + .options('*', (c) => { + return c.body('OK'); + }), + ), + ); + return router; + } } type Prettify = { diff --git a/apps/meteor/app/api/server/v1/assets.ts b/apps/meteor/app/api/server/v1/assets.ts index fd9f31d40923a..2843cf8627d51 100644 --- a/apps/meteor/app/api/server/v1/assets.ts +++ b/apps/meteor/app/api/server/v1/assets.ts @@ -41,7 +41,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', })(Settings.updateValueById, key, value); if (modifiedCount) { @@ -78,7 +78,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', })(Settings.updateValueById, key, value); if (modifiedCount) { diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index bf273b75070dc..40d30fd8d2450 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -671,7 +671,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', }); const promises = settingsIds.map((settingId) => { @@ -691,7 +691,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', })(Settings.resetValueById, settingId); }); diff --git a/apps/meteor/app/api/server/v1/push.ts b/apps/meteor/app/api/server/v1/push.ts index a2c29f85db407..b9a79b5d317bd 100644 --- a/apps/meteor/app/api/server/v1/push.ts +++ b/apps/meteor/app/api/server/v1/push.ts @@ -37,7 +37,7 @@ API.v1.addRoute( const result = await Meteor.callAsync('raix:push-update', { id: deviceId, token: { [type]: value }, - authToken: this.request.headers['x-auth-token'], + authToken: this.request.headers.get('x-auth-token'), appName, userId: this.userId, }); diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 6d2bbab89afd8..438fda41850b3 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -212,7 +212,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', }); if (isSettingColor(setting) && isSettingsUpdatePropsColor(this.bodyParams)) { diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 926ae3415d4a9..a065cc47407c7 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -81,7 +81,7 @@ API.v1.addRoute( const user = await getUserFromParams(this.queryParams); const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); - this.response.setHeader('Location', url); + this.response.headers.set('Location', url); return { statusCode: 307, @@ -118,7 +118,7 @@ API.v1.addRoute( const auditStore = new UserChangedAuditStore({ _id: this.user._id, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', username: this.user.username || '', }); @@ -892,7 +892,7 @@ API.v1.addRoute( await Users.enableEmail2FAByUserId(this.userId); // When 2FA is enable we logout all other clients - const xAuthToken = this.request.headers['x-auth-token'] as string; + const xAuthToken = this.request.headers.get('x-auth-token') as string; if (!xAuthToken) { return API.v1.success(); } @@ -1070,7 +1070,7 @@ API.v1.addRoute( { authRequired: true }, { async post() { - const xAuthToken = this.request.headers['x-auth-token'] as string; + const xAuthToken = this.request.headers.get('x-auth-token') as string; if (!xAuthToken) { throw new Meteor.Error('error-parameter-required', 'x-auth-token is required'); diff --git a/apps/meteor/app/apps/server/bridges/api.ts b/apps/meteor/app/apps/server/bridges/api.ts index 46bb70e3339a3..947ce62d690e0 100644 --- a/apps/meteor/app/apps/server/bridges/api.ts +++ b/apps/meteor/app/apps/server/bridges/api.ts @@ -3,6 +3,7 @@ import type { RequestMethod } from '@rocket.chat/apps-engine/definition/accessor import type { IApiRequest, IApiEndpoint, IApi } from '@rocket.chat/apps-engine/definition/api'; import { ApiBridge } from '@rocket.chat/apps-engine/server/bridges/ApiBridge'; import type { AppApi } from '@rocket.chat/apps-engine/server/managers/AppApi'; +import bodyParser from 'body-parser'; import type { Response, Request, IRouter, RequestHandler } from 'express'; import express from 'express'; import { Meteor } from 'meteor/meteor'; @@ -14,7 +15,7 @@ const apiServer = express(); apiServer.disable('x-powered-by'); -WebApp.connectHandlers.use(apiServer); +WebApp.rawConnectHandlers.use(apiServer); interface IRequestWithPrivateHash extends Request { _privateHash?: string; @@ -28,7 +29,7 @@ export class AppApisBridge extends ApiBridge { super(); this.appRouters = new Map(); - apiServer.use('/api/apps/private/:appId/:hash', (req: IRequestWithPrivateHash, res: Response) => { + apiServer.use('/api/apps/private/:appId/:hash', bodyParser.json(), (req: IRequestWithPrivateHash, res: Response) => { const notFound = (): Response => res.sendStatus(404); const router = this.appRouters.get(req.params.appId); @@ -41,7 +42,7 @@ export class AppApisBridge extends ApiBridge { notFound(); }); - apiServer.use('/api/apps/public/:appId', (req: Request, res: Response) => { + apiServer.use('/api/apps/public/:appId', bodyParser.json(), (req: Request, res: Response) => { const notFound = (): Response => res.sendStatus(404); const router = this.appRouters.get(req.params.appId); diff --git a/apps/meteor/app/integrations/server/api/api.js b/apps/meteor/app/integrations/server/api/api.js index a94cb55bd8677..5541616c7cbe9 100644 --- a/apps/meteor/app/integrations/server/api/api.js +++ b/apps/meteor/app/integrations/server/api/api.js @@ -239,14 +239,15 @@ function integrationInfoRest() { } class WebHookAPI extends APIClass { - async authenticatedRoute(request) { - request.integration = await Integrations.findOne({ - _id: request.params.integrationId, - token: decodeURIComponent(request.params.token), + async authenticatedRoute() { + const { integrationId, token } = this.urlParams; + this.request.integration = await Integrations.findOne({ + _id: integrationId, + token: decodeURIComponent(token), }); - if (!request.integration) { - incomingLogger.info(`Invalid integration id ${request.params.integrationId} or token ${request.params.token}`); + if (!this.request.integration) { + incomingLogger.info(`Invalid integration id ${integrationId} or token ${token}`); return { error: { @@ -259,7 +260,7 @@ class WebHookAPI extends APIClass { }; } - return Users.findOneById(request.integration.userId); + return Users.findOneById(this.request.integration.userId); } /* Webhooks are not versioned, so we must not validate we know a version before adding a rate limiter */ @@ -313,28 +314,29 @@ const Api = new WebHookAPI({ apiPath: 'hooks/', }); -// middleware for special requests that are urlencoded but have a json payload (like GitHub webhooks) -Api.router.use((req, res, next) => { - if (req.headers['content-type'] !== 'application/x-www-form-urlencoded') { - return next(); - } - - // make sure body has only one key and it is 'payload' - if (!req.body || typeof req.body !== 'object' || !('payload' in req.body) || Object.keys(req.body).length !== 1) { +const middleware = async (c, next) => { + const { req } = c; + if (req.raw.headers.get('content-type') !== 'application/x-www-form-urlencoded') { return next(); } try { - req.bodyParams = JSON.parse(req.body.payload); + const body = await (req.header('content-type')?.includes('application/json') ? req.raw.clone().json() : req.raw.clone().text()); + if (!body || typeof body !== 'object' || !('payload' in body) || Object.keys(body).length !== 1) { + return next(); + } - return next(); + // need to compose the full payload in this weird way because body-parser thought it was a form + c.set('bodyParams-override', JSON.parse(body.payload)); } catch (e) { - res.writeHead(400); - res.end(JSON.stringify({ success: false, error: e.message })); + c.body(JSON.stringify({ success: false, error: e.message }), 400); } return next(); -}); +}; + +// middleware for special requests that are urlencoded but have a json payload (like GitHub webhooks) +Api.router.use(middleware); Api.addRoute( ':integrationId/:userId/:token', @@ -419,5 +421,5 @@ Api.addRoute( ); Meteor.startup(() => { - WebApp.connectHandlers.use(Api.router.router); + WebApp.rawConnectHandlers.use(Api.router.router); }); diff --git a/apps/meteor/app/lib/server/functions/getModifiedHttpHeaders.ts b/apps/meteor/app/lib/server/functions/getModifiedHttpHeaders.ts index e62727814de38..2eb9c704b604b 100644 --- a/apps/meteor/app/lib/server/functions/getModifiedHttpHeaders.ts +++ b/apps/meteor/app/lib/server/functions/getModifiedHttpHeaders.ts @@ -1,5 +1,5 @@ -export const getModifiedHttpHeaders = (httpHeaders: Record) => { - const modifiedHttpHeaders = { ...httpHeaders }; +export const getModifiedHttpHeaders = (httpHeaders: Headers) => { + const modifiedHttpHeaders = { ...Object.fromEntries(httpHeaders.entries()) }; if ('x-auth-token' in modifiedHttpHeaders) { modifiedHttpHeaders['x-auth-token'] = '[redacted]'; diff --git a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts index be6b107dc044d..5b76b007c1620 100644 --- a/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts +++ b/apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts @@ -1,5 +1,4 @@ import { Logger } from '@rocket.chat/logger'; -import type { Response } from 'express'; import semver from 'semver'; import { metrics } from '../../../metrics/server'; @@ -12,9 +11,9 @@ const throwErrorsForVersionsUnder = process.env.ROCKET_CHAT_DEPRECATION_THROW_ER const writeDeprecationHeader = (res: Response | undefined, type: string, message: string, version: string) => { if (res) { - res.setHeader('x-deprecation-type', type); - res.setHeader('x-deprecation-message', message); - res.setHeader('x-deprecation-version', version); + res.headers.set('x-deprecation-type', type); + res.headers.set('x-deprecation-message', message); + res.headers.set('x-deprecation-version', version); } }; diff --git a/apps/meteor/app/livechat/imports/server/rest/appearance.ts b/apps/meteor/app/livechat/imports/server/rest/appearance.ts index 215f208c06dc7..126c93d5fc93c 100644 --- a/apps/meteor/app/livechat/imports/server/rest/appearance.ts +++ b/apps/meteor/app/livechat/imports/server/rest/appearance.ts @@ -98,7 +98,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', }); const promises = eligibleSettings.map(({ _id, value }) => auditSettingOperation(Settings.updateValueById, _id, value)); diff --git a/apps/meteor/app/livechat/imports/server/rest/sms.ts b/apps/meteor/app/livechat/imports/server/rest/sms.ts index 3b7aa07773071..8b476c58886cd 100644 --- a/apps/meteor/app/livechat/imports/server/rest/sms.ts +++ b/apps/meteor/app/livechat/imports/server/rest/sms.ts @@ -108,7 +108,7 @@ API.v1.addRoute('livechat/sms-incoming/:service', { const smsDepartment = settings.get('SMS_Default_Omnichannel_Department'); const SMSService = await OmnichannelIntegration.getSmsService(service); - if (!SMSService.validateRequest(this.request)) { + if (!(await SMSService.validateRequest(this.request.clone()))) { return API.v1.failure('Invalid request'); } diff --git a/apps/meteor/app/livechat/imports/server/rest/upload.ts b/apps/meteor/app/livechat/imports/server/rest/upload.ts index 86c815cce72c9..8cb0a0511eade 100644 --- a/apps/meteor/app/livechat/imports/server/rest/upload.ts +++ b/apps/meteor/app/livechat/imports/server/rest/upload.ts @@ -10,7 +10,7 @@ import { sendFileLivechatMessage } from '../../../server/methods/sendFileLivecha API.v1.addRoute('livechat/upload/:rid', { async post() { - if (!this.request.headers['x-visitor-token']) { + if (!this.request.headers.get('x-visitor-token')) { return API.v1.forbidden(); } @@ -22,7 +22,7 @@ API.v1.addRoute('livechat/upload/:rid', { }); } - const visitorToken = this.request.headers['x-visitor-token']; + const visitorToken = this.request.headers.get('x-visitor-token'); const visitor = await LivechatVisitors.getVisitorByToken(visitorToken as string, {}); if (!visitor) { diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.ts b/apps/meteor/app/livechat/server/api/lib/livechat.ts index a6c774fb4ddfb..bd8df571884ec 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.ts +++ b/apps/meteor/app/livechat/server/api/lib/livechat.ts @@ -93,10 +93,10 @@ export async function findAgent(agentId?: string): Promise = {}): { +export function normalizeHttpHeaderData(headers: Headers = new Headers()): { httpHeaders: Record; } { - const httpHeaders = Object.assign({}, headers); + const httpHeaders = Object.fromEntries(headers.entries()); return { httpHeaders }; } diff --git a/apps/meteor/app/livechat/server/api/v1/integration.ts b/apps/meteor/app/livechat/server/api/v1/integration.ts index 7e56c8ca8e591..48b748f4fb7be 100644 --- a/apps/meteor/app/livechat/server/api/v1/integration.ts +++ b/apps/meteor/app/livechat/server/api/v1/integration.ts @@ -55,7 +55,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', }); const promises = settingsIds.map((setting) => auditSettingOperation(Settings.updateValueById, setting._id, setting.value)); diff --git a/apps/meteor/app/livechat/server/api/v1/room.ts b/apps/meteor/app/livechat/server/api/v1/room.ts index 071016456db0b..19e9a716c4635 100644 --- a/apps/meteor/app/livechat/server/api/v1/room.ts +++ b/apps/meteor/app/livechat/server/api/v1/room.ts @@ -78,7 +78,7 @@ API.v1.addRoute( const roomInfo = { source: { ...(isWidget(this.request.headers) - ? { type: OmnichannelSourceType.WIDGET, destination: this.request.headers.host } + ? { type: OmnichannelSourceType.WIDGET, destination: this.request.headers.get('host')! } : { type: OmnichannelSourceType.API }), }, }; diff --git a/apps/meteor/app/meteor-accounts-saml/server/listener.ts b/apps/meteor/app/meteor-accounts-saml/server/listener.ts index 92a0c520ab651..8cdf7c9e6f636 100644 --- a/apps/meteor/app/meteor-accounts-saml/server/listener.ts +++ b/apps/meteor/app/meteor-accounts-saml/server/listener.ts @@ -1,7 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'http'; -import type { IIncomingMessage } from '@rocket.chat/core-typings'; import bodyParser from 'body-parser'; +import express from 'express'; import { Meteor } from 'meteor/meteor'; import { RoutePolicy } from 'meteor/routepolicy'; import { WebApp } from 'meteor/webapp'; @@ -38,11 +38,11 @@ const samlUrlToObject = function (url: string | undefined): ISAMLAction | null { return result; }; -const middleware = async function (req: IIncomingMessage, res: ServerResponse, next: (err?: any) => void): Promise { +const middleware = async function (req: express.Request, res: ServerResponse, next: (err?: any) => void): Promise { // Make sure to catch any exceptions because otherwise we'd crash // the runner try { - const samlObject = samlUrlToObject(req.url); + const samlObject = samlUrlToObject(req.originalUrl); if (!samlObject?.serviceName) { next(); return; @@ -72,6 +72,12 @@ const middleware = async function (req: IIncomingMessage, res: ServerResponse, n }; // Listen to incoming SAML http requests -WebApp.connectHandlers - .use(bodyParser.json()) - .use(async (req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) => middleware(req as IIncomingMessage, res, next)); +WebApp.connectHandlers.use( + /^\/_saml/, + bodyParser.json(), + express.urlencoded({ + extended: true, + limit: '50mb', + }), + async (req: IncomingMessage, res: ServerResponse, next: (err?: any) => void) => middleware(req as express.Request, res, next), +); diff --git a/apps/meteor/ee/app/api-enterprise/server/middlewares/license.ts b/apps/meteor/ee/app/api-enterprise/server/middlewares/license.ts index 2d5c8c0faecf8..d285ab11ccdcb 100644 --- a/apps/meteor/ee/app/api-enterprise/server/middlewares/license.ts +++ b/apps/meteor/ee/app/api-enterprise/server/middlewares/license.ts @@ -1,13 +1,11 @@ import type { LicenseManager } from '@rocket.chat/license'; -import type { Request, Response, NextFunction } from 'express'; +import type { MiddlewareHandler } from 'hono'; import type { FailureResult, TypedOptions } from '../../../../../app/api/server/definition'; -type ExpressMiddleware = (req: Request, res: Response, next: NextFunction) => void; - export const license = - (options: TypedOptions, licenseManager: LicenseManager): ExpressMiddleware => - async (_req, res, next) => { + (options: TypedOptions, licenseManager: LicenseManager): MiddlewareHandler => + async (c, next) => { if (!options.license) { return next(); } @@ -27,11 +25,7 @@ export const license = }; if (!license) { - // Explicitly set the content type to application/json to avoid the following issue: - // https://github.com/expressjs/express/issues/2238 - res.writeHead(failure.statusCode, { 'Content-Type': 'application/json' }); - res.write(JSON.stringify(failure.body)); - return res.end(); + return c.json(failure.body, failure.statusCode); } return next(); diff --git a/apps/meteor/ee/server/api/index.ts b/apps/meteor/ee/server/api/index.ts index 96dc64c5ced41..264cf37e329eb 100644 --- a/apps/meteor/ee/server/api/index.ts +++ b/apps/meteor/ee/server/api/index.ts @@ -4,3 +4,4 @@ import './licenses'; import './sessions'; import './chat'; import './roles'; +import '../apps/communication/uikit'; diff --git a/apps/meteor/ee/server/api/licenses.ts b/apps/meteor/ee/server/api/licenses.ts index 5c686c0e532e1..30484a1301b0b 100644 --- a/apps/meteor/ee/server/api/licenses.ts +++ b/apps/meteor/ee/server/api/licenses.ts @@ -60,7 +60,7 @@ API.v1.addRoute( _id: this.userId, username: this.user.username!, ip: this.requestIp, - useragent: this.request.headers['user-agent'] || '', + useragent: this.request.headers.get('user-agent') || '', }); (await auditSettingOperation(Settings.updateValueById, 'Enterprise_License', license)).modifiedCount && diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 53719aee9a87a..58088a9d0289f 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -6,9 +6,7 @@ import type { IUser, IMessage } from '@rocket.chat/core-typings'; import { License } from '@rocket.chat/license'; import { Settings, Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import type express from 'express'; import { Meteor } from 'meteor/meteor'; -import { WebApp } from 'meteor/webapp'; import { ZodError } from 'zod'; import { actionButtonsHandler } from './endpoints/actionButtonsHandler'; @@ -55,14 +53,17 @@ export class AppsRestApi { async loadAPI() { this.api = new API.ApiClass({ - version: 'apps', - apiPath: '/api', + apiPath: '', useDefaultAuth: true, prettyJson: false, enableCors: false, + version: 'apps', }); + await this.addManagementRoutes(); - (WebApp.connectHandlers as unknown as ReturnType).use(this.api.router.router); + + // Using the same instance of the existing API for now, to be able to use the same api prefix(/api) + API.api.use(this.api.router); } addManagementRoutes() { diff --git a/apps/meteor/ee/server/apps/communication/uikit.ts b/apps/meteor/ee/server/apps/communication/uikit.ts index 0392076704d72..7d490406d007f 100644 --- a/apps/meteor/ee/server/apps/communication/uikit.ts +++ b/apps/meteor/ee/server/apps/communication/uikit.ts @@ -1,6 +1,7 @@ import type { UiKitCoreAppPayload } from '@rocket.chat/core-services'; import { UiKitCoreApp } from '@rocket.chat/core-services'; import type { OperationParams, UrlParams } from '@rocket.chat/rest-typings'; +import bodyParser from 'body-parser'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -33,7 +34,7 @@ settings.watch('API_CORS_Origin', (value: string) => { : []; }); -WebApp.connectHandlers.use(apiServer); +WebApp.rawConnectHandlers.use(apiServer); // eslint-disable-next-line new-cap const router = express.Router(); @@ -89,7 +90,7 @@ const corsOptions: cors.CorsOptions = { }, }; -apiServer.use('/api/apps/ui.interaction/', cors(corsOptions), router); // didn't have the rateLimiter option +apiServer.use('/api/apps/ui.interaction/', bodyParser.json(), cors(corsOptions), router); // didn't have the rateLimiter option type UiKitUserInteractionRequest = Request< UrlParams<'/apps/ui.interaction/:id'>, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 1d22f40b2590f..c913ffb323134 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -139,6 +139,7 @@ "@types/proxy-from-env": "^1.0.4", "@types/proxyquire": "^1.3.31", "@types/psl": "^1.1.3", + "@types/qs": "^6", "@types/react": "~18.3.17", "@types/react-dom": "~18.3.5", "@types/sanitize-html": "^2.13.0", @@ -352,6 +353,7 @@ "he": "^1.2.0", "highlight.js": "11.8.0", "hljs9": "npm:highlight.js@^9.18.5", + "hono": "^4.6.19", "http-proxy-agent": "^7.0.2", "human-interval": "^2.0.1", "i18next-http-backend": "^1.4.5", @@ -406,6 +408,7 @@ "prometheus-gc-stats": "^0.6.5", "proxy-from-env": "^1.1.0", "psl": "^1.10.0", + "qs": "^6.14.0", "query-string": "^7.1.3", "queue-fifo": "^0.2.6", "re-resizable": "^6.10.1", diff --git a/apps/meteor/server/main.ts b/apps/meteor/server/main.ts index 90ee58fb8a6a1..10f724c745e79 100644 --- a/apps/meteor/server/main.ts +++ b/apps/meteor/server/main.ts @@ -10,6 +10,7 @@ import './settings'; import { configureServer } from './configuration'; import { registerServices } from './services/startup'; import { startup } from './startup'; +import { startRestAPI } from '../app/api/server/api'; import { settings } from '../app/settings/server'; import { startupApp } from '../ee/server'; import { startRocketChat } from '../startRocketChat'; @@ -27,3 +28,4 @@ await Promise.all([configureServer(settings), registerServices(), startup()]); await startRocketChat(); await startupApp(); +await startRestAPI(); diff --git a/apps/meteor/server/oauth2-server/oauth.ts b/apps/meteor/server/oauth2-server/oauth.ts index 7cf7b24d453d3..e52e5fabfac4f 100644 --- a/apps/meteor/server/oauth2-server/oauth.ts +++ b/apps/meteor/server/oauth2-server/oauth.ts @@ -21,6 +21,17 @@ export class OAuth2Server { this.config = config; this.app = express(); + this.app.use( + '/oauth/*', + express.json({ + limit: '50mb', + }), + express.urlencoded({ + extended: true, + limit: '50mb', + }), + express.query({}), + ); this.oauth = new OAuthServer({ model: new Model(this.config), diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts b/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts index d036345663cd4..c1e5e32018926 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/mobex.ts @@ -1,7 +1,6 @@ import { Base64 } from '@rocket.chat/base64'; import type { ISMSProvider, ServiceData, SMSProviderResult, SMSProviderResponse } from '@rocket.chat/core-typings'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import type { Request } from 'express'; import { settings } from '../../../../app/settings/server'; import { SystemLogger } from '../../../lib/logger/system'; @@ -197,7 +196,7 @@ export class Mobex implements ISMSProvider { }; } - validateRequest(_request: Request): boolean { + async validateRequest(_request: Request): Promise { return true; } diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts index d2f89d35e7c5d..7d4c4a48e1a20 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/twilio.ts @@ -1,7 +1,6 @@ import { api } from '@rocket.chat/core-services'; import type { ISMSProvider, ServiceData, SMSProviderResponse, SMSProviderResult } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; -import type { Request } from 'express'; import filesize from 'filesize'; import twilio from 'twilio'; @@ -245,7 +244,7 @@ export class Twilio implements ISMSProvider { }; } - isRequestFromTwilio(signature: string, request: Request): boolean { + async isRequestFromTwilio(signature: string, request: Request): Promise { const authToken = settings.get('SMS_Twilio_authToken'); let siteUrl = settings.get('Site_Url'); if (siteUrl.endsWith('/')) { @@ -257,17 +256,23 @@ export class Twilio implements ISMSProvider { return false; } - const twilioUrl = request.originalUrl ? `${siteUrl}${request.originalUrl}` : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`; + const twilioUrl = request.url ? `${siteUrl}${request.url}` : `${siteUrl}/api/v1/livechat/sms-incoming/twilio`; - return twilio.validateRequest(authToken, signature, twilioUrl, request.body); + let body = {}; + try { + body = await request.json(); + // eslint-disable-next-line no-empty + } catch {} + + return twilio.validateRequest(authToken, signature, twilioUrl, body); } - validateRequest(request: Request): boolean { + async validateRequest(request: Request): Promise { // We're not getting original twilio requests on CI :p if (process.env.TEST_MODE === 'true') { return true; } - const twilioHeader = request.headers['x-twilio-signature'] || ''; + const twilioHeader = request.headers.get('x-twilio-signature') || ''; const twilioSignature = Array.isArray(twilioHeader) ? twilioHeader[0] : twilioHeader; return this.isRequestFromTwilio(twilioSignature, request); } diff --git a/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts b/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts index aa42bacad6243..063070a30e7e8 100644 --- a/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts +++ b/apps/meteor/server/services/omnichannel-integrations/providers/voxtelesys.ts @@ -2,7 +2,6 @@ import { api } from '@rocket.chat/core-services'; import type { ISMSProvider, ServiceData, SMSProviderResponse } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { serverFetch as fetch } from '@rocket.chat/server-fetch'; -import type { Request } from 'express'; import filesize from 'filesize'; import { settings } from '../../../../app/settings/server'; @@ -163,7 +162,7 @@ export class Voxtelesys implements ISMSProvider { }; } - validateRequest(_request: Request): boolean { + async validateRequest(_request: Request): Promise { return true; } diff --git a/apps/meteor/tests/unit/app/lib/server/functions/getModifiedHttpHeaders.tests.ts b/apps/meteor/tests/unit/app/lib/server/functions/getModifiedHttpHeaders.tests.ts index 5130bbe59a99f..119abd3e2f0ec 100644 --- a/apps/meteor/tests/unit/app/lib/server/functions/getModifiedHttpHeaders.tests.ts +++ b/apps/meteor/tests/unit/app/lib/server/functions/getModifiedHttpHeaders.tests.ts @@ -8,7 +8,7 @@ describe('getModifiedHttpHeaders', () => { 'x-auth-token': '12345', 'some-other-header': 'value', }; - const result = getModifiedHttpHeaders(inputHeaders); + const result = getModifiedHttpHeaders(new Headers(inputHeaders)); expect(result['x-auth-token']).to.equal('[redacted]'); expect(result['some-other-header']).to.equal('value'); }); @@ -17,7 +17,7 @@ describe('getModifiedHttpHeaders', () => { const inputHeaders = { 'some-other-header': 'value', }; - const result = getModifiedHttpHeaders(inputHeaders); + const result = getModifiedHttpHeaders(new Headers(inputHeaders)); expect(result).to.deep.equal(inputHeaders); }); @@ -26,7 +26,7 @@ describe('getModifiedHttpHeaders', () => { cookie: 'session_id=abc123; rc_token=98765; other_cookie=value', }; const expectedCookies = 'session_id=abc123; rc_token=[redacted]; other_cookie=value'; - const result = getModifiedHttpHeaders(inputHeaders); + const result = getModifiedHttpHeaders(new Headers(inputHeaders)); expect(result.cookie).to.equal(expectedCookies); }); @@ -34,7 +34,7 @@ describe('getModifiedHttpHeaders', () => { const inputHeaders = { cookie: 'session_id=abc123; other_cookie=value', }; - const result = getModifiedHttpHeaders(inputHeaders); + const result = getModifiedHttpHeaders(new Headers(inputHeaders)); expect(result.cookie).to.equal(inputHeaders.cookie); }); @@ -42,7 +42,7 @@ describe('getModifiedHttpHeaders', () => { const inputHeaders = { 'some-other-header': 'value', }; - const result = getModifiedHttpHeaders(inputHeaders); + const result = getModifiedHttpHeaders(new Headers(inputHeaders)); expect(result).to.deep.equal(inputHeaders); }); @@ -52,7 +52,7 @@ describe('getModifiedHttpHeaders', () => { 'cookie': 'session_id=abc123; rc_token=98765; other_cookie=value', }; const expectedCookies = 'session_id=abc123; rc_token=[redacted]; other_cookie=value'; - const result = getModifiedHttpHeaders(inputHeaders); + const result = getModifiedHttpHeaders(new Headers(inputHeaders)); expect(result['x-auth-token']).to.equal('[redacted]'); expect(result.cookie).to.equal(expectedCookies); }); diff --git a/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts b/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts index dd0a62da3ad1e..9ee9471d8b41b 100644 --- a/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts +++ b/apps/meteor/tests/unit/server/services/omnichannel-integrations/providers/twilio.spec.ts @@ -53,7 +53,7 @@ describe('Twilio Request Validation', () => { twilioStub.isRequestFromTwilio.reset(); }); - it('should not validate a request when process.env.TEST_MODE is true', () => { + it('should not validate a request when process.env.TEST_MODE is true', async () => { process.env.TEST_MODE = 'true'; const twilio = new Twilio(); @@ -63,10 +63,10 @@ describe('Twilio Request Validation', () => { }, }; - expect(twilio.validateRequest(request)).to.be.true; + expect(await twilio.validateRequest(request)).to.be.true; }); - it('should validate a request when process.env.TEST_MODE is false', () => { + it('should validate a request when process.env.TEST_MODE is false', async () => { process.env.TEST_MODE = 'false'; settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); @@ -81,15 +81,21 @@ describe('Twilio Request Validation', () => { const request = { headers: { - 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, }, - body: requestBody, + json: () => requestBody, }; - expect(twilio.validateRequest(request)).to.be.true; + expect(await twilio.validateRequest(request)).to.be.true; }); - it('should validate a request when query string is present', () => { + it('should validate a request when query string is present', async () => { process.env.TEST_MODE = 'false'; settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); @@ -103,17 +109,23 @@ describe('Twilio Request Validation', () => { }; const request = { - originalUrl: '/api/v1/livechat/sms-incoming/twilio?department=1', + url: '/api/v1/livechat/sms-incoming/twilio?department=1', headers: { - 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio?department=1', requestBody), + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio?department=1', requestBody), + }; + + return headers[param]; + }, }, - body: requestBody, + json: () => requestBody, }; - expect(twilio.validateRequest(request)).to.be.true; + expect(await twilio.validateRequest(request)).to.be.true; }); - it('should reject a request where signature doesnt match', () => { + it('should reject a request where signature doesnt match', async () => { settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); settingsStub.get.withArgs('Site_Url').returns('https://example.com'); @@ -126,15 +138,21 @@ describe('Twilio Request Validation', () => { const request = { headers: { - 'x-twilio-signature': getSignature('anotherAuthToken', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('anotherAuthToken', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, }, - body: requestBody, + json: () => requestBody, }; - expect(twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request)).to.be.false; }); - it('should reject a request where signature is missing', () => { + it('should reject a request where signature is missing', async () => { settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); settingsStub.get.withArgs('Site_Url').returns('https://example.com'); @@ -146,14 +164,16 @@ describe('Twilio Request Validation', () => { }; const request = { - headers: {}, - body: requestBody, + headers: { + get: () => null, + }, + json: () => requestBody, }; - expect(twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request)).to.be.false; }); - it('should reject a request where the signature doesnt correspond body', () => { + it('should reject a request where the signature doesnt correspond body', async () => { settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); settingsStub.get.withArgs('Site_Url').returns('https://example.com'); @@ -166,15 +186,21 @@ describe('Twilio Request Validation', () => { const request = { headers: { - 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', {}), + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', {}), + }; + + return headers[param]; + }, }, - body: requestBody, + json: () => requestBody, }; - expect(twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request)).to.be.false; }); - it('should return false if URL is not provided', () => { + it('should return false if URL is not provided', async () => { process.env.TEST_MODE = 'false'; settingsStub.get.withArgs('SMS_Twilio_authToken').returns('test'); @@ -189,15 +215,21 @@ describe('Twilio Request Validation', () => { const request = { headers: { - 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, }, - body: requestBody, + json: () => requestBody, }; - expect(twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request)).to.be.false; }); - it('should return false if authToken is not provided', () => { + it('should return false if authToken is not provided', async () => { process.env.TEST_MODE = 'false'; settingsStub.get.withArgs('SMS_Twilio_authToken').returns(''); @@ -212,11 +244,17 @@ describe('Twilio Request Validation', () => { const request = { headers: { - 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + get: (param: string) => { + const headers: Record = { + 'x-twilio-signature': getSignature('test', 'https://example.com/api/v1/livechat/sms-incoming/twilio', requestBody), + }; + + return headers[param]; + }, }, - body: requestBody, + json: () => requestBody, }; - expect(twilio.validateRequest(request)).to.be.false; + expect(await twilio.validateRequest(request)).to.be.false; }); }); diff --git a/packages/core-typings/src/omnichannel/sms.ts b/packages/core-typings/src/omnichannel/sms.ts index 49364da2b8c36..c29437910066d 100644 --- a/packages/core-typings/src/omnichannel/sms.ts +++ b/packages/core-typings/src/omnichannel/sms.ts @@ -1,5 +1,3 @@ -import type { Request } from 'express'; - type ServiceMedia = { url: string; contentType: string; @@ -29,7 +27,7 @@ export interface ISMSProviderConstructor { export interface ISMSProvider { parse(data: unknown): ServiceData; - validateRequest(request: Request): boolean; + validateRequest(request: Request): Promise; sendBatch?(from: string, to: string[], message: string): Promise; response(): SMSProviderResponse; diff --git a/yarn.lock b/yarn.lock index ab2940cbbf036..c5571301a3671 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8760,6 +8760,7 @@ __metadata: "@types/proxy-from-env": "npm:^1.0.4" "@types/proxyquire": "npm:^1.3.31" "@types/psl": "npm:^1.1.3" + "@types/qs": "npm:^6" "@types/react": "npm:~18.3.17" "@types/react-dom": "npm:~18.3.5" "@types/sanitize-html": "npm:^2.13.0" @@ -8857,6 +8858,7 @@ __metadata: he: "npm:^1.2.0" highlight.js: "npm:11.8.0" hljs9: "npm:highlight.js@^9.18.5" + hono: "npm:^4.6.19" http-proxy-agent: "npm:^7.0.2" human-interval: "npm:^2.0.1" i18next: "npm:~23.4.9" @@ -8929,6 +8931,7 @@ __metadata: proxy-from-env: "npm:^1.1.0" proxyquire: "npm:^2.1.3" psl: "npm:^1.10.0" + qs: "npm:^6.14.0" query-string: "npm:^7.1.3" queue-fifo: "npm:^0.2.6" raw-loader: "npm:~4.0.2" @@ -12724,6 +12727,13 @@ __metadata: languageName: node linkType: hard +"@types/qs@npm:^6": + version: 6.9.18 + resolution: "@types/qs@npm:6.9.18" + checksum: 10/152fab96efd819cc82ae67c39f089df415da6deddb48f1680edaaaa4e86a2a597de7b2ff0ad391df66d11a07006a08d52c9405e86b8cb8f3d5ba15881fe56cc7 + languageName: node + linkType: hard + "@types/range-parser@npm:*": version: 1.2.4 resolution: "@types/range-parser@npm:1.2.4" @@ -22714,6 +22724,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.6.19": + version: 4.6.19 + resolution: "hono@npm:4.6.19" + checksum: 10/b3e317bdaf868359b68271d5aa395b1561ceedf3ac97f2d76cc5b9e00e01a794f862bdb5d5574b96b284f5456e2be4d42e6f9511a479d1afdf8c09dff75150e7 + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -30929,6 +30946,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.14.0": + version: 6.14.0 + resolution: "qs@npm:6.14.0" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10/a60e49bbd51c935a8a4759e7505677b122e23bf392d6535b8fc31c1e447acba2c901235ecb192764013cd2781723dc1f61978b5fdd93cc31d7043d31cdc01974 + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3"