diff --git a/.changeset/rate-limit-ipv6-key-generator.md b/.changeset/rate-limit-ipv6-key-generator.md new file mode 100644 index 0000000000..21fd7c3b34 --- /dev/null +++ b/.changeset/rate-limit-ipv6-key-generator.md @@ -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. diff --git a/packages/backend-defaults/src/lib/rateLimitMiddleware.test.ts b/packages/backend-defaults/src/lib/rateLimitMiddleware.test.ts new file mode 100644 index 0000000000..38a989b805 --- /dev/null +++ b/packages/backend-defaults/src/lib/rateLimitMiddleware.test.ts @@ -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); + } + }); +}); diff --git a/packages/backend-defaults/src/lib/rateLimitMiddleware.ts b/packages/backend-defaults/src/lib/rateLimitMiddleware.ts index 57163b5908..74179ff232 100644 --- a/packages/backend-defaults/src/lib/rateLimitMiddleware.ts +++ b/packages/backend-defaults/src/lib/rateLimitMiddleware.ts @@ -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 (