snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { hashLoginToken } from '@rocket.chat/account-utils';
import { Authorization } from '@rocket.chat/core-services';
import { Users } from '@rocket.chat/models';
import type { Request, Response, NextFunction } from 'express';
import { oAuth2ServerAuth } from '../../../lib/auth/oauth2-server/oauth2-server';
type AuthenticationMiddlewareConfig = {
rejectUnauthorized?: boolean;
cookies?: boolean;
};
export function authenticationMiddleware(
config: AuthenticationMiddlewareConfig = {
rejectUnauthorized: true,
cookies: false,
},
) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (config.cookies) {
req.headers['x-auth-token'] = req.cookies.rc_token ?? req.headers['x-auth-token'];
req.headers['x-user-id'] = req.cookies.rc_uid ?? req.headers['x-user-id'];
}
const { 'x-user-id': userId, 'x-auth-token': authToken } = req.headers;
if (userId && authToken) {
const user = await Users.findOneByIdAndLoginToken(userId as string, hashLoginToken(authToken as string));
req.user = user || undefined;
} else {
const { authorization } = req.headers;
const accessToken = typeof req.query.access_token === 'string' ? req.query.access_token : undefined;
delete req.query.access_token;
req.user = await oAuth2ServerAuth({ authorization, accessToken });
}
if (config.rejectUnauthorized && !req.user) {
res.status(401).send('Unauthorized');
return;
}
req.userId = req?.user?._id;
next();
};
}
export function hasPermissionMiddleware(
permission: string,
{ rejectUnauthorized } = {
rejectUnauthorized: true,
},
) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.userId) {
if (rejectUnauthorized) {
res.status(401).send('Unauthorized');
return;
}
req.unauthorized = true;
return next();
}
if (!(await Authorization.hasPermission(req.userId, permission))) {
if (rejectUnauthorized) {
res.status(403).send('Forbidden');
return;
}
req.unauthorized = true;
}
next();
};
}

View File

@@ -0,0 +1,48 @@
import { type IUser, type RequiredField } from '@rocket.chat/core-typings';
import { type Logger } from '@rocket.chat/logger';
import type { MiddlewareHandler } from 'hono';
import { Meteor } from 'meteor/meteor';
import { settings } from '../../../settings';
import { applyBreakingChanges, type APIClass } from '../../ApiClass';
import { convertHonoContextToApiActionContext, type HonoContext } from '../../router';
const isUserWithUsername = (user: IUser | null): user is RequiredField<IUser, 'username'> => {
return user !== null && typeof user === 'object' && 'username' in user && user.username !== undefined;
};
export function authenticationMiddlewareForHono(
api: APIClass<string, Record<string, unknown>>,
options: {
authRequired?: boolean;
authOrAnonRequired?: boolean;
userWithoutUsername?: boolean;
logger: Logger;
},
): MiddlewareHandler {
return async (c: HonoContext, next) => {
const user = await api.authenticatedRoute(convertHonoContextToApiActionContext(c, { logger: options.logger }));
const shouldPreventAnonymousRead = !user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead');
const shouldPreventUserRead = !user && options.authRequired;
if (shouldPreventAnonymousRead || shouldPreventUserRead) {
const result = api.unauthorized('You must be logged in to do this.');
// TODO: MAJOR
if (!applyBreakingChanges) {
Object.assign(result.body, {
status: 'error',
message: 'You must be logged in to do this.',
});
}
return c.json(result.body, result.statusCode);
}
if (user && !options.userWithoutUsername && !isUserWithUsername(user)) {
throw new Meteor.Error('error-unauthorized', 'Users must have a username');
}
c.set('user', user);
return next();
};
}

View File

