From a68bbc54c02524bf679247802601c376127c6b88 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 27 Feb 2025 15:02:43 +0200 Subject: [PATCH] feat: allow plugin specific rate limiting Signed-off-by: Hellgren Heikki --- packages/backend-defaults/config.d.ts | 40 +++++++++ .../http/createRateLimitMiddleware.ts | 40 +++++++++ .../httpRouter/httpRouterServiceFactory.ts | 7 +- .../rootHttpRouter/http/MiddlewareFactory.ts | 64 +++------------ .../RateLimitStoreFactory.test.ts | 4 +- .../http => lib}/RateLimitStoreFactory.ts | 0 .../src/lib/rateLimitMiddleware.ts | 81 +++++++++++++++++++ 7 files changed, 180 insertions(+), 56 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts rename packages/backend-defaults/src/{entrypoints/rootHttpRouter/http => lib}/RateLimitStoreFactory.test.ts (95%) rename packages/backend-defaults/src/{entrypoints/rootHttpRouter/http => lib}/RateLimitStoreFactory.ts (100%) create mode 100644 packages/backend-defaults/src/lib/rateLimitMiddleware.ts diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index f67680d1e2..167a8935f9 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -804,6 +804,11 @@ export interface Config { | { type: 'memory'; }; + /** + * Enable/disable global rate limiting. If this is disabled, plugin specific rate limiting must be + * used. + */ + global?: boolean; /** * Time frame in milliseconds or as human duration for which requests are checked/remembered. * Defaults to one minute. @@ -834,6 +839,41 @@ export interface Config { * Defaults to false. */ skipFailedRequests?: boolean; + /** Plugin specific rate limiting configuration */ + plugin?: { + [pluginId: string]: { + /** + * Time frame in milliseconds or as human duration for which requests are checked/remembered. + * Defaults to one minute. + */ + window?: string | HumanDuration; + /** + * The maximum number of connections to allow during the `window` before rate limiting the client. + * Defaults to 5. + */ + incomingRequestLimit?: number; + /** + * Whether to pass requests in case of store failure. + * Defaults to false. + */ + passOnStoreError?: boolean; + /** + * List of allowed IP addresses that are not rate limited. + * Defaults to [127.0.0.1, 0:0:0:0:0:0:0:1, ::1]. + */ + ipAllowList?: string[]; + /** + * Skip rate limiting for requests that have been successful. + * Defaults to false. + */ + skipSuccessfulRequests?: boolean; + /** + * Skip rate limiting for requests that have failed. + * Defaults to false. + */ + skipFailedRequests?: boolean; + }; + }; }; /** diff --git a/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts new file mode 100644 index 0000000000..0e9239650b --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/httpRouter/http/createRateLimitMiddleware.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NextFunction, Request, Response } from 'express'; +import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts'; +import { Config } from '@backstage/config'; +import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts'; + +export const createRateLimitMiddleware = (options: { + pluginId: string; + config: Config; +}) => { + const { pluginId, config } = options; + const configKey = `backend.rateLimit.${pluginId}`; + const enabled = config.has(configKey); + if (!enabled) { + return (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; + } + + const rateLimitOptions = config.getConfig(configKey); + + return rateLimitMiddleware({ + store: RateLimitStoreFactory.create(config), + config: rateLimitOptions, + }); +}; diff --git a/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts index 1f2b989018..0fc70a07f0 100644 --- a/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/httpRouter/httpRouterServiceFactory.ts @@ -22,12 +22,13 @@ import { HttpRouterServiceAuthPolicy, } from '@backstage/backend-plugin-api'; import { - createLifecycleMiddleware, + createAuthIntegrationRouter, createCookieAuthRefreshMiddleware, createCredentialsBarrier, - createAuthIntegrationRouter, + createLifecycleMiddleware, } from './http'; import { MiddlewareFactory } from '../rootHttpRouter'; +import { createRateLimitMiddleware } from './http/createRateLimitMiddleware.ts'; /** * HTTP route registration for plugins. @@ -61,6 +62,8 @@ export const httpRouterServiceFactory = createServiceFactory({ }) { const router = PromiseRouter(); + router.use(createRateLimitMiddleware({ pluginId: plugin.getId(), config })); + rootHttpRouter.use(`/api/${plugin.getId()}`, router); const credentialsBarrier = createCredentialsBarrier({ diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts index 3dddc99974..9a76ec5a59 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts @@ -43,10 +43,8 @@ import { ServiceUnavailableError, } from '@backstage/errors'; import { applyInternalErrorFilter } from './applyInternalErrorFilter'; -import { rateLimit } from 'express-rate-limit'; -import { readDurationFromConfig } from '@backstage/config'; -import { durationToMilliseconds } from '@backstage/types'; -import { RateLimitStoreFactory } from './RateLimitStoreFactory'; +import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts'; +import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts'; type LogMeta = { date: string; @@ -254,59 +252,21 @@ export class MiddlewareFactory { ? undefined : this.#config.getOptionalConfig('backend.rateLimit'); - let windowMs: number = 60000; - if (rateLimitOptions && rateLimitOptions.has('window')) { - const windowDuration = readDurationFromConfig(rateLimitOptions, { - key: 'window', - }); - windowMs = durationToMilliseconds(windowDuration); + // Global rate limiting disabled + if ( + rateLimitOptions && + rateLimitOptions.getOptionalBoolean('global') === false + ) { + return (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; } - const ipAllowList = rateLimitOptions?.getOptionalStringArray( - 'ipAllowList', - ) ?? ['127.0.0.1', '0:0:0:0:0:0:0:1', '::1']; - - return rateLimit({ - windowMs, - limit: rateLimitOptions?.getOptionalNumber('incomingRequestLimit'), - skipSuccessfulRequests: rateLimitOptions?.getOptionalBoolean( - 'skipSuccessfulRequests', - ), - message: { - error: { - name: 'Error', - message: `Too many requests, please try again later`, - }, - response: { - statusCode: 429, - }, - }, - statusCode: 429, - skipFailedRequests: - rateLimitOptions?.getOptionalBoolean('skipFailedRequests'), - passOnStoreError: - rateLimitOptions?.getOptionalBoolean('passOnStoreError'), - keyGenerator(req, _res): string { - if (!req.ip) { - return req.socket.remoteAddress!; - } - return req.ip; - }, - skip: (req, _res) => { - return ( - Boolean(req.ip && ipAllowList.includes(req.ip)) || - Boolean( - req.socket.remoteAddress && - ipAllowList.includes(req.socket.remoteAddress), - ) - ); - }, - validate: { - trustProxy: false, - }, + return rateLimitMiddleware({ store: useDefaults ? undefined : RateLimitStoreFactory.create(this.#config), + config: rateLimitOptions, }); } diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts similarity index 95% rename from packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts rename to packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts index f1044979e8..02a7f30417 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/lib/RateLimitStoreFactory.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { mockServices } from '@backstage/backend-test-utils'; -import { RateLimitStoreFactory } from './RateLimitStoreFactory'; +import { RateLimitStoreFactory } from './RateLimitStoreFactory.ts'; import { RedisStore } from 'rate-limit-redis'; jest.mock('@keyv/redis', () => { @@ -55,7 +55,7 @@ describe('CacheRateLimitStoreFactory', () => { backend: { rateLimit: { store: { - client: 'redis', + type: 'redis', connection: 'redis://localhost:6379', }, }, diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/lib/RateLimitStoreFactory.ts similarity index 100% rename from packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts rename to packages/backend-defaults/src/lib/RateLimitStoreFactory.ts diff --git a/packages/backend-defaults/src/lib/rateLimitMiddleware.ts b/packages/backend-defaults/src/lib/rateLimitMiddleware.ts new file mode 100644 index 0000000000..57163b5908 --- /dev/null +++ b/packages/backend-defaults/src/lib/rateLimitMiddleware.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { RequestHandler } from 'express'; +import { rateLimit, Store } from 'express-rate-limit'; +import { Config, readDurationFromConfig } from '@backstage/config'; +import { durationToMilliseconds } from '@backstage/types'; + +export const rateLimitMiddleware = (options: { + store?: Store; + config?: Config; +}): RequestHandler => { + const { store, config } = options; + let windowMs: number = 60000; + if (config && config.has('window')) { + const windowDuration = readDurationFromConfig(config, { + key: 'window', + }); + windowMs = durationToMilliseconds(windowDuration); + } + const limit = config?.getOptionalNumber('incomingRequestLimit'); + const ipAllowList = config?.getOptionalStringArray('ipAllowList') ?? [ + '127.0.0.1', + '0:0:0:0:0:0:0:1', + '::1', + ]; + const skipSuccessfulRequests = config?.getOptionalBoolean( + 'skipSuccessfulRequests', + ); + const skipFailedRequests = config?.getOptionalBoolean('skipFailedRequests'); + const passOnStoreError = config?.getOptionalBoolean('passOnStoreError'); + + return rateLimit({ + windowMs, + limit, + skipSuccessfulRequests, + message: { + error: { + name: 'Error', + message: `Too many requests, please try again later`, + }, + response: { + statusCode: 429, + }, + }, + statusCode: 429, + skipFailedRequests, + passOnStoreError: passOnStoreError, + keyGenerator(req, _res): string { + if (!req.ip) { + return req.socket.remoteAddress!; + } + return req.ip; + }, + skip: (req, _res) => { + return ( + Boolean(req.ip && ipAllowList.includes(req.ip)) || + Boolean( + req.socket.remoteAddress && + ipAllowList.includes(req.socket.remoteAddress), + ) + ); + }, + validate: { + trustProxy: false, + }, + store, + }); +};