From 5e6a1fa49d5f3d30a62a78eeef672d44876dcd80 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 20 Oct 2026 19:37:49 -0400 Subject: [PATCH] fix(rate-limit): key IPv6 clients on their /56 in the flow limiters The magic link, OTP and OAuth flow key generators fell back to the full client address, so a client holding an IPv6 range got a fresh bucket per address. --- .changeset/rate-limit-ipv6-keys.md | 5 +++++ src/middleware/rateLimit.ts | 20 +++++++++++++------- tests/unit/middleware/rateLimit.spec.ts | 15 ++++++++++++++- 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 .changeset/rate-limit-ipv6-keys.md diff --git a/.changeset/rate-limit-ipv6-keys.md b/.changeset/rate-limit-ipv6-keys.md new file mode 100644 index 00000000..a1053278 --- /dev/null +++ b/.changeset/rate-limit-ipv6-keys.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': patch +--- + +The magic link, OTP and OAuth flow rate limiters key an IPv6 client on its /56 when they fall back to the client address, as the other limiters already do. Keyed on the full address, a client holding a whole IPv6 range could take a fresh bucket for each request. Requests keyed on an email or phone number are unchanged. diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 1e3bca82..c82f3e95 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -6,7 +6,7 @@ import { DefaultFlowRateLimits } from '@seamless-auth/types'; import { NextFunction, Request, Response } from 'express'; -import rateLimit, { RateLimitRequestHandler } from 'express-rate-limit'; +import rateLimit, { ipKeyGenerator, RateLimitRequestHandler } from 'express-rate-limit'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { AuthenticatedRequest } from '../types/types.js'; @@ -19,6 +19,15 @@ async function getConfiguredRateLimit() { return rate_limit ?? 50; } +// An IPv6 client is keyed on its /56, the library's default, rather than its address. +// A single host typically holds a whole /64, and keyed per address it could take a fresh +// bucket for every request. +function clientAddressKey(req: Request) { + const address = req.ip ?? req.socket.remoteAddress; + + return address ? ipKeyGenerator(address) : 'unknown'; +} + function getMagicLinkIdentityKey(req: Request) { const authReq = req as AuthenticatedRequest; const body = req.body as { email?: unknown } | undefined; @@ -32,7 +41,7 @@ function getMagicLinkIdentityKey(req: Request) { return `email:${email.toLowerCase()}`; } - return `ip:${req.ip ?? req.socket.remoteAddress ?? 'unknown'}`; + return `ip:${clientAddressKey(req)}`; } function getOtpIdentityKey(req: Request) { @@ -49,14 +58,11 @@ function getOtpIdentityKey(req: Request) { return `phone:${phone}`; } - return `ip:${req.ip ?? req.socket.remoteAddress ?? 'unknown'}`; + return `ip:${clientAddressKey(req)}`; } function getOAuthFlowKey(req: Request) { - return [ - req.params?.providerId ?? 'unknown-provider', - req.ip ?? req.socket.remoteAddress ?? 'unknown', - ].join(':'); + return [req.params?.providerId ?? 'unknown-provider', clientAddressKey(req)].join(':'); } const dynamicLimiter = rateLimit({ diff --git a/tests/unit/middleware/rateLimit.spec.ts b/tests/unit/middleware/rateLimit.spec.ts index 2d21028e..ce843aeb 100644 --- a/tests/unit/middleware/rateLimit.spec.ts +++ b/tests/unit/middleware/rateLimit.spec.ts @@ -5,8 +5,10 @@ vi.mock('../../../src/config/getSystemConfig', () => ({ getSystemConfig: vi.fn(), })); -vi.mock('express-rate-limit', () => { +vi.mock('express-rate-limit', async (importOriginal) => { + const actual = await importOriginal(); return { + ipKeyGenerator: actual.ipKeyGenerator, default: vi.fn((options = {}) => vi.fn(async (req, res, next) => { if (typeof (options as any).limit === 'function') { @@ -357,6 +359,17 @@ describe('rate limiter key generators', () => { expect(otp({ socket: {} })).toBe('ip:unknown'); }); + it('keys an IPv6 client on its /56 so rotating addresses shares one bucket', async () => { + const { magicLink, otp, oauth } = await keyGenerators(); + const first = { params: { providerId: 'google' }, ip: '2001:db8:1:2:3:4:5:6' }; + const rotated = { params: { providerId: 'google' }, ip: '2001:db8:1:2:ffff::9' }; + + expect(magicLink(first)).toBe('ip:2001:db8:1::/56'); + expect(otp(rotated)).toBe('ip:2001:db8:1::/56'); + expect(oauth(first)).toBe(oauth(rotated)); + expect(oauth({ params: {}, ip: '::ffff:203.0.113.7' })).toBe('unknown-provider:203.0.113.7'); + }); + it('falls back to the unknown provider and unknown IP for OAuth flows', async () => { const { oauth } = await keyGenerators();