diff --git a/.env-cmdrc-template b/.env-cmdrc-template index 93384bc..974cbe5 100644 --- a/.env-cmdrc-template +++ b/.env-cmdrc-template @@ -19,6 +19,7 @@ "METRICS_MAX_PAGE": 50, "REGEX_MAX_TIMEOUT": 3000, "REGEX_MAX_BLACLIST": 50, + "MAX_REQUEST_PER_MINUTE": 0, "GIT_OAUTH_CLIENT_ID": "MOCK_GIT_OAUTH_CLIENT_ID", "GIT_OAUTH_SECRET": "MOCK_GIT_OAUTH_SECRET", "BITBUCKET_OAUTH_CLIENT_ID": "MOCK_BITBUCKET_OAUTH_CLIENT_ID", diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index f78c34a..8266f0b 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -51,6 +51,7 @@ jobs: RELAY_BYPASS_VERIFICATION: true METRICS_ACTIVATED: true METRICS_MAX_PAGE: 50 + MAX_REQUEST_PER_MINUTE: 0 SWITCHER_API_ENABLE: false SWITCHER_API_LOGGER: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bead5c5..129ef75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,7 @@ jobs: RELAY_BYPASS_VERIFICATION: true METRICS_ACTIVATED: true METRICS_MAX_PAGE: 50 + MAX_REQUEST_PER_MINUTE: 0 SWITCHER_API_ENABLE: false SWITCHER_API_LOGGER: false diff --git a/config/.env.dev b/config/.env.dev index 0d955e8..9610334 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -9,6 +9,7 @@ RELAY_BYPASS_HTTPS=true RELAY_BYPASS_VERIFICATION=true REGEX_MAX_TIMEOUT=3000 REGEX_MAX_BLACLIST=50 +MAX_REQUEST_PER_MINUTE=0 HISTORY_ACTIVATED=true METRICS_ACTIVATED=true METRICS_MAX_PAGE=50 diff --git a/docker-compose.yml b/docker-compose.yml index ebcf8c5..f489681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: - METRICS_MAX_PAGE=${METRICS_MAX_PAGE} - REGEX_MAX_TIMEOUT=${REGEX_MAX_TIMEOUT} - REGEX_MAX_BLACLIST=${REGEX_MAX_BLACLIST} + - MAX_REQUEST_PER_MINUTE=${MAX_REQUEST_PER_MINUTE} - GIT_OAUTH_CLIENT_ID=${GIT_OAUTH_CLIENT_ID} - GIT_OAUTH_SECRET=${GIT_OAUTH_SECRET} - BITBUCKET_OAUTH_CLIENT_ID=${BITBUCKET_OAUTH_CLIENT_ID} diff --git a/package.json b/package.json index e4ebd55..06a9ee2 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "cors": "^2.8.5", "express": "^4.18.2", "express-basic-auth": "^1.2.1", + "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", "graphql": "^16.6.0", "graphql-http": "^1.18.0", diff --git a/src/app.js b/src/app.js index 4fd3aa7..72a7667 100644 --- a/src/app.js +++ b/src/app.js @@ -2,7 +2,7 @@ import express from 'express'; import swaggerUi from 'swagger-ui-express'; import { createHandler } from 'graphql-http/lib/use/express'; import cors from 'cors'; -import helmet from 'helmet'; +import helmet from 'helmet'; require('./db/mongoose'); @@ -22,6 +22,7 @@ import permissionRouter from './routers/permission'; import slackRouter from './routers/slack'; import schema from './client/schema'; import { appAuth, auth, resourcesAuth, slackAuth } from './middleware/auth'; +import { clientLimiter, defaultLimiter } from './middleware/limiter'; const app = express(); app.use(express.json()); @@ -57,9 +58,9 @@ const handler = (req, res, next) => createHandler({ schema, context: req })(req, res, next); // Component: Client API -app.use('/graphql', appAuth, handler); +app.use('/graphql', appAuth, clientLimiter, handler); // Admin: Client API -app.use('/adm-graphql', auth, handler); +app.use('/adm-graphql', auth, defaultLimiter, handler); // Slack: Client API app.use('/slack-graphql', slackAuth, handler); @@ -76,7 +77,7 @@ app.get('/swagger.json', resourcesAuth(), (_req, res) => { res.status(200).send(swaggerDocument); }); -app.get('/check', (_req, res) => { +app.get('/check', defaultLimiter, (_req, res) => { res.status(200).send({ status: 'UP', attributes: { @@ -92,6 +93,7 @@ app.get('/check', (_req, res) => { metrics: process.env.METRICS_ACTIVATED, max_metrics_pages: process.env.METRICS_MAX_PAGE, max_stretegy_op: process.env.MAX_STRATEGY_OPERATION, + max_rpm: process.env.MAX_REQUEST_PER_MINUTE, regex_max_timeout: process.env.REGEX_MAX_TIMEOUT, regex_max_blacklist: process.env.REGEX_MAX_BLACLIST } diff --git a/src/external/switcher-api-facade.js b/src/external/switcher-api-facade.js index 543ece9..361dc98 100644 --- a/src/external/switcher-api-facade.js +++ b/src/external/switcher-api-facade.js @@ -7,6 +7,7 @@ import { getTotalConfigsByDomainId } from '../services/config'; import { getTotalComponentsByDomainId } from '../services/component'; import { getTotalEnvByDomainId } from '../services/environment'; import { getTotalTeamsByDomainId } from '../services/team'; +import { DEFAULT_RATE_LIMIT } from '../middleware/limiter'; const apiKey = process.env.SWITCHER_API_KEY; const environment = process.env.SWITCHER_API_ENVIRONMENT; @@ -24,7 +25,8 @@ export const SwitcherKeys = Object.freeze({ ACCOUNT_IN_NOTIFY: 'ACCOUNT_IN_NOTIFY', ACCOUNT_OUT_NOTIFY: 'ACCOUNT_OUT_NOTIFY', SLACK_INTEGRATION: 'SLACK_INTEGRATION', - SLACK_UPDATE: 'SLACK_UPDATE' + SLACK_UPDATE: 'SLACK_UPDATE', + RATE_LIMIT: 'RATE_LIMIT' }); function switcherFlagResult(flag, message) { @@ -38,7 +40,7 @@ export async function checkFeature(feature, params, restrictTo = SwitcherKeys) { if (!key) throw new BadRequestError('Invalid feature'); - return switcher.isItOn(feature, params); + return switcher.isItOn(feature, params, true); } export async function checkDomain(req) { @@ -200,4 +202,22 @@ export function notifyAcDeletion(adminid) { switcher.isItOn(SwitcherKeys.ACCOUNT_OUT_NOTIFY, [ checkValue(adminid)]); +} + +export async function getRateLimit(key, component) { + if (process.env.SWITCHER_API_ENABLE === 'true' && key !== process.env.SWITCHER_API_KEY) { + const domain = await getDomainById(component.domain); + const result = await checkFeature(SwitcherKeys.RATE_LIMIT, [ + checkValue(String(domain.owner)) + ]); + + if (result) { + const log = Switcher.getLogger(SwitcherKeys.RATE_LIMIT) + .find(log => log.input[0][1] === String(domain.owner)); + + return JSON.parse(log.response.message).rate_limit; + } + } + + return parseInt(process.env.MAX_REQUEST_PER_MINUTE || DEFAULT_RATE_LIMIT); } \ No newline at end of file diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 33cc2da..2b78607 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -4,6 +4,7 @@ import { getAdmin, getAdminById } from '../services/admin'; import { getComponentById } from '../services/component'; import Admin from '../models/admin'; import Component from '../models/component'; +import { getRateLimit } from '../external/switcher-api-facade'; export async function auth(req, res, next) { try { @@ -67,6 +68,7 @@ export async function appAuth(req, res, next) { req.component = component.name; req.componentId = component._id; req.environment = decoded.environment; + req.rate_limit = decoded.rate_limit; next(); } catch (e) { res.status(401).send({ error: 'Invalid API token.' }); @@ -101,11 +103,13 @@ export async function appGenerateCredentials(req, res, next) { throw new Error(); } - const token = await component.generateAuthToken(req.body.environment); + const rate_limit = await getRateLimit(key, component); + const token = await component.generateAuthToken(req.body.environment, rate_limit); req.token = token; req.domain = domain; req.environment = req.body.environment; + req.rate_limit = rate_limit; next(); } catch (e) { res.status(401).send({ error: 'Invalid token request' }); diff --git a/src/middleware/limiter.js b/src/middleware/limiter.js new file mode 100644 index 0000000..31e11db --- /dev/null +++ b/src/middleware/limiter.js @@ -0,0 +1,25 @@ +import rateLimit, { MemoryStore } from 'express-rate-limit'; + +const DEFAULT_WINDOWMS = 1 * 60 * 1000; +const ERROR_MESSAGE = { + error: 'API request per minute quota exceeded' +}; + +export const DEFAULT_RATE_LIMIT = 1000; + +export const defaultLimiter = rateLimit({ + windowMs: DEFAULT_WINDOWMS, + max: parseInt(process.env.MAX_REQUEST_PER_MINUTE || DEFAULT_RATE_LIMIT), + standardHeaders: true, + message: ERROR_MESSAGE, + store: new MemoryStore(), +}); + +export const clientLimiter = rateLimit({ + windowMs: DEFAULT_WINDOWMS, + keyGenerator: (request) => request.domain, + max: (request) => request.rate_limit, + standardHeaders: true, + message: ERROR_MESSAGE, + store: new MemoryStore(), +}); \ No newline at end of file diff --git a/src/models/component.js b/src/models/component.js index c5ed22d..d6b294f 100644 --- a/src/models/component.js +++ b/src/models/component.js @@ -63,7 +63,7 @@ componentSchema.methods.generateApiKey = async function () { return apiKey; }; -componentSchema.methods.generateAuthToken = async function (environment) { +componentSchema.methods.generateAuthToken = async function (environment, rate_limit) { const component = this; const options = { @@ -73,6 +73,7 @@ componentSchema.methods.generateAuthToken = async function (environment) { return jwt.sign(({ component: component._id, environment, + rate_limit, vc: component.apihash.substring(50, component.apihash.length - 1) }), process.env.JWT_SECRET, options); }; diff --git a/src/routers/client-api.js b/src/routers/client-api.js index f432786..95870e5 100644 --- a/src/routers/client-api.js +++ b/src/routers/client-api.js @@ -5,6 +5,7 @@ import { appAuth, appGenerateCredentials } from '../middleware/auth'; import { resolveCriteria, checkDomain } from '../client/resolvers'; import { getConfigs } from '../services/config'; import { body, check, query } from 'express-validator'; +import { clientLimiter } from '../middleware/limiter'; const router = new express.Router(); @@ -12,7 +13,7 @@ const router = new express.Router(); // GET /check?key=KEY&showReason=true // GET /check?key=KEY&showStrategy=true // GET /check?key=KEY&bypassMetric=true -router.post('/criteria', appAuth, [ +router.post('/criteria', appAuth, clientLimiter, [ query('key').isLength({ min: 1 }), body('entry.*.input').isString() ], validate, checkConfig, checkConfigComponent, async (req, res) => { @@ -42,7 +43,7 @@ router.post('/criteria', appAuth, [ } }); -router.get('/criteria/snapshot_check/:version', appAuth, async (req, res) => { +router.get('/criteria/snapshot_check/:version', appAuth, clientLimiter, async (req, res) => { try { const domain = await checkDomain(req.domain); const version = req.params.version; @@ -61,7 +62,7 @@ router.get('/criteria/snapshot_check/:version', appAuth, async (req, res) => { } }); -router.post('/criteria/switchers_check', appAuth, [ +router.post('/criteria/switchers_check', appAuth, clientLimiter, [ check('switchers', 'Switcher Key is required').isArray().isLength({ min: 1 }) ], validate, async (req, res) => { try { @@ -73,7 +74,7 @@ router.post('/criteria/switchers_check', appAuth, [ } }); -router.post('/criteria/auth', appGenerateCredentials, async (req, res) => { +router.post('/criteria/auth', appGenerateCredentials, clientLimiter, async (req, res) => { try { const { exp } = jwt.decode(req.token); res.send({ token: req.token, exp }); diff --git a/tests/fixtures/db_api.js b/tests/fixtures/db_api.js index 02759cd..ce0d9ea 100644 --- a/tests/fixtures/db_api.js +++ b/tests/fixtures/db_api.js @@ -76,6 +76,7 @@ export const permission1 = { }; export const component1Id = new mongoose.Types.ObjectId(); +export const component1Key = randomUUID(); export const component1 = { _id: component1Id, name: 'TestApp', @@ -271,9 +272,8 @@ export const setupDatabase = async () => { await new Permission(permissionAll2).save(); await new Permission(permissionAll3).save(); await new Permission(permissionAll4).save(); - - const apiKey = randomUUID(); - const hash = await bcryptjs.hash(apiKey, 8); + + const hash = await bcryptjs.hash(component1Key, 8); component1.apihash = hash; await new Component(component1).save(); }; \ No newline at end of file diff --git a/tests/unit-test/switcher-api-facade.test.js b/tests/unit-test/switcher-api-facade.test.js index b3f4647..1e87693 100644 --- a/tests/unit-test/switcher-api-facade.test.js +++ b/tests/unit-test/switcher-api-facade.test.js @@ -14,7 +14,8 @@ import { checkSlackIntegration, notifyAcCreation, notifyAcDeletion, - SwitcherKeys + SwitcherKeys, + getRateLimit } from '../../src/external/switcher-api-facade'; import { setupDatabase, @@ -23,9 +24,12 @@ import { domainId, domainDocument, groupConfigDocument, - config1Document + config1Document, + component1, + component1Key } from '../fixtures/db_api'; -import { Switcher } from 'switcher-client'; +import { Switcher, checkValue } from 'switcher-client'; +import ExecutionLogger from 'switcher-client/src/lib/utils/executionLogger'; afterAll(async () => { await new Promise(resolve => setTimeout(resolve, 1000)); @@ -238,4 +242,28 @@ describe('Testing Switcher API Facade', () => { await expect(call()).rejects.toThrowError('Slack Integration is not available.'); }); + test('UNIT_API_FACADE - Should read rate limit - 100 Request Per Minute', async () => { + const call = async () => { + Switcher.assume(SwitcherKeys.RATE_LIMIT).true(); + ExecutionLogger.add( + { message: JSON.stringify({ rate_limit: 100 }) }, + SwitcherKeys.RATE_LIMIT, + [checkValue(domainDocument.owner.toString())] + ); + + return getRateLimit(component1Key, component1); + }; + + await expect(call()).resolves.toBe(100); + }); + + test('UNIT_API_FACADE - Should NOT read rate limit - Default Request Per Minute', async () => { + const call = async () => { + Switcher.assume(SwitcherKeys.RATE_LIMIT).false(); + return getRateLimit(component1Key, component1); + }; + + await expect(call()).resolves.toBe(parseInt(process.env.MAX_REQUEST_PER_MINUTE)); + }); + }); \ No newline at end of file