diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eea75c3374..390c9710082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Fixes: - [core] fixes for changeOwner script - [core] Add null checking for user permission when opening the dashboard - [core] Preserve URL hash during oauth +- [core] Rate limiting for api endpoints Enterprise Fixes: - [retention_segments] Adding null check for breakdown filtering @@ -29,7 +30,7 @@ Dependencies: - Bump sass from 1.93.3 to 1.96.0 - Bump sass-embedded from 1.93.3 to 1.96.0 - Bump sharp from 0.34.4 to 0.34.5 -- Bump sharp from 0.34.4 to 0.34.5 +- Bump sharp from 0.34.4 to 0.34.5 - Bump swiper from 11.2.10 to 12.0.3 - Bump terser from 5.44.0 to 5.44.1 - Bump vite from 7.1.12 to 7.2.7 diff --git a/api/api.js b/api/api.js index efb4f061c65..71422fa06a8 100644 --- a/api/api.js +++ b/api/api.js @@ -17,6 +17,7 @@ const pack = require('../package.json'); const versionInfo = require('../frontend/express/version.info.js'); const moment = require("moment"); const tracker = require('./parts/mgmt/tracker.js'); +const { RateLimiterMemory } = require("rate-limiter-flexible"); var t = ["countly:", "api"]; common.processRequest = processRequest; @@ -119,6 +120,8 @@ plugins.connectToAllDatabases().then(function() { api_additional_headers: "X-Frame-Options:deny\nX-XSS-Protection:1; mode=block\nStrict-Transport-Security:max-age=31536000; includeSubDomains; preload\nAccess-Control-Allow-Origin:*", dashboard_rate_limit_window: 60, dashboard_rate_limit_requests: 500, + api_rate_limit_window: 0, + api_rate_limit_requests: 0, proxy_hostname: "", proxy_port: "", proxy_username: "", @@ -375,6 +378,35 @@ plugins.connectToAllDatabases().then(function() { console.log("Starting worker", process.pid, "parent:", process.ppid); const taskManager = require('./utils/taskmanager.js'); + const rateLimitWindow = parseInt(plugins.getConfig("security").api_rate_limit_window, 10) || 0; + const rateLimitRequests = parseInt(plugins.getConfig("security").api_rate_limit_requests, 10) || 0; + const rateLimiterInstance = new RateLimiterMemory({ points: rateLimitRequests, duration: rateLimitWindow }); + const requiresRateLimiting = rateLimitWindow > 0 && rateLimitRequests > 0; + const omit = /^\/i(\/bulk)?(\?|$)/; // omit /i endpoint from rate limiting + /** + * Rate Limiting Middleware + * @param {Function} next - The next middleware function + * @returns {Function} - The wrapped middleware function with rate limiting + */ + const rateLimit = (next) => { + if (!requiresRateLimiting) { + return next; + } + return (req, res) => { + if (omit.test(req.url)) { + return next(req, res); + } + const ip = common.getIpAddress(req); + rateLimiterInstance + .consume(ip) + .then(() => next(req, res)) + .catch(() => { + log.w(`Rate limit exceeded for IP: ${ip}`); + common.returnMessage({ req, res, qstring: {} }, 429, "Too Many Requests"); + }); + }; + }; + common.cache = new CacheWorker(); common.cache.start(); @@ -412,10 +444,10 @@ plugins.connectToAllDatabases().then(function() { if (common.config.api.ssl.ca) { sslOptions.ca = fs.readFileSync(common.config.api.ssl.ca); } - server = https.createServer(sslOptions, handleRequest); + server = https.createServer(sslOptions, rateLimit(handleRequest)); } else { - server = http.createServer(handleRequest); + server = http.createServer(rateLimit(handleRequest)); } server.listen(serverOptions.port, serverOptions.host).timeout = common.config.api.timeout || 120000; diff --git a/api/utils/common.js b/api/utils/common.js index 1e3e832eefa..ec080ca9a9a 100644 --- a/api/utils/common.js +++ b/api/utils/common.js @@ -1486,7 +1486,9 @@ var ipLogger = common.log('ip:api'); common.getIpAddress = function(req) { var ipAddress = ""; if (req) { - if (req.headers) { + // TODO: add config option to trust x-forwarded-for header + // or add a configuration option to set trusted proxies + if (req.headers && ("x-forwarded-for" in req.headers || "x-real-ip" in req.headers)) { ipAddress = req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || ""; } else if (req.connection && req.connection.remoteAddress) { diff --git a/package-lock.json b/package-lock.json index ca7c0c960d9..be2524e4a89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "offline-geocoder": "git+https://github.com/Countly/offline-geocoder.git", "properties-parser": "0.6.0", "puppeteer": "^24.6.1", + "rate-limiter-flexible": "^9.0.1", "sass": "1.96.0", "semver": "^7.7.1", "sharp": "^0.34.2", @@ -169,7 +170,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2212,7 +2212,6 @@ "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/utils": "^8.13.0", "eslint-visitor-keys": "^4.2.0", @@ -2516,7 +2515,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3320,7 +3318,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4531,8 +4528,7 @@ "version": "0.0.1534754", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -4881,7 +4877,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6551,7 +6546,6 @@ "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.6.1.tgz", "integrity": "sha512-/ABUy3gYWu5iBmrUSRBP97JLpQUm0GgVveDCp6t3yRNIoltIYw7rEj3g5y1o2PGPR2vfTRGa7WC/LZHLTXnEzA==", "license": "MIT", - "peer": true, "dependencies": { "dateformat": "~4.6.2", "eventemitter2": "~0.4.13", @@ -9266,7 +9260,6 @@ "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -10723,7 +10716,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11216,6 +11208,12 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-9.0.1.tgz", + "integrity": "sha512-sO+QdoGPCxroi4VkO2FIVjfUGuexhRkBc9ROHqu5eVEEz+oPHzQqvCc25ajFfMUBosbNGb6qpNa8xmxH9YNZsg==", + "license": "ISC" + }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -12066,7 +12064,6 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", - "peer": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -12888,7 +12885,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13101,6 +13097,7 @@ "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", @@ -13125,6 +13122,7 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" diff --git a/package.json b/package.json index fa8c6325639..046f263ed2e 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "offline-geocoder": "git+https://github.com/Countly/offline-geocoder.git", "properties-parser": "0.6.0", "puppeteer": "^24.6.1", + "rate-limiter-flexible": "^9.0.1", "sass": "1.96.0", "semver": "^7.7.1", "sharp": "^0.34.2", diff --git a/plugins/plugins/frontend/public/localization/plugins.properties b/plugins/plugins/frontend/public/localization/plugins.properties index aecc967e390..d4a3ef37dfd 100644 --- a/plugins/plugins/frontend/public/localization/plugins.properties +++ b/plugins/plugins/frontend/public/localization/plugins.properties @@ -127,6 +127,8 @@ configs.user-level-configuration = User Level Configuration configs.table-description = Settings in this section will override global settings for the user configs.security-dashboard_rate_limit_window = Dashboard Rate Limit Time (seconds) configs.security-dashboard_rate_limit_requests = Dashboard Request Rate Limit +configs.security-api_rate_limit_window = API Rate Limit Time (seconds) +configs.security-api_rate_limit_requests = API Request Rate Limit configs.danger-zone = Danger Zone configs.password = Password configs.fill-required-fields = Please fill all required fields @@ -234,6 +236,8 @@ configs.help.security-password_expiration = Number of days after which user must configs.help.user-level-configuration = Allow separate dashboard users to change these configs for their account only. configs.help.security-dashboard_rate_limit_window = Will start blocking if request amount is reached in this time window configs.help.security-dashboard_rate_limit_requests = How many requests to allow per time window? +configs.help.security-api_rate_limit_window = Will start blocking if request amount is reached in this time window. Requires a server restart. +configs.help.security-api_rate_limit_requests = How many requests to allow per time window? Requires a server restart. configs.help.push-proxyhost = Hostname or IP address of HTTP CONNECT proxy server to use for communication with APN & FCM when sending push notifications. configs.help.push-proxyport = Port number of the proxy server configs.help.push-proxyuser = (if needed) Username for proxy server HTTP Basic authentication