fix(security): normalize IPv6 in rate-limit keyGenerator (ADR-0021) #260

Merged
julien merged 1 commits from fix/bff-rate-limit-ipv6-key into main 2026-06-01 12:31:54 +02:00
2 changed files with 35 additions and 2 deletions
@@ -153,6 +153,32 @@ describe('createRateLimitMiddleware', () => {
await mw(makeReq({ ip: '10.0.0.11', path: '/api/x' }), res, next); await mw(makeReq({ ip: '10.0.0.11', path: '/api/x' }), res, next);
expect(res.status).not.toHaveBeenCalledWith(429); expect(res.status).not.toHaveBeenCalledWith(429);
}); });
it('keys IPv6 addresses by their /56 prefix so per-host rotation cannot bypass the bucket', async () => {
const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 99 });
const next = jest.fn() as unknown as NextFunction;
// Two addresses inside `2001:db8:abcd:0000::/56` must share a
// bucket — otherwise an attacker swaps the host suffix on every
// retry and the per-IP limit never bites. This is the bypass the
// lib's `ERR_ERL_KEY_GEN_IPV6` boot-time validation refuses to
// ship. (The lib v8 default mask is `/56`, a typical residential
// ISP customer allocation.)
let res = makeRes();
await mw(makeReq({ ip: '2001:db8:abcd::1', path: '/api/x' }), res, next);
expect(res.status).not.toHaveBeenCalledWith(429);
res = makeRes();
await mw(makeReq({ ip: '2001:db8:abcd::ffff', path: '/api/x' }), res, next);
expect(res.status).toHaveBeenCalledWith(429);
// Different `/56` (`2001:db8:abce::/56`) — independent bucket.
// Confirms the truncation does not collapse all IPv6 traffic into
// one global bucket.
res = makeRes();
await mw(makeReq({ ip: '2001:db8:abce::1', path: '/api/x' }), res, next);
expect(res.status).not.toHaveBeenCalledWith(429);
});
}); });
function restore(name: string, value: string | undefined): void { function restore(name: string, value: string | undefined): void {
@@ -1,5 +1,5 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express'; import type { NextFunction, Request, RequestHandler, Response } from 'express';
import rateLimit, { type Options } from 'express-rate-limit'; import rateLimit, { ipKeyGenerator, type Options } from 'express-rate-limit';
import { errorResponse } from './structured-error.filter'; import { errorResponse } from './structured-error.filter';
/** /**
@@ -79,7 +79,14 @@ export function createRateLimitMiddleware(config: RateLimitConfig): RequestHandl
if (hasSession && sessionId) { if (hasSession && sessionId) {
return `s:${sessionId}`; return `s:${sessionId}`;
} }
return `ip:${req.ip ?? 'unknown'}`; // `ipKeyGenerator` normalises the address before keying — most
// importantly, it truncates IPv6 to its `/56` prefix (the lib v8
// default — a typical residential ISP customer allocation) so an
// attacker can't rotate through the trailing bits of their own
// subnet to escape the per-IP bucket. The lib raises
// `ERR_ERL_KEY_GEN_IPV6` at boot if a custom keyGenerator returns
// `req.ip` verbatim, exactly to prevent that bypass.
return `ip:${ipKeyGenerator(req.ip ?? 'unknown')}`;
}, },
handler: (_req: Request, res: Response, _next: NextFunction, _optionsUsed: Options) => { handler: (_req: Request, res: Response, _next: NextFunction, _optionsUsed: Options) => {
res.status(429).json(errorResponse('rate_limited', 'Too many requests')); res.status(429).json(errorResponse('rate_limited', 'Too many requests'));