import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; import { DDPCommon } from 'meteor/ddp-common'; import { DDP } from 'meteor/ddp'; import { Accounts } from 'meteor/accounts-base'; import { Restivus } from 'meteor/nimble:restivus'; import { RateLimiter } from 'meteor/rate-limit'; import _ from 'underscore'; import { Logger } from '../../logger'; import { settings } from '../../settings'; import { metrics } from '../../metrics'; import { hasPermission, hasAllPermission } from '../../authorization'; import { getDefaultUserFields } from '../../utils/server/functions/getDefaultUserFields'; import { checkCodeForUser } from '../../2fa/server/code'; const logger = new Logger('API', {}); const rateLimiterDictionary = {}; export const defaultRateLimiterOptions = { numRequestsAllowed: settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default'), intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), }; let prometheusAPIUserAgent = false; export let API = {}; const getRequestIP = (req) => { const socket = req.socket || req.connection?.socket; const remoteAddress = req.headers['x-real-ip'] || socket?.remoteAddress || req.connection?.remoteAddress || null; let forwardedFor = req.headers['x-forwarded-for']; if (!socket) { return remoteAddress || forwardedFor || null; } const httpForwardedCount = parseInt(process.env.HTTP_FORWARDED_COUNT) || 0; if (httpForwardedCount <= 0) { return remoteAddress; } if (!_.isString(forwardedFor)) { return remoteAddress; } forwardedFor = forwardedFor.trim().split(/\s*,\s*/); if (httpForwardedCount > forwardedFor.length) { return remoteAddress; } return forwardedFor[forwardedFor.length - httpForwardedCount]; }; export class APIClass extends Restivus { constructor(properties) { super(properties); this.apiPath = properties.apiPath; this.authMethods = []; this.fieldSeparator = '.'; this.defaultFieldsToExclude = { joinCode: 0, members: 0, importIds: 0, e2e: 0, }; this.defaultLimitedUserFieldsToExclude = { avatarOrigin: 0, emails: 0, phone: 0, statusConnection: 0, createdAt: 0, lastLogin: 0, services: 0, requirePasswordChange: 0, requirePasswordChangeReason: 0, roles: 0, statusDefault: 0, _updatedAt: 0, settings: 0, }; this.limitedUserFieldsToExclude = this.defaultLimitedUserFieldsToExclude; this.limitedUserFieldsToExcludeIfIsPrivilegedUser = { services: 0, }; } setLimitedCustomFields(customFields) { const nonPublicFieds = customFields.reduce((acc, customField) => { acc[`customFields.${ customField }`] = 0; return acc; }, {}); this.limitedUserFieldsToExclude = { ...this.defaultLimitedUserFieldsToExclude, ...nonPublicFieds, }; } hasHelperMethods() { return API.helperMethods.size !== 0; } getHelperMethods() { return API.helperMethods; } getHelperMethod(name) { return API.helperMethods.get(name); } addAuthMethod(method) { this.authMethods.push(method); } shouldAddRateLimitToRoute(options) { const { version } = this._config; const { rateLimiterOptions } = options; return (typeof rateLimiterOptions === 'object' || rateLimiterOptions === undefined) && Boolean(version) && !process.env.TEST_MODE && Boolean(defaultRateLimiterOptions.numRequestsAllowed && defaultRateLimiterOptions.intervalTimeInMS); } success(result = {}) { if (_.isObject(result)) { result.success = true; } result = { statusCode: 200, body: result, }; logger.debug('Success', result); return result; } failure(result, errorType, stack, error) { if (_.isObject(result)) { result.success = false; } else { result = { success: false, error: result, stack, }; if (errorType) { result.errorType = errorType; } if (error && error.details) { try { result.details = JSON.parse(error.details); } catch (e) { result.details = error.details; } } } result = { statusCode: 400, body: result, }; logger.debug('Failure', result); return result; } notFound(msg) { return { statusCode: 404, body: { success: false, error: msg || 'Resource not found', }, }; } internalError(msg) { return { statusCode: 500, body: { success: false, error: msg || 'Internal error occured', }, }; } unauthorized(msg) { return { statusCode: 403, body: { success: false, error: msg || 'unauthorized', }, }; } tooManyRequests(msg) { return { statusCode: 429, body: { success: false, error: msg || 'Too many requests', }, }; } getRateLimiter(route) { return rateLimiterDictionary[route]; } shouldVerifyRateLimit(route, userId) { return rateLimiterDictionary.hasOwnProperty(route) && settings.get('API_Enable_Rate_Limiter') === true && (process.env.NODE_ENV !== 'development' || settings.get('API_Enable_Rate_Limiter_Dev') === true) && !(userId && hasPermission(userId, 'api-bypass-rate-limit')); } enforceRateLimit(objectForRateLimitMatch, request, response, userId) { if (!this.shouldVerifyRateLimit(objectForRateLimitMatch.route, userId)) { return; } rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.increment(objectForRateLimitMatch); const attemptResult = rateLimiterDictionary[objectForRateLimitMatch.route].rateLimiter.check(objectForRateLimitMatch); const timeToResetAttempsInSeconds = Math.ceil(attemptResult.timeToReset / 1000); response.setHeader('X-RateLimit-Limit', rateLimiterDictionary[objectForRateLimitMatch.route].options.numRequestsAllowed); response.setHeader('X-RateLimit-Remaining', attemptResult.numInvocationsLeft); response.setHeader('X-RateLimit-Reset', new Date().getTime() + attemptResult.timeToReset); if (!attemptResult.allowed) { throw new Meteor.Error('error-too-many-requests', `Error, too many requests. Please slow down. You must wait ${ timeToResetAttempsInSeconds } seconds before trying this endpoint again.`, { timeToReset: attemptResult.timeToReset, seconds: timeToResetAttempsInSeconds, }); } } reloadRoutesToRefreshRateLimiter() { const { version } = this._config; this._routes.forEach((route) => { if (this.shouldAddRateLimitToRoute(route.options)) { this.addRateLimiterRuleForRoutes({ routes: [route.path], rateLimiterOptions: route.options.rateLimiterOptions || defaultRateLimiterOptions, endpoints: Object.keys(route.endpoints).filter((endpoint) => endpoint !== 'options'), apiVersion: version, }); } }); } addRateLimiterRuleForRoutes({ routes, rateLimiterOptions, endpoints, apiVersion }) { if (!rateLimiterOptions.numRequestsAllowed) { throw new Meteor.Error('You must set "numRequestsAllowed" property in rateLimiter for REST API endpoint'); } if (!rateLimiterOptions.intervalTimeInMS) { throw new Meteor.Error('You must set "intervalTimeInMS" property in rateLimiter for REST API endpoint'); } const addRateLimitRuleToEveryRoute = (routes) => { routes.forEach((route) => { rateLimiterDictionary[route] = { rateLimiter: new RateLimiter(), options: rateLimiterOptions, }; const rateLimitRule = { IPAddr: (input) => input, route, }; rateLimiterDictionary[route].rateLimiter.addRule(rateLimitRule, rateLimiterOptions.numRequestsAllowed, rateLimiterOptions.intervalTimeInMS); }); }; routes .map((route) => this.namedRoutes(route, endpoints, apiVersion)) .map(addRateLimitRuleToEveryRoute); } processTwoFactor({ userId, request, invocation, options, connection }) { const code = request.headers['x-2fa-code']; const method = request.headers['x-2fa-method']; checkCodeForUser({ user: userId, code, method, options, connection }); invocation.twoFactorChecked = true; } getFullRouteName(route, method, apiVersion = null) { let prefix = `/${ this.apiPath || '' }`; if (apiVersion) { prefix += `${ apiVersion }/`; } return `${ prefix }${ route }${ method }`; } namedRoutes(route, endpoints, apiVersion) { const routeActions = Array.isArray(endpoints) ? endpoints : Object.keys(endpoints); return routeActions.map((action) => this.getFullRouteName(route, action, apiVersion)); } addRoute(routes, options, endpoints) { // Note: required if the developer didn't provide options if (typeof endpoints === 'undefined') { endpoints = options; options = {}; } let shouldVerifyPermissions; if (!_.isArray(options.permissionsRequired)) { options.permissionsRequired = undefined; shouldVerifyPermissions = false; } else { shouldVerifyPermissions = !!options.permissionsRequired.length; } // Allow for more than one route using the same option and endpoints if (!_.isArray(routes)) { routes = [routes]; } const { version } = this._config; if (this.shouldAddRateLimitToRoute(options)) { this.addRateLimiterRuleForRoutes({ routes, rateLimiterOptions: options.rateLimiterOptions || defaultRateLimiterOptions, endpoints, apiVersion: version, }); } routes.forEach((route) => { // Note: This is required due to Restivus calling `addRoute` in the constructor of itself Object.keys(endpoints).forEach((method) => { const _options = { ...options }; if (typeof endpoints[method] === 'function') { endpoints[method] = { action: endpoints[method] }; } else { const extraOptions = { ...endpoints[method] }; delete extraOptions.action; Object.assign(_options, extraOptions); } // Add a try/catch for each endpoint const originalAction = endpoints[method].action; const api = this; endpoints[method].action = function _internalRouteActionHandler() { const rocketchatRestApiEnd = metrics.rocketchatRestApi.startTimer({ method, version, ...prometheusAPIUserAgent && { user_agent: this.request.headers['user-agent'] }, entrypoint: route.startsWith('method.call') ? decodeURIComponent(this.request._parsedUrl.pathname.slice(8)) : route, }); logger.debug(`${ this.request.method.toUpperCase() }: ${ this.request.url }`); this.requestIp = getRequestIP(this.request); const objectForRateLimitMatch = { IPAddr: this.requestIp, route: `${ this.request.route }${ this.request.method.toLowerCase() }`, }; let result; const connection = { id: Random.id(), close() {}, token: this.token, httpHeaders: this.request.headers, clientAddress: this.requestIp, }; try { api.enforceRateLimit(objectForRateLimitMatch, this.request, this.response, this.userId); if (shouldVerifyPermissions && (!this.userId || !hasAllPermission(this.userId, _options.permissionsRequired))) { throw new Meteor.Error('error-unauthorized', 'User does not have the permissions required for this action', { permissions: _options.permissionsRequired, }); } const invocation = new DDPCommon.MethodInvocation({ connection, isSimulation: false, userId: this.userId, }); Accounts._accountData[connection.id] = { connection, }; Accounts._setAccountData(connection.id, 'loginToken', this.token); if (_options.twoFactorRequired) { api.processTwoFactor({ userId: this.userId, request: this.request, invocation, options: _options.twoFactorOptions, connection }); } result = DDP._CurrentInvocation.withValue(invocation, () => originalAction.apply(this)); } catch (e) { logger.debug(`${ method } ${ route } threw an error:`, e.stack); const apiMethod = { 'error-too-many-requests': 'tooManyRequests', 'error-unauthorized': 'unauthorized', }[e.error] || 'failure'; result = API.v1[apiMethod](typeof e === 'string' ? e : e.message, e.error, process.env.TEST_MODE ? e.stack : undefined, e); } finally { delete Accounts._accountData[connection.id]; } result = result || API.v1.success(); rocketchatRestApiEnd({ status: result.statusCode, }); return result; }; if (this.hasHelperMethods()) { for (const [name, helperMethod] of this.getHelperMethods()) { endpoints[method][name] = helperMethod; } } // Allow the endpoints to make usage of the logger which respects the user's settings endpoints[method].logger = logger; }); super.addRoute(route, options, endpoints); }); } _initAuth() { const loginCompatibility = (bodyParams, request) => { // Grab the username or email that the user is logging in with const { user, username, email, password, code: bodyCode } = bodyParams; let usernameToLDAPLogin = ''; if (password == null) { return bodyParams; } if (_.without(Object.keys(bodyParams), 'user', 'username', 'email', 'password', 'code').length > 0) { return bodyParams; } const code = bodyCode || request.headers['x-2fa-code']; const auth = { password, }; if (typeof user === 'string') { auth.user = user.includes('@') ? { email: user } : { username: user }; usernameToLDAPLogin = user; } else if (username) { auth.user = { username }; usernameToLDAPLogin = username; } else if (email) { auth.user = { email }; usernameToLDAPLogin = email; } if (auth.user == null) { return bodyParams; } if (auth.password.hashed) { auth.password = { digest: auth.password, algorithm: 'sha-256', }; } const objectToLDAPLogin = { ldap: true, username: usernameToLDAPLogin, ldapPass: auth.password, ldapOptions: {}, }; if (settings.get('LDAP_Enable') && !code) { return objectToLDAPLogin; } if (code) { return { totp: { code, login: settings.get('LDAP_Enable') ? objectToLDAPLogin : auth, }, }; } return auth; }; const self = this; this.addRoute('login', { authRequired: false }, { post() { const args = loginCompatibility(this.bodyParams, this.request); const getUserInfo = self.getHelperMethod('getUserInfo'); const invocation = new DDPCommon.MethodInvocation({ connection: { close() {}, httpHeaders: this.request.headers, clientAddress: getRequestIP(this.request), }, }); let auth; try { auth = DDP._CurrentInvocation.withValue(invocation, () => Meteor.call('login', args)); } catch (error) { let e = error; if (error.reason === 'User not found') { e = { error: 'Unauthorized', reason: 'Unauthorized', }; } return { statusCode: 401, body: { status: 'error', error: e.error, details: e.details, message: e.reason || e.message, }, }; } this.user = Meteor.users.findOne({ _id: auth.id, }, { fields: getDefaultUserFields(), }); this.userId = this.user._id; const response = { status: 'success', data: { userId: this.userId, authToken: auth.token, me: getUserInfo(this.user), }, }; const extraData = self._config.onLoggedIn && self._config.onLoggedIn.call(this); if (extraData != null) { _.extend(response.data, { extra: extraData, }); } return response; }, }); const logout = function() { // Remove the given auth token from the user's account const authToken = this.request.headers['x-auth-token']; const hashedToken = Accounts._hashLoginToken(authToken); const tokenLocation = self._config.auth.token; const index = tokenLocation.lastIndexOf('.'); const tokenPath = tokenLocation.substring(0, index); const tokenFieldName = tokenLocation.substring(index + 1); const tokenToRemove = {}; tokenToRemove[tokenFieldName] = hashedToken; const tokenRemovalQuery = {}; tokenRemovalQuery[tokenPath] = tokenToRemove; Meteor.users.update(this.user._id, { $pull: tokenRemovalQuery, }); const response = { status: 'success', data: { message: 'You\'ve been logged out!', }, }; // Call the logout hook with the authenticated user attached const extraData = self._config.onLoggedOut && self._config.onLoggedOut.call(this); if (extraData != null) { _.extend(response.data, { extra: extraData, }); } return response; }; /* Add a logout endpoint to the API After the user is logged out, the onLoggedOut hook is called (see Restfully.configure() for adding hook). */ return this.addRoute('logout', { authRequired: true, }, { get() { console.warn('Warning: Default logout via GET will be removed in Restivus v1.0. Use POST instead.'); console.warn(' See https://github.com/kahmali/meteor-restivus/issues/100'); return logout.call(this); }, post: logout, }); } } const getUserAuth = function _getUserAuth(...args) { const invalidResults = [undefined, null, false]; return { token: 'services.resume.loginTokens.hashedToken', user() { if (this.bodyParams && this.bodyParams.payload) { this.bodyParams = JSON.parse(this.bodyParams.payload); } for (let i = 0; i < API.v1.authMethods.length; i++) { const method = API.v1.authMethods[i]; if (typeof method === 'function') { const result = method.apply(this, args); if (!invalidResults.includes(result)) { return result; } } } let token; if (this.request.headers['x-auth-token']) { token = Accounts._hashLoginToken(this.request.headers['x-auth-token']); } this.token = token; return { userId: this.request.headers['x-user-id'], token, }; }, }; }; API = { helperMethods: new Map(), getUserAuth, ApiClass: APIClass, }; const defaultOptionsEndpoint = function _defaultOptionsEndpoint() { if (this.request.method === 'OPTIONS' && this.request.headers['access-control-request-method']) { if (settings.get('API_Enable_CORS') === true) { this.response.writeHead(200, { 'Access-Control-Allow-Origin': settings.get('API_CORS_Origin'), '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', }); } else { this.response.writeHead(405); this.response.write('CORS not enabled. Go to "Admin > General > REST Api" to enable it.'); } } else { this.response.writeHead(404); } this.done(); }; const createApi = function _createApi(_api, options = {}) { _api = _api || new APIClass(Object.assign({ apiPath: 'api/', useDefaultAuth: true, prettyJson: process.env.NODE_ENV === 'development', defaultOptionsEndpoint, auth: getUserAuth(), }, options)); delete _api._config.defaultHeaders['Access-Control-Allow-Origin']; delete _api._config.defaultHeaders['Access-Control-Allow-Headers']; delete _api._config.defaultHeaders.Vary; if (settings.get('API_Enable_CORS')) { const origin = settings.get('API_CORS_Origin'); if (origin) { _api._config.defaultHeaders['Access-Control-Allow-Origin'] = origin; if (origin !== '*') { _api._config.defaultHeaders.Vary = 'Origin'; } } _api._config.defaultHeaders['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token'; } return _api; }; const createApis = function _createApis() { API.v1 = createApi(API.v1, { version: 'v1', }); API.default = createApi(API.default); }; // also create the API immediately createApis(); // register the API to be re-created once the CORS-setting changes. settings.get(/^(API_Enable_CORS|API_CORS_Origin)$/, () => { createApis(); }); settings.get('Accounts_CustomFields', (key, value) => { if (!value) { return API.v1.setLimitedCustomFields([]); } try { const customFields = JSON.parse(value); const nonPublicCustomFields = Object.keys(customFields).filter((customFieldKey) => customFields[customFieldKey].public !== true); API.v1.setLimitedCustomFields(nonPublicCustomFields); } catch (error) { console.warn('Invalid Custom Fields', error); } }); settings.get('API_Enable_Rate_Limiter_Limit_Time_Default', (key, value) => { defaultRateLimiterOptions.intervalTimeInMS = value; API.v1.reloadRoutesToRefreshRateLimiter(); }); settings.get('API_Enable_Rate_Limiter_Limit_Calls_Default', (key, value) => { defaultRateLimiterOptions.numRequestsAllowed = value; API.v1.reloadRoutesToRefreshRateLimiter(); }); settings.get('Prometheus_API_User_Agent', (key, value) => { prometheusAPIUserAgent = value; });