From 7e2abe7475ff9781413dbebe707281d9ec140e1c Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Tue, 19 May 2026 03:20:51 -0500 Subject: [PATCH] =?UTF-8?q?docs(rate-limit):=20correct=20IPv6=20subnet=20w?= =?UTF-8?q?idth=20in=20keyByAuthKeyOrIp=20doc=20=E2=80=94=20/56=20not=20/6?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docstring and inline comment claimed express-rate-limit's ipKeyGenerator collapses IPv6 source IPs to a /64 prefix, but the helper's default is actually /56 (per its `ipv6Subnet = 56` default parameter). The code calls `ipKeyGenerator(ip)` without overriding, so /56 is what we get — meaning a single IPv6 client could in theory rotate through 256 distinct /64 prefixes inside their /56 allocation and all still bucket to the same rate-limit budget. That's the correct behavior; the docs just lied about the width. Also fixed a smaller inaccuracy: the doc claimed the helper takes `(req, res)` but the real signature is `(ip, ipv6Subnet = 56)`. Added two unit tests pinning the actual /56 behavior — addresses inside one /56 collapse to one key, addresses in different /56s diverge. This makes the boundary observable so a future helper upgrade that quietly changes the default surfaces as a test failure instead of silent rate-limit drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/middleware/rate-limit-key.js | 16 +++++++++------- tests/unit/rate-limit-key.test.js | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/middleware/rate-limit-key.js b/app/middleware/rate-limit-key.js index ff1e65e..704d8f5 100644 --- a/app/middleware/rate-limit-key.js +++ b/app/middleware/rate-limit-key.js @@ -19,11 +19,12 @@ * * IPv6 handling: express-rate-limit v8+ refuses to start unless any * custom keyGenerator that touches `req.ip` routes the value through - * `ipKeyGenerator(req, res)`. The helper canonicalizes IPv4 and IPv6 - * addresses (matching them to a /64 prefix on IPv6 to prevent a - * single client from bypassing limits by rotating through their /64 - * allocation). Without this wrapper, IPv6 clients could each present - * a unique address and slip past the per-IP budget. + * `ipKeyGenerator(ip)`. The helper canonicalizes IPv4 addresses + * (returning them verbatim) and IPv6 addresses (collapsing them to + * their /56 network prefix — the helper's default `ipv6Subnet`). + * Without this wrapper, an IPv6 client could present a fresh address + * from anywhere inside their allocation on every request and slip + * past the per-IP budget. * * Exported separately from server.js so unit tests can exercise * the keying directly without spinning up an HTTP server. @@ -39,8 +40,9 @@ function keyByAuthKeyOrIp(req /*, res */) { } // express-rate-limit v8+ requires the helper. It takes the raw // IP string and returns the IPv4 address verbatim or the IPv6 - // /64 prefix. Fall back to 'unknown' when no source IP is - // available (e.g. unit-test fixtures or non-IP transports). + // /56 network prefix (the helper's default). Fall back to + // 'unknown' when no source IP is available (e.g. unit-test + // fixtures or non-IP transports). const ip = req.ip || (req.connection && req.connection.remoteAddress); if (!ip) return 'ip:unknown'; return 'ip:' + ipKeyGenerator(ip); diff --git a/tests/unit/rate-limit-key.test.js b/tests/unit/rate-limit-key.test.js index 00d27d6..6bab3be 100644 --- a/tests/unit/rate-limit-key.test.js +++ b/tests/unit/rate-limit-key.test.js @@ -59,4 +59,23 @@ describe('keyByAuthKeyOrIp', () => { const b = keyByAuthKeyOrIp(fakeReq({ ip: '1.1.1.1' })); expect(a).toBe(b); }); + + test('IPv6 addresses in the same /56 collapse to the same key', () => { + // An attacker rotating through addresses inside their ISP + // allocation would defeat per-IP rate limiting if the key + // were the raw IP. express-rate-limit's ipKeyGenerator + // canonicalizes IPv6 to its /56 network (helper default), + // so two different addresses inside one /56 must bucket to + // the same key. + const a = keyByAuthKeyOrIp(fakeReq({ ip: '2001:db8:1234:5600::1' })); + const b = keyByAuthKeyOrIp(fakeReq({ ip: '2001:db8:1234:56ff::dead' })); + expect(a).toBe(b); + expect(a.startsWith('ip:')).toBe(true); + }); + + test('IPv6 addresses in different /56 prefixes get different keys', () => { + const a = keyByAuthKeyOrIp(fakeReq({ ip: '2001:db8:1234:5600::1' })); + const b = keyByAuthKeyOrIp(fakeReq({ ip: '2001:db8:1234:5700::1' })); + expect(a).not.toBe(b); + }); });