diff --git a/.changeset/famous-terms-rescue.md b/.changeset/famous-terms-rescue.md index 073f3a02ba..78630eb532 100644 --- a/.changeset/famous-terms-rescue.md +++ b/.changeset/famous-terms-rescue.md @@ -9,7 +9,6 @@ Rate limiting can be turned on by adding the following configuration to `app-con ```yaml backend: rateLimit: - enabled: true - windowMs: 60000 - limit: 100 + window: 60000 + incomingRequestLimit: 100 ``` diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 7866b17c29..49a51ec2f5 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { HumanDuration, JsonValue } from '@backstage/types'; +import { HumanDuration } from '@backstage/types'; export interface Config { app: { @@ -793,68 +793,48 @@ export interface Config { /** * Rate limiting options */ - rateLimit?: { - /** - * Store to use for rate limiting. If not defined, the store will be automatically - * decided based on `backend.cache.store` value. If - * redis is not available, the store will be memory. - */ - store?: 'memory' | 'redis'; - /** - * Rate limiting enabled. Defaults to false. - */ - enabled?: boolean; - /** - * Time frame in milliseconds or as human duration for which requests are checked/remembered. - * Defaults to 6000ms. - */ - window?: number | HumanDuration; - /** - * The maximum number of connections to allow during the `window` before rate limiting the client. - * Defaults to 5. - */ - limit?: number; - /** - * The response body to send back when a client is rate limited. - * Defaults to 'Too many requests, please try again later.'. - */ - message?: JsonValue; - /** - * The HTTP status code to send back when a client is rate limited. - * Defaults to 429. - */ - statusCode?: number; - /** - * Whether to send the legacy rate limit headers for the limit. - * Defaults to true. - */ - legacyHeaders?: boolean; - /** - * Whether to enable support for headers conforming the RateLimit header fields for HTTP - * standardization. Defaults to undefined. - */ - standardHeaders?: 'draft-6' | 'draft-7'; - /** - * 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]. - */ - 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; - }; + rateLimit?: + | false + | { + store?: + | { + client: 'redis'; + connection: string; + } + | { + client: 'memory'; + }; + /** + * Time frame in milliseconds or as human duration for which requests are checked/remembered. + * Defaults to 6000ms. + */ + window?: number | 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; + }; /** * Configuration related to URL reading, used for example for reading catalog info diff --git a/packages/backend-defaults/report-rootHttpRouter.api.md b/packages/backend-defaults/report-rootHttpRouter.api.md index 91e760b7ea..84424be39d 100644 --- a/packages/backend-defaults/report-rootHttpRouter.api.md +++ b/packages/backend-defaults/report-rootHttpRouter.api.md @@ -20,7 +20,6 @@ import { RootHttpRouterService } from '@backstage/backend-plugin-api'; import { Router } from 'express'; import type { Server } from 'node:http'; import { ServiceFactory } from '@backstage/backend-plugin-api'; -import type { Store } from 'express-rate-limit'; // @public (undocumented) export function createHealthRouter(options: { @@ -111,15 +110,6 @@ export interface MiddlewareFactoryOptions { logger: LoggerService; } -// @public -export class RateLimitStoreFactory { - constructor(config: Config); - // (undocumented) - create(): Store | undefined; - // (undocumented) - redis(): Store; -} - // @public export function readCorsOptions(config?: Config): CorsOptions; diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts index 561a1999c9..853e5aa8f6 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/MiddlewareFactory.ts @@ -43,12 +43,9 @@ import { ServiceUnavailableError, } from '@backstage/errors'; import { applyInternalErrorFilter } from './applyInternalErrorFilter'; -import { DraftHeadersVersion, rateLimit } from 'express-rate-limit'; -import { - durationToMilliseconds, - HumanDuration, - JsonValue, -} from '@backstage/types'; +import { rateLimit } from 'express-rate-limit'; +import { Config } from '@backstage/config'; +import { durationToMilliseconds, HumanDuration } from '@backstage/types'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; type LogMeta = { @@ -245,15 +242,15 @@ export class MiddlewareFactory { * @returns An Express request handler */ rateLimit(): RequestHandler { - const rateLimitOptions = - this.#config.getOptionalConfig('backend.rateLimit'); - const enabled = rateLimitOptions?.getOptionalBoolean('enabled') ?? false; - if (!rateLimitOptions || !enabled) { + const conf = this.#config.getOptional( + 'backend.rateLimit', + ); + if (!conf || typeof conf !== 'object') { return (_req: Request, _res: Response, next: NextFunction) => { next(); }; } - + const rateLimitOptions = conf as Config; const window = rateLimitOptions.getOptional( 'window', ); @@ -272,32 +269,34 @@ export class MiddlewareFactory { const ipAllowList = rateLimitOptions.getOptionalStringArray( 'ipAllowList', - ) ?? ['127.0.0.1']; + ) ?? ['127.0.0.1', '0:0:0:0:0:0:0:1', '::1']; return rateLimit({ windowMs, - limit: rateLimitOptions.getOptionalNumber('limit'), - message: rateLimitOptions.getOptional('message'), - statusCode: rateLimitOptions.getOptionalNumber('statusCode'), + limit: rateLimitOptions.getOptionalNumber('incomingRequestLimit'), skipSuccessfulRequests: rateLimitOptions.getOptionalBoolean( 'skipSuccessfulRequests', ), skipFailedRequests: rateLimitOptions.getOptionalBoolean('skipFailedRequests'), - legacyHeaders: rateLimitOptions.getOptionalBoolean('legacyHeaders'), - standardHeaders: rateLimitOptions.getOptionalString( - 'standardHeaders', - ) as DraftHeadersVersion, passOnStoreError: rateLimitOptions.getOptionalBoolean('passOnStoreError'), keyGenerator(req, _res): string { if (!req.ip) { return req.socket.remoteAddress!; } - - return req.ip.replace(/:\d+[^:]*$/, ''); + return req.ip; }, skip: (req, _res) => { - return Boolean(req.ip && ipAllowList.includes(req.ip)); + return ( + Boolean(req.ip && ipAllowList.includes(req.ip)) || + Boolean( + req.socket.remoteAddress && + ipAllowList.includes(req.socket.remoteAddress), + ) + ); + }, + validate: { + trustProxy: false, }, store: new RateLimitStoreFactory(this.#config).create(), }); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts index 105cf0cac6..4fdc513c81 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.test.ts @@ -13,49 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { mockServices } from '@backstage/backend-test-utils'; +import { mockServices, TestCaches } from '@backstage/backend-test-utils'; import { RateLimitStoreFactory } from './RateLimitStoreFactory'; import { RedisStore } from 'rate-limit-redis'; -import KeyvRedis from '@keyv/redis'; - -jest.mock('@keyv/redis'); -(KeyvRedis as jest.Mocked).mockImplementation(() => { - return { - redis: { - call: jest.fn().mockResolvedValue('OK'), - }, - }; -}); describe('CacheRateLimitStoreFactory', () => { - it('should return redis store with auto configuration', () => { - const config = mockServices.rootConfig({ - data: { - backend: { - cache: { - store: 'redis', - }, - rateLimit: { - store: undefined, - }, - }, - }, - }); - const factory = new RateLimitStoreFactory(config); - const store = factory.create(); - expect(store).toBeInstanceOf(RedisStore); - }); + const caches = TestCaches.create(); + + afterEach(jest.clearAllMocks); it('should return undefined store with auto configuration if redis is not available', () => { const config = mockServices.rootConfig({ data: { backend: { - cache: { - store: 'memory', - }, - database: { - client: 'sqlite3', - }, rateLimit: { store: undefined, }, @@ -67,12 +37,20 @@ describe('CacheRateLimitStoreFactory', () => { expect(store).toBeUndefined(); }); - it('should return redis store if configured explicitly', () => { + it('should return redis store if configured explicitly', async () => { + if (!caches.supports('REDIS_7')) { + return; + } + const conf = await caches.init('REDIS_7'); + const config = mockServices.rootConfig({ data: { backend: { rateLimit: { - store: 'redis', + store: { + client: 'redis', + connection: conf.connection, + }, }, }, }, diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts index 00e5440ebe..85eb3c4d86 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/RateLimitStoreFactory.ts @@ -21,46 +21,28 @@ import { RedisStore } from 'rate-limit-redis'; /** * Creates a store for `express-rate-limit` based on the configuration. * - * @public + * @internal */ export class RateLimitStoreFactory { constructor(private readonly config: Config) {} create(): Store | undefined { - const storeType = this.config.getOptionalString('backend.rateLimit.store'); - if (!storeType) { - return this.auto(); + const store = this.config.getOptionalConfig('backend.rateLimit.store'); + if (!store) { + return undefined; } - switch (storeType) { + const client = store.getString('client'); + switch (client) { case 'redis': - return this.redis(); + return this.redis(store); default: - throw new Error( - `Invalid 'backend.rateLimit.store' provided: ${storeType}`, - ); + return undefined; } } - private auto(): Store | undefined { - const cacheStore = - this.config.getOptionalString('backend.cache.store') || 'memory'; - // Use redis as primary if available - if (cacheStore === 'redis') { - return this.redis(); - } - - // Fallback to undefined (memory) - return undefined; - } - - redis(): Store { - const connectionString = - this.config.getOptionalString('backend.cache.connection') || ''; - const useRedisSets = - this.config.getOptionalBoolean('backend.cache.useRedisSets') ?? true; - const keyv = new KeyvRedis(connectionString, { - useRedisSets, - }); + redis(storeConfig: Config): Store { + const connectionString = storeConfig.getString('connection'); + const keyv = new KeyvRedis(connectionString); return new RedisStore({ // Keyv uses ioredis under the hood sendCommand: (...args: string[]) => keyv.redis.call(...args), diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts index 5c63d887bb..4a9ec14cf8 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/http/index.ts @@ -28,4 +28,3 @@ export type { HttpServerCertificateOptions, HttpServerOptions, } from './types'; -export { RateLimitStoreFactory } from './RateLimitStoreFactory';