@@ -0,0 +1,326 @@
import { Router } from '@rocket.chat/http-router';
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import { cors } from './cors';
import { CachedSettings } from '../../../settings/CachedSettings';
describe('Cors middleware', () => {
it('should return allow-origin header for GET if CORS enabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: '*',
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('origin', 'http://localhost');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
expect(response.headers).not.toHaveProperty('access-control-allow-methods');
expect(response.headers).toHaveProperty('access-control-allow-headers');
});
it('should return allow-origin header for GET if CORS disabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: false,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('origin', 'http://localhost');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
expect(response.headers).not.toHaveProperty('access-control-allow-methods');
expect(response.headers).toHaveProperty('access-control-allow-headers');
});
it('should handle CORS if enabled to *', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: '*',
} as any);
settings.set({
_id: 'MCP_Enabled',
value: true,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app)
.options('/api/test')
.set('origin', 'http://localhost')
.set('access-control-request-method', 'POST')
.set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version');
expect(response.statusCode).toBe(200);
expect(response.body).not.toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', '*');
expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH');
expect(response.headers).toHaveProperty(
'access-control-allow-headers',
'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version',
);
});
it('should handle CORS if enabled to specific origin', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: 'http://localhost',
} as any);
settings.set({
_id: 'MCP_Enabled',
value: true,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app)
.options('/api/test')
.set('origin', 'http://localhost')
.set('access-control-request-method', 'POST')
.set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version');
expect(response.statusCode).toBe(200);
expect(response.body).not.toHaveProperty('message', 'CORS test successful');
expect(response.headers).toHaveProperty('access-control-allow-origin', 'http://localhost');
expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH');
expect(response.headers).toHaveProperty(
'access-control-allow-headers',
'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version',
);
});
it('should not advertise the MCP protocol header while MCP is disabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({ _id: 'API_Enable_CORS', value: true } as any);
settings.set({ _id: 'API_CORS_Origin', value: '*' } as any);
settings.set({ _id: 'MCP_Enabled', value: false } as any);
api
.use(cors(settings))
.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200, body: {} }));
app.use(api.router);
const response = await request(app).options('/api/test').set('origin', 'http://localhost').set('access-control-request-method', 'POST');
expect(response.statusCode).toBe(200);
expect(response.headers['access-control-allow-headers']).not.toContain('MCP-Protocol-Version');
});
it('should not handle CORS if origin is not allowed', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: true,
} as any);
settings.set({
_id: 'API_CORS_Origin',
value: 'http://localhost',
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).options('/api/test').set('origin', 'http://example.com');
expect(response.statusCode).toBe(403);
});
it('should not handle CORS if disabled', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'API_Enable_CORS',
value: false,
} as any);
api.use(cors(settings)).get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'CORS test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).options('/api/test').set('origin', 'http://localhost');
expect(response.statusCode).toBe(405);
expect(response.text).toBe('CORS not enabled. Go to "Admin > General > REST Api" to enable it.');
expect(response.headers).not.toHaveProperty('access-control-allow-origin');
});
});

View File

@@ -0,0 +1,62 @@
import type { MiddlewareHandler } from 'hono';
import type { CachedSettings } from '../../../settings/CachedSettings';
const defaultHeaders = {
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, HEAD, PATCH',
'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization',
};
const getAllowedHeaders = (settings: CachedSettings): string =>
`${defaultHeaders['Access-Control-Allow-Headers']}${settings.get('MCP_Enabled') ? ', MCP-Protocol-Version' : ''}`;
export const cors =
(settings: CachedSettings): MiddlewareHandler =>
async (c, next) => {
const { req, res } = c;
const allowedHeaders = getAllowedHeaders(settings);
if (req.method !== 'OPTIONS') {
res.headers.set('Access-Control-Allow-Origin', '*');
res.headers.set('Access-Control-Allow-Headers', allowedHeaders);
await next();
return;
}
// 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', allowedHeaders);
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', allowedHeaders);
await next();
};

View File

