feat: allow plugin specific rate limiting

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-02-27 15:02:43 +02:00
parent 6633c138b5
commit a68bbc54c0
7 changed files with 180 additions and 56 deletions
+40
View File
@@ -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;
};
};
};
/**
@@ -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,
});
};
@@ -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({
@@ -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,
});
}
@@ -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',
},
},
@@ -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,
});
};