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
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:
73
apps/meteor/server/api/v1/middlewares/authentication.ts
Normal file
73
apps/meteor/server/api/v1/middlewares/authentication.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
48
apps/meteor/server/api/v1/middlewares/authenticationHono.ts
Normal file
48
apps/meteor/server/api/v1/middlewares/authenticationHono.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
326
apps/meteor/server/api/v1/middlewares/cors.spec.ts
Normal file
326
apps/meteor/server/api/v1/middlewares/cors.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
62
apps/meteor/server/api/v1/middlewares/cors.ts
Normal file
62
apps/meteor/server/api/v1/middlewares/cors.ts
Normal 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();
|
||||
};
|
||||
86
apps/meteor/server/api/v1/middlewares/experimental.spec.ts
Normal file
86
apps/meteor/server/api/v1/middlewares/experimental.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
29
apps/meteor/server/api/v1/middlewares/experimental.ts
Normal file
29
apps/meteor/server/api/v1/middlewares/experimental.ts
Normal 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();
|
||||
};
|
||||
37
apps/meteor/server/api/v1/middlewares/logger.ts
Normal file
37
apps/meteor/server/api/v1/middlewares/logger.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
349
apps/meteor/server/api/v1/middlewares/metrics.spec.ts
Normal file
349
apps/meteor/server/api/v1/middlewares/metrics.spec.ts
Normal 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/*' });
|
||||
});
|
||||
});
|
||||
71
apps/meteor/server/api/v1/middlewares/metrics.ts
Normal file
71
apps/meteor/server/api/v1/middlewares/metrics.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
56
apps/meteor/server/api/v1/middlewares/permissions.ts
Normal file
56
apps/meteor/server/api/v1/middlewares/permissions.ts
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
25
apps/meteor/server/api/v1/middlewares/tracer.ts
Normal file
25
apps/meteor/server/api/v1/middlewares/tracer.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user