@@ -0,0 +1,86 @@
import { Router } from '@rocket.chat/http-router';
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import { cors } from './cors';
import { experimentalWarningMiddleware } from './experimental';
import { CachedSettings } from '../../../settings/CachedSettings';
const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
const buildApp = ({ corsEnabled }: { corsEnabled: boolean }) => {
const ajv = new Ajv();
const settings = new CachedSettings();
settings.set({ _id: 'API_Enable_CORS', value: corsEnabled } as any);
settings.set({ _id: 'API_CORS_Origin', value: 'https://allowed.example' } as any);
const route = (router: Router<any, any, any>) =>
router.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({
statusCode: 200 as const,
body: {},
}));
const api = new Router('/api')
.use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) }))
.use(cors(settings))
.use(route(new Router('/v1')))
.use(route(new Router('/experimental')));
const app = express();
app.use(api.router);
return app;
};
const preflight = (app: express.Express, path: string, origin: string) =>
request(app).options(path).set('Origin', origin).set('Access-Control-Request-Method', 'GET');
describe('Experimental middleware', () => {
it('should stamp the unstable signal headers on experimental responses', async () => {
const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/test');
expect(res.statusCode).toBe(200);
expect(res.headers['x-experimental']).toBe('true');
expect(res.headers.warning).toBe(WARNING_HEADER);
});
it('should not stamp responses from other versions', async () => {
const res = await request(buildApp({ corsEnabled: true })).get('/api/v1/test');
expect(res.statusCode).toBe(200);
expect(res.headers['x-experimental']).toBeUndefined();
expect(res.headers.warning).toBeUndefined();
});
it('should stamp 404s for unmatched experimental paths', async () => {
const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/nope');
expect(res.statusCode).toBe(404);
expect(res.headers['x-experimental']).toBe('true');
});
// cors answers rejected preflights without calling next(), so these only carry the headers
// while the middleware stays registered ahead of it
it('should stamp preflight rejections when CORS is disabled', async () => {
const res = await preflight(buildApp({ corsEnabled: false }), '/api/experimental/test', 'https://allowed.example');
expect(res.statusCode).toBe(405);
expect(res.headers['x-experimental']).toBe('true');
expect(res.headers.warning).toBe(WARNING_HEADER);
});
it('should stamp preflight rejections from disallowed origins', async () => {
const res = await preflight(buildApp({ corsEnabled: true }), '/api/experimental/test', 'https://evil.example');
expect(res.statusCode).toBe(403);
expect(res.headers['x-experimental']).toBe('true');
expect(res.headers.warning).toBe(WARNING_HEADER);
});
it('should not stamp preflight rejections from other versions', async () => {
const res = await preflight(buildApp({ corsEnabled: true }), '/api/v1/test', 'https://evil.example');
expect(res.statusCode).toBe(403);
expect(res.headers['x-experimental']).toBeUndefined();
});
});

View File

@@ -0,0 +1,29 @@
import type { MiddlewareHandler } from 'hono';
// `x-experimental` is the supported programmatic signal. `Warning: 299` is emitted for
// legacy tooling only — warn code 299 came from RFC 7234, which RFC 9111 has obsoleted
// along with the `Warning` header itself.
const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"';
/**
* Stamps every experimental response with the unstable signal headers.
*
* Registered on the shared `/api` mount ahead of `cors`, and scoped by path rather than by
* router: `cors` answers rejected preflights with 403/405 without calling `next()`, so a
* middleware living on `API.experimental.router` would never run for those responses.
*
* The headers are set on `c.res.headers` before the downstream handlers run; Hono merges them
* into whatever response is produced later, so 404s and CORS rejections are covered too.
*/
export const experimentalWarningMiddleware =
({ basePathRegex }: { basePathRegex: RegExp }): MiddlewareHandler =>
async (c, next) => {
if (!basePathRegex.test(c.req.path)) {
return next();
}
c.res.headers.set('x-experimental', 'true');
c.res.headers.set('Warning', WARNING_HEADER);
await next();
};

View File

@@ -0,0 +1,37 @@
import type { Logger } from '@rocket.chat/logger';
import { censorUrl } from '@rocket.chat/tools';
import type { MiddlewareHandler } from 'hono';
import { getRestPayload } from '../../../lib/logger/logPayloads';
export const loggerMiddleware =
(logger: Logger): MiddlewareHandler =>
async (c, next) => {
const startTime = Date.now();
const log = logger.logger.child(
{
method: c.req.method,
url: censorUrl(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) && (await getRestPayload(c.req))),
},
{
redact: [
'payload.password', // Potentially logged by v1/login
],
},
);
await next();
log.http({
status: c.res.status,
responseTime: Date.now() - startTime,
});
};

View File

