Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rate-limit-ipv6-keys.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 13 additions & 7 deletions src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -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({
Expand Down
15 changes: 14 additions & 1 deletion tests/unit/middleware/rateLimit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('express-rate-limit')>();
return {
ipKeyGenerator: actual.ipKeyGenerator,
default: vi.fn((options = {}) =>
vi.fn(async (req, res, next) => {
if (typeof (options as any).limit === 'function') {
Expand Down Expand Up @@ -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();

Expand Down
Loading