diff --git a/apps/portal-bff/src/security/rate-limit.middleware.spec.ts b/apps/portal-bff/src/security/rate-limit.middleware.spec.ts index 14f9641..58eaf47 100644 --- a/apps/portal-bff/src/security/rate-limit.middleware.spec.ts +++ b/apps/portal-bff/src/security/rate-limit.middleware.spec.ts @@ -153,6 +153,32 @@ describe('createRateLimitMiddleware', () => { await mw(makeReq({ ip: '10.0.0.11', path: '/api/x' }), res, next); 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 { diff --git a/apps/portal-bff/src/security/rate-limit.middleware.ts b/apps/portal-bff/src/security/rate-limit.middleware.ts index 5d579fd..ddc5706 100644 --- a/apps/portal-bff/src/security/rate-limit.middleware.ts +++ b/apps/portal-bff/src/security/rate-limit.middleware.ts @@ -1,5 +1,5 @@ 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'; /** @@ -79,7 +79,14 @@ export function createRateLimitMiddleware(config: RateLimitConfig): RequestHandl if (hasSession && 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) => { res.status(429).json(errorResponse('rate_limited', 'Too many requests'));