@@ -0,0 +1,349 @@
import { Router } from '@rocket.chat/http-router';
import Ajv from 'ajv';
import express from 'express';
import request from 'supertest';
import { metricsMiddleware } from './metrics';
import { CachedSettings } from '../../../settings/CachedSettings';
describe('Metrics middleware', () => {
it('should handle metrics', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'Prometheus_API_User_Agent',
value: true,
} as any);
const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};
// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
const responseSizeHistogram = { observe: jest.fn() };
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
api
.use(
metricsMiddleware({
api: { version: 1 } as any,
settings,
endpointTimeSummary: summary as any,
endpointTimeHistogram: histogram as any,
responseSizeHistogram: responseSizeHistogram as any,
activeRequestsGauge: activeRequestsGauge as any,
}),
)
.get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'Metrics test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');
expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: '/api/test' });
});
it('should strip path from metrics', async () => {
const ajv = new Ajv();
const app = express();
const api = new Router('/api');
const settings = new CachedSettings();
settings.set({
_id: 'Prometheus_API_User_Agent',
value: true,
} as any);
const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};
// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
const responseSizeHistogram = { observe: jest.fn() };
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
api
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\//),
api: { version: 1 } as any,
settings,
endpointTimeSummary: summary as any,
endpointTimeHistogram: histogram as any,
responseSizeHistogram: responseSizeHistogram as any,
activeRequestsGauge: activeRequestsGauge as any,
}),
)
.get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: 'Metrics test successful',
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/test').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');
expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 1, user_agent: 'test', entrypoint: 'test' });
});
it('should decode path for method.call endpoints', async () => {
const ajv = new Ajv();
const app = express();
const settings = new CachedSettings();
const api = new Router('/api');
const summary = {
startTimer: jest.fn().mockImplementation(() => jest.fn()),
};
// Get the mock startTimer function
const mockEndTimer = jest.fn();
summary.startTimer.mockReturnValue(mockEndTimer);
const histogram = { startTimer: jest.fn().mockReturnValue(jest.fn()) };
const responseSizeHistogram = { observe: jest.fn() };
const activeRequestsGauge = { inc: jest.fn(), dec: jest.fn() };
api
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\//),
api: { version: 1 } as any,
settings,
endpointTimeSummary: summary as any,
endpointTimeHistogram: histogram as any,
responseSizeHistogram: responseSizeHistogram as any,
activeRequestsGauge: activeRequestsGauge as any,
}),
)
.get(
'/method.call/:id',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => {
return {
statusCode: 200,
body: {
message: `Metrics test successful`,
},
};
},
);
app.use(api.router);
const response = await request(app).get('/api/method.call/get%3Aparam').set('user-agent', 'test');
expect(response.statusCode).toBe(200);
expect(response.body).toHaveProperty('message', 'Metrics test successful');
expect(summary.startTimer).toHaveBeenCalledTimes(1);
expect(mockEndTimer).toHaveBeenCalledWith({
status: 200,
method: 'get',
version: 1,
entrypoint: 'method.call/get:param',
});
});
it('should only record requests matching its own base path', async () => {
const ajv = new Ajv();
const app = express();
const settings = new CachedSettings();
const makeMetrics = () => {
const endTimer = jest.fn();
return {
endTimer,
summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
responseSizeHistogram: { observe: jest.fn() },
activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
};
};
const v1Metrics = makeMetrics();
const experimentalMetrics = makeMetrics();
const route = (router: Router<any, any, any>) =>
router.get(
'/test',
{
response: {
200: ajv.compile({
type: 'object',
properties: {
message: { type: 'string' },
},
}),
},
},
async () => ({
statusCode: 200,
body: { message: 'Metrics test successful' },
}),
);
const api = new Router('/api');
api
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\/v1\//),
api: { version: 'v1' } as any,
settings,
endpointTimeSummary: v1Metrics.summary as any,
endpointTimeHistogram: v1Metrics.histogram as any,
responseSizeHistogram: v1Metrics.responseSizeHistogram as any,
activeRequestsGauge: v1Metrics.activeRequestsGauge as any,
}),
)
.use(
metricsMiddleware({
basePathRegex: new RegExp(/^\/api\/experimental\//),
api: { version: 'experimental' } as any,
settings,
endpointTimeSummary: experimentalMetrics.summary as any,
endpointTimeHistogram: experimentalMetrics.histogram as any,
responseSizeHistogram: experimentalMetrics.responseSizeHistogram as any,
activeRequestsGauge: experimentalMetrics.activeRequestsGauge as any,
}),
)
.use(route(new Router('/v1')))
.use(route(new Router('/experimental')));
app.use(api.router);
expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
expect(v1Metrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'v1', entrypoint: 'test' });
expect(experimentalMetrics.summary.startTimer).not.toHaveBeenCalled();
expect(experimentalMetrics.activeRequestsGauge.inc).not.toHaveBeenCalled();
expect((await request(app).get('/api/experimental/test')).statusCode).toBe(200);
expect(experimentalMetrics.summary.startTimer).toHaveBeenCalledTimes(1);
expect(experimentalMetrics.endTimer).toHaveBeenCalledWith({
status: 200,
method: 'get',
version: 'experimental',
entrypoint: 'test',
});
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
});
it('should sample the default router and unmatched paths exactly once', async () => {
const ajv = new Ajv();
const app = express();
const settings = new CachedSettings();
const makeMetrics = () => {
const endTimer = jest.fn();
return {
endTimer,
summary: { startTimer: jest.fn().mockReturnValue(endTimer) },
histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) },
responseSizeHistogram: { observe: jest.fn() },
activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() },
};
};
const v1Metrics = makeMetrics();
const defaultMetrics = makeMetrics();
const wire = (metrics: ReturnType<typeof makeMetrics>, extra: { basePathRegex?: RegExp; excludePathRegex?: RegExp }, version: string) =>
metricsMiddleware({
...extra,
api: { version },
settings,
endpointTimeSummary: metrics.summary as any,
endpointTimeHistogram: metrics.histogram as any,
responseSizeHistogram: metrics.responseSizeHistogram as any,
activeRequestsGauge: metrics.activeRequestsGauge as any,
});
const api = new Router('/api')
.use(wire(v1Metrics, { basePathRegex: new RegExp(/^\/api\/v1\//) }, 'v1'))
.use(wire(defaultMetrics, { excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//) }, 'default'));
const route = (router: Router<any, any, any>, subpath: string) =>
router.get(subpath, { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200 as const, body: {} }));
// the catch-all router is mounted first on purpose: the exclusion guard has to hold
// regardless of the order the versioned routers happen to be registered in
api.use(route(new Router(''), 'info'));
api.use(route(new Router('/v1'), '/test'));
expect((await request(app.use(api.router)).get('/api/info')).statusCode).toBe(200);
expect(v1Metrics.summary.startTimer).not.toHaveBeenCalled();
expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'default', entrypoint: '/api/info' });
defaultMetrics.endTimer.mockClear();
expect((await request(app).get('/api/v1/test')).statusCode).toBe(200);
expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1);
expect(defaultMetrics.summary.startTimer).toHaveBeenCalledTimes(1); // still just the /api/info call
expect(defaultMetrics.endTimer).not.toHaveBeenCalled();
expect((await request(app).get('/api/bogus')).statusCode).toBe(404);
expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 404, method: 'get', version: 'default', entrypoint: '/api/*' });
});
});

