feat: allow plugin specific rate limiting
Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
+40
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+40
@@ -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({
|
||||
|
||||
+12
-52
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user