fix(security): normalize IPv6 in rate-limit keyGenerator (ADR-0021) #260
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user