View File

@@ -0,0 +1,71 @@
import type { MiddlewareHandler } from 'hono';
import type { Gauge, Histogram, Summary } from 'prom-client';
import type { CachedSettings } from '../../../settings/CachedSettings';
export const metricsMiddleware =
({
basePathRegex,
excludePathRegex,
api,
settings,
endpointTimeSummary,
endpointTimeHistogram,
responseSizeHistogram,
activeRequestsGauge,
}: {
basePathRegex?: RegExp;
excludePathRegex?: RegExp;
api: { version?: string };
settings: CachedSettings;
endpointTimeSummary: Summary;
endpointTimeHistogram: Histogram;
responseSizeHistogram: Histogram;
activeRequestsGauge: Gauge;
}): MiddlewareHandler =>
async (c, next) => {
// Several metrics middlewares share the same `/api` mount (v1, experimental, apps, default), so
// each one has to ignore the paths that belong to the others or a request gets sampled more than
// once. The versioned ones opt in by prefix; the catch-all opts out of the prefixes it does not own.
if (basePathRegex && !basePathRegex.test(c.req.path)) {
return next();
}
if (excludePathRegex?.test(c.req.path)) {
return next();
}
const rocketchatRestApiEnd = endpointTimeSummary.startTimer();
const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer();
const methodLabel = { method: c.req.method.toLowerCase() };
activeRequestsGauge.inc(methodLabel);
await next();
activeRequestsGauge.dec(methodLabel);
const { method, path, routePath } = c.req;
// get rid of the base path (i.e.: /api/v1/)
const entrypoint = basePathRegex ? routePath.replace(basePathRegex, '') : routePath;
const histogramLabels = {
status: c.res.status,
method: method.toLowerCase(),
version: api.version,
entrypoint: basePathRegex && entrypoint.startsWith('method.call') ? decodeURIComponent(path.replace(basePathRegex, '')) : entrypoint,
};
rocketchatRestApiEnd({
...histogramLabels,
...(settings.get('Prometheus_API_User_Agent') && { user_agent: c.req.header('user-agent') }),
});
rocketchatRestApiHistEnd(histogramLabels);
const contentLength = parseInt(c.res.headers.get('content-length') || '0', 10);
if (contentLength > 0) {
responseSizeHistogram.observe(histogramLabels, contentLength);
}
};

