Merge pull request #28708 from drodil/backend_rate_limit

Backend rate limit
This commit is contained in:
Fredrik Adelöw
2025-06-16 21:01:37 +02:00
committed by GitHub
15 changed files with 514 additions and 9 deletions
+27
View File
@@ -0,0 +1,27 @@
---
'@backstage/backend-defaults': patch
---
Added new rate limit middleware to allow rate limiting requests to the backend
If you are using the `configure` callback of the root HTTP router service and do NOT call `applyDefaults()` inside it, please see [the relevant changes](https://github.com/backstage/backstage/pull/28708/files#diff-86ad1b6a694dd250823aee39d410428dd837c9d9a04ca8c33bd1081fbe3f22af) that were made, to see if you want to apply them as well to your custom configuration.
Rate limiting can be turned on by adding the following configuration to `app-config.yaml`:
```yaml
backend:
rateLimit:
window: 6s
incomingRequestLimit: 100
```
Plugin specific rate limiting can be configured by adding the following configuration to `app-config.yaml`:
```yaml
backend:
rateLimit:
global: false # This will disable the global rate limiting
plugin:
catalog:
window: 6s
incomingRequestLimit: 100
```
+6
View File
@@ -36,6 +36,12 @@ backend:
# keys:
# - secret: ${BACKEND_SECRET}
# Used for testing rate limiting locally
# rateLimit:
# windowMs: 1m
# incomingRequestLimit: 1
# ipAllowList: []
auth:
# TODO: once plugins have been migrated we can remove this, but right now it
# is require for the backend-next to work in this repo
@@ -68,6 +68,60 @@ For those routes you will also have to specify `allowLimitedAccess: true` when
using the [`auth`](./auth.md) and [`httpAuth`](./http-auth.md) services to
access the incoming credentials.
## Rate limiting
Rate limiting allows you to limit the amount of requests users can send to you backend.
This is useful for blocking various network attacks, such as DDOS.
To enable rate limiting, add the following to your config:
```yaml
backend:
rateLimit: true
```
You can additionally configure the rate limiting parameters, also by plugin:
```yaml
backend:
rateLimit:
global: true # Enables or disables rate limit for all plugins
window: 6s # Time window for rate limiting for single client
incomingRequestLimit: 100 # Number of requests to accept from one client during time window
ipAllowList: ['127.0.0.1'] # IPs to bypass rate limiting
skipSuccesfulRequests: false # Rate limit successful requests
skipFailedRequests: false # Rate limit failed requests
plugin:
# Plugin specific rate limiting
catalog:
window: 3s
incomingRequestLimit: 50
```
By default, the rate limiting is per instance and the request counts are stored into memory.
If you want to share this information across all your backstage instances, you have to configure
the rate limiting store:
```yaml
backend:
rateLimit:
global: true
store:
type: redis
connection: redis://127.0.0.1:16379
```
If your instance is working behind a proxy, you have to configure the backend to trust the proxy
for the rate limiting being able to distinguish clients.
```yaml
backend:
trustProxy: true
```
For more information about the trust proxy configuration and available options,
please refer to [express documentation](https://expressjs.com/en/guide/behind-proxies.html).
## Configuring the service
For more advanced customization, there are several APIs from the `@backstage/backend-defaults/httpRouter` package that allow you to customize the implementation of the config service. The default implementation uses all of the middleware exported from `@backstage/backend-defaults/httpRouter`, including `createLifecycleMiddleware`, `createAuthIntegrationRouter`, `createCredentialsBarrier` and `createCookieAuthRefreshMiddleware`. You can use these to create your own `httpRouter` service implementation, for example - here's how you would add a custom health check route to all plugins:
@@ -78,6 +132,7 @@ import {
createCookieAuthRefreshMiddleware,
createCredentialsBarrier,
createAuthIntegrationRouter,
createRateLimitMiddleware,
} from '@backstage/backend-defaults/httpRouter';
import { createServiceFactory } from '@backstage/backend-plugin-api';
@@ -105,6 +160,11 @@ backend.add(
}) {
const router = PromiseRouter();
// Optional rate limiting middleware
router.use(
createRateLimitMiddleware({ pluginId: plugin.getId(), config }),
);
rootHttpRouter.use(`/api/${plugin.getId()}`, router);
const credentialsBarrier = createCredentialsBarrier({
@@ -40,6 +40,10 @@ createBackendPlugin({
});
```
## Rate limiting
Please refer to the [HTTP Router documentation](./http-router.md#rate-limiting).
## Configuring the service
### Via `app-config.yaml`
@@ -121,6 +125,11 @@ backend.add(
app.use(middleware.cors());
app.use(middleware.compression());
// Optional rate limiting middleware
app.use(middleware.rateLimit());
// If you are using rate limiting behind a proxy, you should set the `trust proxy` setting to true
app.set('trust proxy', true);
app.use(healthRouter);
// you can add you your own middleware in here
+86
View File
@@ -790,6 +790,92 @@ export interface Config {
headers?: { [name: string]: string };
};
/**
* Rate limiting options. Defining this as `true` will enable rate limiting with default values.
*/
rateLimit?:
| true
| {
store?:
| {
type: 'redis';
connection: string;
}
| {
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.
*/
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;
/** 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;
};
};
};
/**
* Configuration related to URL reading, used for example for reading catalog info
* files, scaffolder templates, and techdocs content.
+2
View File
@@ -168,6 +168,7 @@
"cron": "^3.0.0",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"express-rate-limit": "^7.5.0",
"fs-extra": "^11.2.0",
"git-url-parse": "^15.0.0",
"helmet": "^6.0.0",
@@ -187,6 +188,7 @@
"pg": "^8.11.3",
"pg-connection-string": "^2.3.0",
"pg-format": "^1.0.4",
"rate-limit-redis": "^4.2.0",
"raw-body": "^2.4.1",
"selfsigned": "^2.0.0",
"tar": "^6.1.12",
@@ -93,6 +93,7 @@ export class MiddlewareFactory {
helmet(): RequestHandler;
logging(): RequestHandler;
notFound(): RequestHandler;
rateLimit(): RequestHandler;
}
// @public
@@ -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.plugin.${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, prefix: pluginId }),
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({
@@ -15,15 +15,15 @@
*/
import {
RootConfigService,
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import {
Request,
Response,
ErrorRequestHandler,
NextFunction,
Request,
RequestHandler,
Response,
} from 'express';
import cors from 'cors';
import helmet from 'helmet';
@@ -37,12 +37,14 @@ import {
InputError,
NotAllowedError,
NotFoundError,
NotImplementedError,
NotModifiedError,
ServiceUnavailableError,
serializeError,
ServiceUnavailableError,
} from '@backstage/errors';
import { NotImplementedError } from '@backstage/errors';
import { applyInternalErrorFilter } from './applyInternalErrorFilter';
import { RateLimitStoreFactory } from '../../../lib/RateLimitStoreFactory.ts';
import { rateLimitMiddleware } from '../../../lib/rateLimitMiddleware.ts';
type LogMeta = {
date: string;
@@ -227,6 +229,47 @@ export class MiddlewareFactory {
return cors(readCorsOptions(this.#config.getOptionalConfig('backend')));
}
/**
* Returns a middleware that implements rate limiting.
*
* @remarks
*
* Rate limiting is a common technique to prevent abuse of APIs. This middleware is
* configured using the config key `backend.rateLimit`.
*
* @returns An Express request handler
*/
rateLimit(): RequestHandler {
const enabled = this.#config.has('backend.rateLimit');
if (!enabled) {
return (_req: Request, _res: Response, next: NextFunction) => {
next();
};
}
const useDefaults = this.#config.getOptional('backend.rateLimit') === true;
const rateLimitOptions = useDefaults
? undefined
: this.#config.getOptionalConfig('backend.rateLimit');
// Global rate limiting disabled
if (
rateLimitOptions &&
rateLimitOptions.getOptionalBoolean('global') === false
) {
return (_req: Request, _res: Response, next: NextFunction) => {
next();
};
}
return rateLimitMiddleware({
store: useDefaults
? undefined
: RateLimitStoreFactory.create({ config: this.#config }),
config: rateLimitOptions,
});
}
/**
* Express middleware to handle errors during request processing.
*
@@ -15,13 +15,13 @@
*/
import {
RootConfigService,
coreServices,
createServiceFactory,
LifecycleService,
LoggerService,
RootConfigService,
} from '@backstage/backend-plugin-api';
import express, { RequestHandler, Express } from 'express';
import express, { Express, RequestHandler } from 'express';
import type { Server } from 'node:http';
import {
createHttpServer,
@@ -121,6 +121,7 @@ const rootHttpRouterServiceFactoryWithOptions = (
app.use(middleware.cors());
app.use(middleware.compression());
app.use(middleware.logging());
app.use(middleware.rateLimit());
app.use(healthRouter);
app.use(routes);
app.use(middleware.notFound());
@@ -0,0 +1,68 @@
/*
* Copyright 2024 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 { mockServices } from '@backstage/backend-test-utils';
import { RateLimitStoreFactory } from './RateLimitStoreFactory.ts';
import { RedisStore } from 'rate-limit-redis';
jest.mock('@keyv/redis', () => {
const Actual = jest.requireActual('@keyv/redis');
return {
...Actual,
__esModule: true,
default: jest.fn(() => {
return {
getClient: jest.fn(() => ({
sendCommand: jest.fn().mockReturnValue('mock'),
})),
};
}),
};
});
describe('CacheRateLimitStoreFactory', () => {
afterEach(jest.clearAllMocks);
it('should return undefined store with auto configuration if redis is not available', () => {
const config = mockServices.rootConfig({
data: {
backend: {
rateLimit: {
store: undefined,
},
},
},
});
const store = RateLimitStoreFactory.create({ config });
expect(store).toBeUndefined();
});
it('should return redis store if configured explicitly', async () => {
const config = mockServices.rootConfig({
data: {
backend: {
rateLimit: {
store: {
type: 'redis',
connection: 'redis://localhost:6379',
},
},
},
},
});
const store = RateLimitStoreFactory.create({ config });
expect(store).toBeInstanceOf(RedisStore);
});
});
@@ -0,0 +1,58 @@
/*
* Copyright 2024 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 { Config } from '@backstage/config';
import type { Store } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
/**
* Creates a store for `express-rate-limit` based on the configuration.
*
* @internal
*/
export class RateLimitStoreFactory {
static create(options: {
config: Config;
prefix?: string;
}): Store | undefined {
const { config, prefix } = options;
const store = config.getOptionalConfig('backend.rateLimit.store');
if (!store) {
return undefined;
}
const type = store.getString('type');
switch (type) {
case 'redis':
return this.redis({ store, prefix });
case 'memory':
default:
return undefined;
}
}
private static redis(options: { store: Config; prefix?: string }): Store {
const { store, prefix } = options;
const connectionString = store.getString('connection');
const KeyvRedis = require('@keyv/redis').default;
const keyv = new KeyvRedis(connectionString);
return new RedisStore({
prefix,
sendCommand: async (...args: string[]) => {
const client = await keyv.getClient();
return client.sendCommand(args);
},
});
}
}
@@ -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,
});
};
+20
View File
@@ -3602,6 +3602,7 @@ __metadata:
cron: "npm:^3.0.0"
express: "npm:^4.17.1"
express-promise-router: "npm:^4.1.0"
express-rate-limit: "npm:^7.5.0"
fs-extra: "npm:^11.2.0"
git-url-parse: "npm:^15.0.0"
helmet: "npm:^6.0.0"
@@ -3624,6 +3625,7 @@ __metadata:
pg: "npm:^8.11.3"
pg-connection-string: "npm:^2.3.0"
pg-format: "npm:^1.0.4"
rate-limit-redis: "npm:^4.2.0"
raw-body: "npm:^2.4.1"
selfsigned: "npm:^2.0.0"
supertest: "npm:^7.0.0"
@@ -31437,6 +31439,15 @@ __metadata:
languageName: node
linkType: hard
"express-rate-limit@npm:^7.5.0":
version: 7.5.0
resolution: "express-rate-limit@npm:7.5.0"
peerDependencies:
express: ^4.11 || 5 || ^5.0.0-beta.1
checksum: 10/eff34c83bf586789933a332a339b66649e2cca95c8e977d193aa8bead577d3182ac9f0e9c26f39389287539b8038890ff023f910b54ebb506a26a2ce135b92ca
languageName: node
linkType: hard
"express-session@npm:^1.17.1, express-session@npm:^1.17.3":
version: 1.18.1
resolution: "express-session@npm:1.18.1"
@@ -43415,6 +43426,15 @@ __metadata:
languageName: node
linkType: hard
"rate-limit-redis@npm:^4.2.0":
version: 4.2.0
resolution: "rate-limit-redis@npm:4.2.0"
peerDependencies:
express-rate-limit: ">= 6"
checksum: 10/22adc67918ca906f613b45f9dcfd039f543d363921979d21ba56be5f3288c6e9973c9e4bb4ec59810fc6b3abb20defd572c102f607a8c3b4d273d5e09b63839f
languageName: node
linkType: hard
"rate-limiter-flexible@npm:^4.0.1":
version: 4.0.1
resolution: "rate-limiter-flexible@npm:4.0.1"