fix(backend-defaults): use ipKeyGenerator for rate limiter keys

The built-in rate limiter derived its key directly from `req.ip`, which
express-rate-limit 8.x rejects with an ERR_ERL_KEY_GEN_IPV6 validation
error. A raw IPv6 address used as a rate limiting key would let a client
bypass the limit by rotating through addresses in its allotted block, so
the library now requires its `ipKeyGenerator` helper to normalize the
address. Wrap the resolved client address in that helper so the limiter
starts cleanly and groups IPv6 clients by their address block.

Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
This commit is contained in:
Asish Kumar
2026-05-25 03:28:10 +05:30
parent 6fa1cacfbe
commit def82d4d5e
3 changed files with 106 additions and 5 deletions
@@ -0,0 +1,5 @@
---
'@backstage/backend-defaults': patch
---
Fixed the built-in rate limiter throwing a validation error and refusing to start when `backend.rateLimit` is enabled. Requests are now keyed using the address normalization helper from `express-rate-limit`, which is required by newer versions of that library and ensures IPv6 clients are grouped by their address block rather than by individual address.
@@ -0,0 +1,93 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import { rateLimitMiddleware } from './rateLimitMiddleware';
function createApp(config: ConfigReader) {
const app = express();
// Mimic a backend running behind a proxy so that the X-Forwarded-For header
// is used to populate `req.ip`, allowing us to exercise IPv6 client handling.
app.set('trust proxy', true);
app.use(rateLimitMiddleware({ config }));
app.get('/', (_req, res) => {
res.status(200).send('ok');
});
return app;
}
describe('rateLimitMiddleware', () => {
it('limits requests per client and handles IPv6 addresses without key generator validation errors', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const app = createApp(
new ConfigReader({ incomingRequestLimit: 2, ipAllowList: [] }),
);
const agent = request(app);
const ipv6 = '2001:db8::1';
const first = await agent.get('/').set('X-Forwarded-For', ipv6);
const second = await agent.get('/').set('X-Forwarded-For', ipv6);
const third = await agent.get('/').set('X-Forwarded-For', ipv6);
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(third.status).toBe(429);
expect(third.body).toEqual({
error: {
name: 'Error',
message: 'Too many requests, please try again later',
},
response: { statusCode: 429 },
});
// express-rate-limit 8.x rejects key generators that reference `req.ip`
// without using its `ipKeyGenerator` helper, reporting an ERR_ERL_KEY_GEN_IPV6
// validation error. Make sure our key generator does not trip that check.
const logs = [...errorSpy.mock.calls, ...warnSpy.mock.calls]
.flat()
.map(arg =>
arg instanceof Error
? `${arg.message} ${(arg as { code?: string }).code ?? ''}`
: String(arg),
)
.join('\n');
expect(logs).not.toMatch(/IPV6|ipKeyGenerator/i);
errorSpy.mockRestore();
warnSpy.mockRestore();
});
it('does not rate limit addresses on the allow list', async () => {
const app = createApp(
new ConfigReader({
incomingRequestLimit: 1,
ipAllowList: ['2001:db8::5'],
}),
);
const agent = request(app);
for (let i = 0; i < 3; i++) {
const response = await agent
.get('/')
.set('X-Forwarded-For', '2001:db8::5');
expect(response.status).toBe(200);
}
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { RequestHandler } from 'express';
import { rateLimit, Store } from 'express-rate-limit';
import { ipKeyGenerator, rateLimit, Store } from 'express-rate-limit';
import { Config, readDurationFromConfig } from '@backstage/config';
import { durationToMilliseconds } from '@backstage/types';
@@ -59,10 +59,13 @@ export const rateLimitMiddleware = (options: {
skipFailedRequests,
passOnStoreError: passOnStoreError,
keyGenerator(req, _res): string {
if (!req.ip) {
return req.socket.remoteAddress!;
}
return req.ip;
// `ipKeyGenerator` normalizes the address before it is used as a rate
// limiting key. This is required by express-rate-limit 8.x, which rejects
// key generators that reference `req.ip` directly, as a naive IPv6 key
// would let clients trivially bypass the limit by rotating addresses
// within their allotted block.
const ip = req.ip ?? req.socket.remoteAddress;
return ip ? ipKeyGenerator(ip) : '';
},
skip: (req, _res) => {
return (