View File

@@ -0,0 +1,56 @@
import { Logger } from '@rocket.chat/logger';
import type { Method } from '@rocket.chat/rest-typings';
import type { MiddlewareHandler } from 'hono';
import { applyBreakingChanges } from '../../ApiClass';
import { API } from '../../api';
import { type PermissionsPayload, checkPermissionsForInvocation } from '../../api.helpers';
import type { TypedOptions } from '../../definition';
import type { HonoContext } from '../../router';
const logger = new Logger('PermissionsMiddleware');
export const permissionsMiddleware =
(options: TypedOptions): MiddlewareHandler =>
async (c: HonoContext, next) => {
if (!options.permissionsRequired) {
return next();
}
const user = c.get('user');
if (!user) {
if (applyBreakingChanges) {
const unauthorized = API.v1.unauthorized('You must be logged in to do this');
return c.json(unauthorized.body, unauthorized.statusCode);
}
const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(failure.body, failure.statusCode);
}
let hasPermission: boolean;
try {
hasPermission = await checkPermissionsForInvocation(
user._id,
options.permissionsRequired as PermissionsPayload,
c.req.method as Method,
);
} catch (e) {
logger.error({ msg: 'Error checking permissions for invocation', err: e });
const error = API.v1.internalError();
return c.json(error.body, error.statusCode);
}
if (!hasPermission) {
if (applyBreakingChanges) {
const forbidden = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(forbidden.body, forbidden.statusCode);
}
const failure = API.v1.forbidden('User does not have the permissions required for this action [error-unauthorized]');
return c.json(failure.body, failure.statusCode);
}
return next();
};

View File

@@ -0,0 +1,131 @@
import type { Context } from 'hono';
import { remoteAddressMiddleware } from './remoteAddressMiddleware';
describe('remoteAddressMiddleware', () => {
it('should set remoteAddress from x-real-ip header', async () => {
const mockContext = {
req: {
header: (name: string) => (name === 'x-real-ip' ? '192.168.1.1' : undefined),
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1');
expect(next).toHaveBeenCalled();
});
it('should set remoteAddress from socket.remoteAddress when no headers are present', async () => {
const mockContext = {
req: {
header: () => undefined,
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
expect(next).toHaveBeenCalled();
});
it('should handle x-forwarded-for header with HTTP_FORWARDED_COUNT', async () => {
process.env.HTTP_FORWARDED_COUNT = '2';
const mockContext = {
req: {
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1, 172.16.0.1' : undefined),
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
expect(next).toHaveBeenCalled();
});
it('should fallback to socket.remoteAddress when x-forwarded-for count is invalid', async () => {
process.env.HTTP_FORWARDED_COUNT = '5';
const mockContext = {
req: {
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1, 10.0.0.1' : undefined),
},
env: {
server: {
incoming: {
socket: {
remoteAddress: '10.0.0.1',
},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '10.0.0.1');
expect(next).toHaveBeenCalled();
});
it('should handle missing socket information', async () => {
const mockContext = {
req: {
header: (name: string) => (name === 'x-forwarded-for' ? '192.168.1.1' : undefined),
},
env: {
server: {
incoming: {
socket: {},
connection: {},
},
},
},
set: jest.fn(),
} as unknown as Context;
const next = jest.fn();
await remoteAddressMiddleware(mockContext, next);
expect(mockContext.set).toHaveBeenCalledWith('remoteAddress', '192.168.1.1');
expect(next).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,40 @@
import type { IncomingMessage } from 'node: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.split(',').map((ip) => ip.trim());
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();
};

View File

@@ -0,0 +1,25 @@
import { tracerSpan } from '@rocket.chat/tracing';
import type { MiddlewareHandler } from 'hono';
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) {
c.header('X-Trace-Id', span.spanContext().traceId);
}
await next();
span?.setAttribute('status', c.res.status);
},
);
};