Better Auth: Rate limiter keys IPv6 addresses individually and is bypassable via prefix rotation
Description
Am
I affected?
Users are affected if all of the following are true:
- Their app uses
better-authat a version< 1.4.17, or at a v1.5 prerelease tagged<= 1.5.0-beta.8. - The apps authentication endpoints serve clients reachable over IPv6. Most managed hosts including Cloudflare, Vercel, Fly.io, AWS Application Load Balancer, and Google Cloud Load Balancing advertise IPv6 by default.
- The app's rate-limit configuration is enabled (the production default) and relies on the leftmost
x-forwarded-forvalue (the stock setup) or any other configured IP-bearing header.
If users are on 1.4.16 specifically, the normalizeIP helper exists in your version but the IPv6 prefix length defaults to /128. Stock config still permits prefix rotation because no prefix mask is applied. Either upgrade to 1.4.17 or set advanced.ipAddress.ipv6Subnet: 64 in the config.
If applications do not use the rate limiter, or if the deployment serves only IPv4 clients, the prefix-rotation vector does not apply. The representation-aliasing vector still applies to IPv6 addresses delivered over IPv4 transport in some edge cases (an upstream proxy carrying an IPv4-mapped IPv6 source), but it is rare in practice.
Fix:
- Upgrade to
better-auth@1.4.17or later. The current stable line1.6.xand the pre-release line1.7.0-betaboth carry the fix. - If applications cannot upgrade, see workarounds below.
Summary
Better Auth's HTTP rate limiter keyed each request by the exact textual IP address it received in x-forwarded-for (or the configured IP-bearing header). IPv6 clients controlling a typical /64 allocation could rotate through 2^64 distinct source addresses without exhausting the per-address counter, defeating rate limiting on /sign-in/email, /sign-up/email, /forget-password, and every other path the limiter protects. The same bug allowed a single client to vary the textual encoding of one IPv6 address (uppercase, compression, IPv4-mapped, hex-encoded IPv4-in-IPv6) and produce multiple distinct keys.
Details
The pre-fix getIp function returned the leftmost x-forwarded-for value verbatim after a single validity check, and onRequestRateLimit constructed the rate-limit key by string concatenation of that value with the request path. Two facts of IPv6 made the key space larger than the population of clients:
- ISPs and cloud providers assign prefixes, not addresses. RFC 6177 recommends
/56for residential users; cloud providers commonly assign/29to/48. An attacker controlling a single/64therefore controls 2^64 source addresses without doing anything unusual. - IPv6 has multiple textual representations for the same address. RFC 5952 specifies a canonical form, but RFC 4291 §2.2 permits the older mixed forms, and
::ffff:0:0/96IPv4-mapped addresses can be written as either dotted-decimal or hex-encoded.
The fix in better-auth@1.4.17 introduces normalizeIP and applies it to every getIp result. Normalization expands compressed IPv6 forms, lowercases hex digits, collapses IPv4-mapped IPv6 to plain IPv4, and applies a default /64 prefix mask. The rate-limit key construction now uses an explicit | separator to prevent key-construction collisions across address-and-path joins.
The /64 default matches the smallest commonly-allocated IPv6 unit, so a single client cannot use prefix rotation to defeat rate limiting on stock config. Operators who serve clients on coarser allocations (/56 for residential ISPs, larger for cloud) can configure advanced.ipAddress.ipv6Subnet accordingly.
Patches
Fixed in better-auth@1.4.17 on the v1.4.x maintenance line and in better-auth@1.5.0-beta.9 on the v1.5.x line. PR #7470 introduced the normalization primitive (packages/core/src/utils/ip.ts) and applied it to getIp and the rate-limit key. PR #7509 changed the IPv6 prefix-length default from /128 to /64 so that stock config closes the prefix-rotation vector without requiring users to opt in.
After the patch, the rate limiter treats all IPv6 addresses within a /64 allocation as a single client, all textual encodings of one IPv6 address as the same address, and all IPv4-mapped IPv6 addresses as their underlying IPv4 form.
Workarounds
If users cannot upgrade past 1.4.17:
- **On
>= 1.4.16**: setadvanced.ipAddress.ipv6Subnet: 64in the auth configuration. ThenormalizeIPhelper is present at1.4.16; only the default is wrong. This restores the post-1.4.17behavior on stock config. - **On
< 1.4.16**: shift the bypass mitigation upstream. Set the IPv6 prefix length on the app's CDN, WAF, or load balancer rate-limit policy to/64(or coarser per RFC 6177 if the app serves residential traffic). Cloudflare, Vercel Firewall, AWS WAF, and Google Cloud Armor all support per-prefix rate limiting. - As a partial mitigation on any version: tighten the
customRuleswindow for sign-in, sign-up, and password-reset endpoints. This narrows the abuse window but does not close it.
Impact
The bypass enables unbounded authentication attempts from a single IPv6-capable client. Direct consequences:
- Credential-stuffing and brute-force on
/sign-in/emailare no longer rate-limited per client. - Account enumeration via response-shape differences becomes faster.
- Password-reset and email-verification email fan-out can be amplified.
The bypass does not directly compromise any account. Successful exploitation still requires the attacker to guess a credential the password store accepts. The rating reflects the loss of one defense-in-depth layer rather than a direct compromise.
Credit
Reported by @nexryai on GitHub.
Resources
Affected products
1- Range: >= 1.5.0-beta.1, < 1.5.0-beta.9
Patches
243e719bcc0c2fix: set default ipv6 subnet to 64 (#7509)
3 files changed · +33 −21
packages/core/src/types/init-options.ts+1 −1 modified@@ -152,7 +152,7 @@ export type BetterAuthAdvancedOptions = { * Note: This only affects IPv6 addresses. IPv4 addresses are always * rate limited individually. * - * @default 128 (individual address) + * @default 64 (/64 subnet) */ ipv6Subnet?: 128 | 64 | 48 | 32 | undefined; }
packages/core/src/utils/ip.test.ts+31 −19 modified@@ -34,37 +34,45 @@ describe("IP Normalization", () => { describe("IPv6 Normalization", () => { it("should normalize compressed IPv6 to full form", () => { - expect(normalizeIP("2001:db8::1")).toBe( + expect(normalizeIP("2001:db8::1", { ipv6Subnet: 128 })).toBe( "2001:0db8:0000:0000:0000:0000:0000:0001", ); - expect(normalizeIP("::1")).toBe( + expect(normalizeIP("::1", { ipv6Subnet: 128 })).toBe( "0000:0000:0000:0000:0000:0000:0000:0001", ); - expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000"); + expect(normalizeIP("::", { ipv6Subnet: 128 })).toBe( + "0000:0000:0000:0000:0000:0000:0000:0000", + ); }); it("should normalize uppercase to lowercase", () => { - expect(normalizeIP("2001:DB8::1")).toBe( + expect(normalizeIP("2001:DB8::1", { ipv6Subnet: 128 })).toBe( "2001:0db8:0000:0000:0000:0000:0000:0001", ); - expect(normalizeIP("2001:0DB8:ABCD:EF00::1")).toBe( + expect(normalizeIP("2001:0DB8:ABCD:EF00::1", { ipv6Subnet: 128 })).toBe( "2001:0db8:abcd:ef00:0000:0000:0000:0001", ); }); it("should handle various IPv6 formats consistently", () => { // All these represent the same address const normalized = "2001:0db8:0000:0000:0000:0000:0000:0001"; - expect(normalizeIP("2001:db8::1")).toBe(normalized); - expect(normalizeIP("2001:0db8:0:0:0:0:0:1")).toBe(normalized); - expect(normalizeIP("2001:db8:0::1")).toBe(normalized); - expect(normalizeIP("2001:0db8::0:0:0:1")).toBe(normalized); + expect(normalizeIP("2001:db8::1", { ipv6Subnet: 128 })).toBe(normalized); + expect(normalizeIP("2001:0db8:0:0:0:0:0:1", { ipv6Subnet: 128 })).toBe( + normalized, + ); + expect(normalizeIP("2001:db8:0::1", { ipv6Subnet: 128 })).toBe( + normalized, + ); + expect(normalizeIP("2001:0db8::0:0:0:1", { ipv6Subnet: 128 })).toBe( + normalized, + ); }); it("should handle IPv6 with :: at different positions", () => { - expect(normalizeIP("2001:db8:85a3::8a2e:370:7334")).toBe( - "2001:0db8:85a3:0000:0000:8a2e:0370:7334", - ); + expect( + normalizeIP("2001:db8:85a3::8a2e:370:7334", { ipv6Subnet: 128 }), + ).toBe("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); expect(normalizeIP("::ffff:192.0.2.1")).not.toContain("::"); }); }); @@ -131,11 +139,11 @@ describe("IP Normalization", () => { expect(ip1).toBe(ip2); }); - it("should handle /128 (full address) by default", () => { + it("should handle /64 subnet by default", () => { const ip1 = normalizeIP("2001:db8::1"); - const ip2 = normalizeIP("2001:db8::1", { ipv6Subnet: 128 }); + const ip2 = normalizeIP("2001:db8::1", { ipv6Subnet: 64 }); expect(ip1).toBe(ip2); - expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0001"); + expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); }); it("should not affect IPv4 addresses when ipv6Subnet is set", () => { @@ -179,7 +187,9 @@ describe("IP Normalization", () => { "2001:db8::0:1", ]; - const normalized = representations.map((ip) => normalizeIP(ip)); + const normalized = representations.map((ip) => + normalizeIP(ip, { ipv6Subnet: 128 }), + ); // All should normalize to the same value const uniqueValues = new Set(normalized); expect(uniqueValues.size).toBe(1); @@ -223,19 +233,21 @@ describe("IP Normalization", () => { describe("Edge Cases", () => { it("should handle localhost addresses", () => { expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1"); - expect(normalizeIP("::1")).toBe( + expect(normalizeIP("::1", { ipv6Subnet: 128 })).toBe( "0000:0000:0000:0000:0000:0000:0000:0001", ); }); it("should handle all-zeros address", () => { expect(normalizeIP("0.0.0.0")).toBe("0.0.0.0"); - expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000"); + expect(normalizeIP("::", { ipv6Subnet: 128 })).toBe( + "0000:0000:0000:0000:0000:0000:0000:0000", + ); }); it("should handle link-local addresses", () => { expect(normalizeIP("169.254.0.1")).toBe("169.254.0.1"); - expect(normalizeIP("fe80::1")).toBe( + expect(normalizeIP("fe80::1", { ipv6Subnet: 128 })).toBe( "fe80:0000:0000:0000:0000:0000:0000:0001", ); });
packages/core/src/utils/ip.ts+1 −1 modified@@ -192,7 +192,7 @@ export function normalizeIP( } // Normalize IPv6 - const subnetPrefix = options.ipv6Subnet || 128; + const subnetPrefix = options.ipv6Subnet || 64; return normalizeIPv6(ip, subnetPrefix); }
57af0f7b910dfix(rate-limit): support IPv6 address normalization and subnet (#7470)
12 files changed · +996 −119
docs/content/docs/concepts/rate-limit.mdx+39 −1 modified@@ -52,7 +52,7 @@ Rate limiting uses the connecting IP address to track the number of requests mad default header checked is `x-forwarded-for`, which is commonly used in production environments. If you are using a different header to track the user's IP address, you'll need to specify it. -```ts title="auth.ts" +```ts title="auth.ts" export const auth = betterAuth({ //...other options advanced: { @@ -68,6 +68,44 @@ export const auth = betterAuth({ }) ``` +#### IPv6 Address Support + +Better Auth automatically normalizes IPv6 addresses to prevent bypass attacks where attackers use different representations of the same IPv6 address (e.g., `2001:db8::1` vs `2001:0db8:0000:0000:0000:0000:0000:0001`). This ensures that all representations of the same IPv6 address are treated as the same for rate limiting purposes. + +Additionally, IPv4-mapped IPv6 addresses (e.g., `::ffff:192.0.2.1`) are automatically converted to their IPv4 form (`192.0.2.1`) to prevent attackers from bypassing rate limits by switching between IPv4 and IPv6 representations. + +#### IPv6 Subnet Rate Limiting + +By default, IPv6 addresses are rate limited individually (using the full /128 address). However, since IPv6 typically allocates large address blocks to single users, attackers could potentially bypass rate limits by rotating through multiple IPv6 addresses from their allocation. + +To prevent this, you can configure rate limiting to apply to IPv6 subnets instead of individual addresses: + +```ts title="auth.ts" +export const auth = betterAuth({ + //...other options + advanced: { + ipAddress: { + ipv6Subnet: 64, // Rate limit by /64 subnet instead of individual addresses + }, + }, + rateLimit: { + enabled: true, + window: 60, + max: 100, + }, +}) +``` + +Common IPv6 subnet prefix lengths: +- `128` (default): Individual IPv6 address - most restrictive +- `64`: /64 subnet - typical home/business allocation +- `48`: /48 subnet - larger network allocation +- `32`: /32 subnet - ISP-level allocation + +<Callout type="info"> +IPv6 subnet configuration only affects IPv6 addresses. IPv4 addresses are always rate limited individually. +</Callout> + ### Rate Limit Window ```ts title="auth.ts"
e2e/smoke/test/fixtures/ipv6/index.ts+46 −0 added@@ -0,0 +1,46 @@ +import { DatabaseSync } from "node:sqlite"; +import { serve } from "@hono/node-server"; +import { betterAuth } from "better-auth"; +import { getMigrations } from "better-auth/db/migration"; +import { Hono } from "hono"; + +const database = new DatabaseSync(":memory:"); + +export const auth = betterAuth({ + baseURL: "http://localhost:3000", + database, + emailAndPassword: { + enabled: true, + }, + trustedOrigins: [ + "http://localhost:*", // Allow any localhost port for smoke tests + ], + rateLimit: { + enabled: true, + window: 60, + max: 3, + }, + advanced: { + ipAddress: { + ipv6Subnet: 64, // Group IPv6 addresses by /64 subnet + }, + }, +}); + +const { runMigrations } = await getMigrations(auth.options); + +await runMigrations(); + +const app = new Hono(); + +app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)); + +serve( + { + fetch: app.fetch, + port: 0, + }, + (info) => { + console.log(info.port); + }, +);
e2e/smoke/test/fixtures/ipv6/package.json+10 −0 added@@ -0,0 +1,10 @@ +{ + "name": "fixtures-ipv6", + "private": true, + "type": "module", + "dependencies": { + "@hono/node-server": "^1.19.9", + "better-auth": "workspace:*", + "hono": "^4.11.4" + } +}
e2e/smoke/test/fixtures/ipv6/tsconfig.json+10 −0 added@@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "module": "esnext", + "moduleResolution": "bundler" + } +}
e2e/smoke/test/ipv6.spec.ts+205 −0 added@@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const fixturesDir = fileURLToPath(new URL("./fixtures", import.meta.url)); + +const nodejsWarnings = ["ExperimentalWarning"]; + +describe("IPv6 rate limiting", () => { + const entryFile = join(fixturesDir, "ipv6", "index.ts"); + it("should group IPv6 addresses from same /64 subnet", async (t) => { + const cp = spawn("node", [entryFile], { + stdio: "pipe", + }); + t.after(() => { + cp.kill("SIGINT"); + }); + cp.stderr.on("data", (data) => { + console.error(data.toString()); + }); + const port = await new Promise<number>((resolve) => { + cp.stdout.on("data", (data) => { + const output = data.toString().replace(/\u001b\[[0-9;]*m/g, ""); + if (nodejsWarnings.some((warning) => output.includes(warning))) { + return; + } + const port = +output; + assert.ok(port > 0); + assert.ok(!Number.isNaN(port)); + assert.ok(Number.isFinite(port)); + resolve(port); + }); + }); + + // Different IPv6 addresses from the same /64 subnet + // 2001:db8:abcd:1234::/64 prefix + const ipv6Addresses = [ + "2001:db8:abcd:1234:0000:0000:0000:0001", + "2001:db8:abcd:1234:1111:2222:3333:4444", + "2001:db8:abcd:1234:ffff:ffff:ffff:ffff", + ]; + + // Make requests with different IPv6 addresses from same /64 + // Rate limit is max 3 requests, so 4th should be blocked + for (let i = 0; i < 4; i++) { + const ipv6 = ipv6Addresses[i % ipv6Addresses.length]!; + const response = await fetch( + `http://localhost:${port}/api/auth/sign-in/email`, + { + method: "POST", + body: JSON.stringify({ + email: "test@test.com", + password: "password", + }), + headers: { + "content-type": "application/json", + origin: `http://localhost:${port}`, + "x-forwarded-for": ipv6, + }, + }, + ); + + if (i >= 3) { + assert.equal( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should be rate limited (429)`, + ); + } else { + assert.notEqual( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should not be rate limited`, + ); + } + } + }); + + it("should not group IPv6 addresses from different /64 subnets", async (t) => { + const cp = spawn("node", [entryFile], { + stdio: "pipe", + }); + t.after(() => { + cp.kill("SIGINT"); + }); + cp.stderr.on("data", (data) => { + console.error(data.toString()); + }); + const port = await new Promise<number>((resolve) => { + cp.stdout.on("data", (data) => { + const output = data.toString().replace(/\u001b\[[0-9;]*m/g, ""); + if (nodejsWarnings.some((warning) => output.includes(warning))) { + return; + } + const port = +output; + assert.ok(port > 0); + assert.ok(!Number.isNaN(port)); + assert.ok(Number.isFinite(port)); + resolve(port); + }); + }); + + // Different /64 subnets + const differentSubnets = [ + "2001:db8:abcd:1111:0000:0000:0000:0001", // /64 subnet 1 + "2001:db8:abcd:2222:0000:0000:0000:0001", // /64 subnet 2 + "2001:db8:abcd:3333:0000:0000:0000:0001", // /64 subnet 3 + ]; + + // Each subnet should have its own rate limit counter + // So 3 requests from 3 different subnets should all succeed + for (const ipv6 of differentSubnets) { + const response = await fetch( + `http://localhost:${port}/api/auth/sign-in/email`, + { + method: "POST", + body: JSON.stringify({ + email: "test@test.com", + password: "password", + }), + headers: { + "content-type": "application/json", + origin: `http://localhost:${port}`, + "x-forwarded-for": ipv6, + }, + }, + ); + + assert.notEqual( + response.status, + 429, + `Request with IP ${ipv6} should not be rate limited (different subnet)`, + ); + } + }); + + it("should normalize different IPv6 representations", async (t) => { + const cp = spawn("node", [entryFile], { + stdio: "pipe", + }); + t.after(() => { + cp.kill("SIGINT"); + }); + cp.stderr.on("data", (data) => { + console.error(data.toString()); + }); + const port = await new Promise<number>((resolve) => { + cp.stdout.on("data", (data) => { + const output = data.toString().replace(/\u001b\[[0-9;]*m/g, ""); + if (nodejsWarnings.some((warning) => output.includes(warning))) { + return; + } + const port = +output; + assert.ok(port > 0); + assert.ok(!Number.isNaN(port)); + assert.ok(Number.isFinite(port)); + resolve(port); + }); + }); + + // Different representations of the same IPv6 address + const sameAddressDifferentFormats = [ + "2001:db8::1", + "2001:DB8::1", // uppercase + "2001:0db8::1", // leading zeros + "2001:db8:0:0:0:0:0:1", // expanded + ]; + + // All should be normalized to the same address and share rate limit + for (let i = 0; i < 4; i++) { + const ipv6 = sameAddressDifferentFormats[i]!; + const response = await fetch( + `http://localhost:${port}/api/auth/sign-in/email`, + { + method: "POST", + body: JSON.stringify({ + email: "test@test.com", + password: "password", + }), + headers: { + "content-type": "application/json", + origin: `http://localhost:${port}`, + "x-forwarded-for": ipv6, + }, + }, + ); + + if (i >= 3) { + assert.equal( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should be rate limited (same address)`, + ); + } else { + assert.notEqual( + response.status, + 429, + `Request ${i + 1} with IP ${ipv6} should not be rate limited`, + ); + } + } + }); +});
packages/better-auth/src/api/rate-limiter/index.ts+2 −1 modified@@ -2,6 +2,7 @@ import type { AuthContext, BetterAuthRateLimitStorage, } from "@better-auth/core"; +import { createRateLimitKey } from "@better-auth/core/utils/ip"; import { safeJSONParse } from "@better-auth/core/utils/json"; import { normalizePathname } from "@better-auth/core/utils/url"; import type { RateLimit } from "../../types"; @@ -168,7 +169,7 @@ export async function onRequestRateLimit(req: Request, ctx: AuthContext) { if (!ip) { return; } - const key = ip + path; + const key = createRateLimitKey(ip, path); const specialRules = getDefaultSpecialRules(); const specialRule = specialRules.find((rule) => rule.pathMatcher(path));
packages/better-auth/src/api/rate-limiter/rate-limiter.test.ts+175 −92 modified@@ -1,110 +1,105 @@ +import { normalizeIP } from "@better-auth/core/utils/ip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import type { RateLimit } from "../../types"; -describe( - "rate-limiter", - { - timeout: 10000, - }, - async () => { - const { client, testUser } = await getTestInstance({ - rateLimit: { - enabled: true, - window: 10, - max: 20, - }, - }); - - it("should return 429 after 3 request for sign-in", async () => { - for (let i = 0; i < 5; i++) { - const response = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - }); - if (i >= 3) { - expect(response.error?.status).toBe(429); - } else { - expect(response.error).toBeNull(); - } - } - }); +describe("rate-limiter", async () => { + const { client, testUser } = await getTestInstance({ + rateLimit: { + enabled: true, + window: 10, + max: 20, + }, + }); - it("should reset the limit after the window period", async () => { - vi.useFakeTimers(); - vi.advanceTimersByTime(11000); - for (let i = 0; i < 5; i++) { - const res = await client.signIn.email({ - email: testUser.email, - password: testUser.password, - }); - if (i >= 3) { - expect(res.error?.status).toBe(429); - } else { - expect(res.error).toBeNull(); - } + it("should return 429 after 3 request for sign-in", async () => { + for (let i = 0; i < 5; i++) { + const response = await client.signIn.email({ + email: testUser.email, + password: testUser.password, + }); + if (i >= 3) { + expect(response.error?.status).toBe(429); + } else { + expect(response.error).toBeNull(); } - }); - - it("should respond the correct retry-after header", async () => { - vi.useFakeTimers(); - vi.advanceTimersByTime(3000); - let retryAfter = ""; - await client.signIn.email( - { - email: testUser.email, - password: testUser.password, - }, - { - onError(context) { - retryAfter = context.response.headers.get("X-Retry-After") ?? ""; - }, - }, - ); - expect(retryAfter).toBe("7"); - }); + } + }); - it("should rate limit based on the path", async () => { - const signInRes = await client.signIn.email({ + it("should reset the limit after the window period", async () => { + vi.useFakeTimers(); + vi.advanceTimersByTime(11000); + for (let i = 0; i < 5; i++) { + const res = await client.signIn.email({ email: testUser.email, password: testUser.password, }); - expect(signInRes.error?.status).toBe(429); + if (i >= 3) { + expect(res.error?.status).toBe(429); + } else { + expect(res.error).toBeNull(); + } + } + }); - const signUpRes = await client.signUp.email({ - email: "new-test@email.com", + it("should respond the correct retry-after header", async () => { + vi.useFakeTimers(); + vi.advanceTimersByTime(3000); + let retryAfter = ""; + await client.signIn.email( + { + email: testUser.email, password: testUser.password, - name: "test", - }); - expect(signUpRes.error).toBeNull(); + }, + { + onError(context) { + retryAfter = context.response.headers.get("X-Retry-After") ?? ""; + }, + }, + ); + expect(retryAfter).toBe("7"); + }); + + it("should rate limit based on the path", async () => { + const signInRes = await client.signIn.email({ + email: testUser.email, + password: testUser.password, }); + expect(signInRes.error?.status).toBe(429); - it("non-special-rules limits", async () => { - for (let i = 0; i < 25; i++) { - const response = await client.getSession(); - expect(response.error?.status).toBe(i >= 20 ? 429 : undefined); - } + const signUpRes = await client.signUp.email({ + email: "new-test@email.com", + password: testUser.password, + name: "test", }); + expect(signUpRes.error).toBeNull(); + }); + + it("non-special-rules limits", async () => { + for (let i = 0; i < 25; i++) { + const response = await client.getSession(); + expect(response.error?.status).toBe(i >= 20 ? 429 : undefined); + } + }); - it("query params should be ignored", async () => { - for (let i = 0; i < 25; i++) { - const response = await client.listSessions({ - fetchOptions: { - query: { - "test-query": Math.random().toString(), - }, + it("query params should be ignored", async () => { + for (let i = 0; i < 25; i++) { + const response = await client.listSessions({ + fetchOptions: { + query: { + "test-query": Math.random().toString(), }, - }); + }, + }); - if (i >= 20) { - expect(response.error?.status).toBe(429); - } else { - expect(response.error?.status).toBe(401); - } + if (i >= 20) { + expect(response.error?.status).toBe(429); + } else { + expect(response.error?.status).toBe(401); } - }); - }, -); + } + }); +}); describe("custom rate limiting storage", async () => { const store = new Map<string, string>(); @@ -138,7 +133,7 @@ describe("custom rate limiting storage", async () => { password: testUser.password, }); const rateLimitData: RateLimit = JSON.parse( - store.get("127.0.0.1/sign-in/email") ?? "{}", + store.get("127.0.0.1|/sign-in/email") ?? "{}", ); expect(rateLimitData.lastRequest).toBeGreaterThanOrEqual(lastRequest); lastRequest = rateLimitData.lastRequest; @@ -149,7 +144,7 @@ describe("custom rate limiting storage", async () => { expect(response.error).toBeNull(); expect(rateLimitData.count).toBe(i + 1); } - const rateLimitExp = expirationMap.get("127.0.0.1/sign-in/email"); + const rateLimitExp = expirationMap.get("127.0.0.1|/sign-in/email"); expect(rateLimitExp).toBe(10); } }); @@ -277,7 +272,7 @@ describe("should work in development/test environment", () => { ); expect(signInKeys.length).toBeGreaterThan(0); - expect(signInKeys[0]).toBe(`${LOCALHOST_IP}${REQUEST_PATH}`); + expect(signInKeys[0]).toBe(`${LOCALHOST_IP}|${REQUEST_PATH}`); }); it("should work in test environment", async () => { @@ -321,6 +316,94 @@ describe("should work in development/test environment", () => { ); expect(signInKeys.length).toBeGreaterThan(0); - expect(signInKeys[0]).toBe(`${LOCALHOST_IP}${REQUEST_PATH}`); + expect(signInKeys[0]).toBe(`${LOCALHOST_IP}|${REQUEST_PATH}`); + }); +}); + +describe("IPv6 address normalization and rate limiting", () => { + it("should normalize IPv6 addresses to canonical form", () => { + // All these representations of the same IPv6 address should normalize to the same value + const representations = [ + "2001:db8::1", + "2001:DB8::1", + "2001:0db8::1", + "2001:db8:0::1", + "2001:0db8:0:0:0:0:0:1", + ]; + + const normalized = representations.map((ip) => normalizeIP(ip)); + const uniqueValues = new Set(normalized); + + // All should normalize to the same value + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:0000:0000:0000:0000:0000:0001"); + }); + + it("should convert IPv4-mapped IPv6 to IPv4", () => { + const ipv4Mapped = [ + "::ffff:192.0.2.1", + "::FFFF:192.0.2.1", + "::ffff:c000:0201", // hex-encoded + ]; + + const normalized = ipv4Mapped.map((ip) => normalizeIP(ip)); + + // All should normalize to the same IPv4 address + normalized.forEach((ip) => { + expect(ip).toBe("192.0.2.1"); + }); + }); + + it("should support IPv6 subnet rate limiting", () => { + // Simulate attacker rotating through IPv6 addresses in their /64 allocation + const attackIPs = [ + "2001:db8:abcd:1234:0000:0000:0000:0001", + "2001:db8:abcd:1234:1111:2222:3333:4444", + "2001:db8:abcd:1234:ffff:ffff:ffff:ffff", + ]; + + const normalized = attackIPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + + // All should map to same /64 subnet + const uniqueValues = new Set(normalized); + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:abcd:1234:0000:0000:0000:0000"); + }); + + it("should rate limit different IPv6 subnets separately", () => { + // Different /64 subnets should have separate rate limits + const subnet1IPs = ["2001:db8:abcd:1111::1", "2001:db8:abcd:1111::2"]; + const subnet2IPs = ["2001:db8:abcd:2222::1", "2001:db8:abcd:2222::2"]; + + const normalized1 = subnet1IPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + const normalized2 = subnet2IPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + + // Same subnet should normalize to same value + expect(normalized1[0]).toBe(normalized1[1]); + expect(normalized2[0]).toBe(normalized2[1]); + + // Different subnets should normalize to different values + expect(normalized1[0]).not.toBe(normalized2[0]); + }); + + it("should handle localhost IPv6 addresses", () => { + expect(normalizeIP("::1")).toBe("0000:0000:0000:0000:0000:0000:0000:0001"); + }); + + it("should handle link-local IPv6 addresses", () => { + const linkLocal = normalizeIP("fe80::1"); + expect(linkLocal).toBe("fe80:0000:0000:0000:0000:0000:0000:0001"); + }); + + it("IPv6 subnet should not affect IPv4 addresses", () => { + const ipv4 = "192.168.1.1"; + const normalized = normalizeIP(ipv4, { ipv6Subnet: 64 }); + expect(normalized).toBe(ipv4); }); });
packages/better-auth/src/utils/get-request-ip.ts+4 −17 modified@@ -1,6 +1,6 @@ import type { BetterAuthOptions } from "@better-auth/core"; import { isDevelopment, isTest } from "@better-auth/core/env"; -import * as z from "zod"; +import { isValidIP, normalizeIP } from "@better-auth/core/utils/ip"; // Localhost IP used for test and development environments const LOCALHOST_IP = "127.0.0.1"; @@ -25,7 +25,9 @@ export function getIp( if (typeof value === "string") { const ip = value.split(",")[0]!.trim(); if (isValidIP(ip)) { - return ip; + return normalizeIP(ip, { + ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet, + }); } } } @@ -37,18 +39,3 @@ export function getIp( return null; } - -function isValidIP(ip: string): boolean { - const ipv4 = z.ipv4().safeParse(ip); - - if (ipv4.success) { - return true; - } - - const ipv6 = z.ipv6().safeParse(ip); - if (ipv6.success) { - return true; - } - - return false; -}
packages/core/src/types/init-options.ts+19 −0 modified@@ -136,6 +136,25 @@ export type BetterAuthAdvancedOptions = { * ⚠︎ This is a security risk and it may expose your application to abuse */ disableIpTracking?: boolean; + /** + * IPv6 subnet prefix length for rate limiting. + * + * IPv6 addresses can be grouped by subnet to prevent attackers from + * bypassing rate limits by rotating through multiple addresses in + * their allocation. + * + * Common values: + * - 128 (default): Individual IPv6 address + * - 64: /64 subnet (typical home/business allocation) + * - 48: /48 subnet (larger network allocation) + * - 32: /32 subnet (ISP allocation) + * + * Note: This only affects IPv6 addresses. IPv4 addresses are always + * rate limited individually. + * + * @default 128 (individual address) + */ + ipv6Subnet?: 128 | 64 | 48 | 32 | undefined; } | undefined; /**
packages/core/src/utils/ip.test.ts+243 −0 added@@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; +import { createRateLimitKey, isValidIP, normalizeIP } from "./ip"; + +describe("IP Normalization", () => { + describe("isValidIP", () => { + it("should validate IPv4 addresses", () => { + expect(isValidIP("192.168.1.1")).toBe(true); + expect(isValidIP("127.0.0.1")).toBe(true); + expect(isValidIP("0.0.0.0")).toBe(true); + expect(isValidIP("255.255.255.255")).toBe(true); + }); + + it("should validate IPv6 addresses", () => { + expect(isValidIP("2001:db8::1")).toBe(true); + expect(isValidIP("::1")).toBe(true); + expect(isValidIP("::")).toBe(true); + expect(isValidIP("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe(true); + }); + + it("should reject invalid IPs", () => { + expect(isValidIP("not-an-ip")).toBe(false); + expect(isValidIP("999.999.999.999")).toBe(false); + expect(isValidIP("gggg::1")).toBe(false); + }); + }); + + describe("IPv4 Normalization", () => { + it("should return IPv4 addresses unchanged", () => { + expect(normalizeIP("192.168.1.1")).toBe("192.168.1.1"); + expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1"); + expect(normalizeIP("10.0.0.1")).toBe("10.0.0.1"); + }); + }); + + describe("IPv6 Normalization", () => { + it("should normalize compressed IPv6 to full form", () => { + expect(normalizeIP("2001:db8::1")).toBe( + "2001:0db8:0000:0000:0000:0000:0000:0001", + ); + expect(normalizeIP("::1")).toBe( + "0000:0000:0000:0000:0000:0000:0000:0001", + ); + expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000"); + }); + + it("should normalize uppercase to lowercase", () => { + expect(normalizeIP("2001:DB8::1")).toBe( + "2001:0db8:0000:0000:0000:0000:0000:0001", + ); + expect(normalizeIP("2001:0DB8:ABCD:EF00::1")).toBe( + "2001:0db8:abcd:ef00:0000:0000:0000:0001", + ); + }); + + it("should handle various IPv6 formats consistently", () => { + // All these represent the same address + const normalized = "2001:0db8:0000:0000:0000:0000:0000:0001"; + expect(normalizeIP("2001:db8::1")).toBe(normalized); + expect(normalizeIP("2001:0db8:0:0:0:0:0:1")).toBe(normalized); + expect(normalizeIP("2001:db8:0::1")).toBe(normalized); + expect(normalizeIP("2001:0db8::0:0:0:1")).toBe(normalized); + }); + + it("should handle IPv6 with :: at different positions", () => { + expect(normalizeIP("2001:db8:85a3::8a2e:370:7334")).toBe( + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + ); + expect(normalizeIP("::ffff:192.0.2.1")).not.toContain("::"); + }); + }); + + describe("IPv4-mapped IPv6 Conversion", () => { + it("should convert IPv4-mapped IPv6 to IPv4", () => { + expect(normalizeIP("::ffff:192.0.2.1")).toBe("192.0.2.1"); + expect(normalizeIP("::ffff:127.0.0.1")).toBe("127.0.0.1"); + expect(normalizeIP("::FFFF:10.0.0.1")).toBe("10.0.0.1"); + }); + + it("should handle hex-encoded IPv4 in mapped addresses", () => { + // ::ffff:c000:0201 = ::ffff:192.0.2.1 = 192.0.2.1 + expect(normalizeIP("::ffff:c000:0201")).toBe("192.0.2.1"); + // ::ffff:7f00:0001 = ::ffff:127.0.0.1 = 127.0.0.1 + expect(normalizeIP("::ffff:7f00:0001")).toBe("127.0.0.1"); + }); + + it("should handle full form IPv4-mapped IPv6", () => { + expect(normalizeIP("0:0:0:0:0:ffff:192.0.2.1")).toBe("192.0.2.1"); + }); + }); + + describe("IPv6 Subnet Support", () => { + it("should extract /64 subnet", () => { + /* cspell:disable-next-line */ + const ip1 = normalizeIP("2001:db8:0:0:1234:5678:90ab:cdef", { + ipv6Subnet: 64, + }); + const ip2 = normalizeIP("2001:db8:0:0:ffff:ffff:ffff:ffff", { + ipv6Subnet: 64, + }); + // Both should have same /64 prefix + expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip1).toBe(ip2); + }); + + it("should extract /48 subnet", () => { + /* cspell:disable-next-line */ + const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", { + ipv6Subnet: 48, + }); + const ip2 = normalizeIP("2001:db8:1234:ffff:ffff:ffff:ffff:ffff", { + ipv6Subnet: 48, + }); + // Both should have same /48 prefix + expect(ip1).toBe("2001:0db8:1234:0000:0000:0000:0000:0000"); + expect(ip2).toBe("2001:0db8:1234:0000:0000:0000:0000:0000"); + expect(ip1).toBe(ip2); + }); + + it("should extract /32 subnet", () => { + /* cspell:disable-next-line */ + const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", { + ipv6Subnet: 32, + }); + const ip2 = normalizeIP("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", { + ipv6Subnet: 32, + }); + // Both should have same /32 prefix + expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000"); + expect(ip1).toBe(ip2); + }); + + it("should handle /128 (full address) by default", () => { + const ip1 = normalizeIP("2001:db8::1"); + const ip2 = normalizeIP("2001:db8::1", { ipv6Subnet: 128 }); + expect(ip1).toBe(ip2); + expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0001"); + }); + + it("should not affect IPv4 addresses when ipv6Subnet is set", () => { + expect(normalizeIP("192.168.1.1", { ipv6Subnet: 64 })).toBe( + "192.168.1.1", + ); + }); + }); + + describe("Rate Limit Key Creation", () => { + it("should create keys with separator", () => { + expect(createRateLimitKey("192.168.1.1", "/sign-in")).toBe( + "192.168.1.1|/sign-in", + ); + expect(createRateLimitKey("2001:db8::1", "/api/auth")).toBe( + "2001:db8::1|/api/auth", + ); + }); + + it("should prevent collision attacks", () => { + // Without separator: "192.0.2.1" + "/sign-in" = "192.0.2.1/sign-in" + // "192.0.2" + ".1/sign-in" = "192.0.2.1/sign-in" + // With separator: they're different + const key1 = createRateLimitKey("192.0.2.1", "/sign-in"); + const key2 = createRateLimitKey("192.0.2", ".1/sign-in"); + expect(key1).not.toBe(key2); + expect(key1).toBe("192.0.2.1|/sign-in"); + expect(key2).toBe("192.0.2|.1/sign-in"); + }); + }); + + describe("Security: Bypass Prevention", () => { + it("should prevent IPv6 representation bypass", () => { + // Attacker tries different representations of same address + const representations = [ + "2001:db8::1", + "2001:DB8::1", + "2001:0db8::1", + "2001:db8:0::1", + "2001:0db8:0:0:0:0:0:1", + "2001:db8::0:1", + ]; + + const normalized = representations.map((ip) => normalizeIP(ip)); + // All should normalize to the same value + const uniqueValues = new Set(normalized); + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:0000:0000:0000:0000:0000:0001"); + }); + + it("should prevent IPv4-mapped bypass", () => { + // Attacker switches between IPv4 and IPv4-mapped IPv6 + const ip1 = normalizeIP("192.0.2.1"); + const ip2 = normalizeIP("::ffff:192.0.2.1"); + const ip3 = normalizeIP("::FFFF:192.0.2.1"); + const ip4 = normalizeIP("::ffff:c000:0201"); + + // All should normalize to the same IPv4 + expect(ip1).toBe("192.0.2.1"); + expect(ip2).toBe("192.0.2.1"); + expect(ip3).toBe("192.0.2.1"); + expect(ip4).toBe("192.0.2.1"); + }); + + it("should group IPv6 subnet attacks", () => { + // Attacker rotates through addresses in their /64 allocation + const attackIPs = [ + "2001:db8:abcd:1234:0000:0000:0000:0001", + "2001:db8:abcd:1234:1111:2222:3333:4444", + "2001:db8:abcd:1234:ffff:ffff:ffff:ffff", + "2001:db8:abcd:1234:aaaa:bbbb:cccc:dddd", + ]; + + const normalized = attackIPs.map((ip) => + normalizeIP(ip, { ipv6Subnet: 64 }), + ); + + // All should map to same /64 subnet + const uniqueValues = new Set(normalized); + expect(uniqueValues.size).toBe(1); + expect(normalized[0]).toBe("2001:0db8:abcd:1234:0000:0000:0000:0000"); + }); + }); + + describe("Edge Cases", () => { + it("should handle localhost addresses", () => { + expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1"); + expect(normalizeIP("::1")).toBe( + "0000:0000:0000:0000:0000:0000:0000:0001", + ); + }); + + it("should handle all-zeros address", () => { + expect(normalizeIP("0.0.0.0")).toBe("0.0.0.0"); + expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000"); + }); + + it("should handle link-local addresses", () => { + expect(normalizeIP("169.254.0.1")).toBe("169.254.0.1"); + expect(normalizeIP("fe80::1")).toBe( + "fe80:0000:0000:0000:0000:0000:0000:0001", + ); + }); + }); +});
packages/core/src/utils/ip.ts+211 −0 added@@ -0,0 +1,211 @@ +import * as z from "zod"; + +/** + * Normalizes an IP address for consistent rate limiting. + * + * Features: + * - Normalizes IPv6 to canonical lowercase form + * - Converts IPv4-mapped IPv6 to IPv4 + * - Supports IPv6 subnet extraction + * - Handles all edge cases (::1, ::, etc.) + */ + +interface NormalizeIPOptions { + /** + * For IPv6 addresses, extract the subnet prefix instead of full address. + * Common values: 32, 48, 64, 128 (default: 128 = full address) + * + * @default 128 + */ + ipv6Subnet?: 128 | 64 | 48 | 32; +} + +/** + * Checks if an IP is valid IPv4 or IPv6 + */ +export function isValidIP(ip: string): boolean { + return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success; +} + +/** + * Checks if an IP is IPv6 + */ +function isIPv6(ip: string): boolean { + return z.ipv6().safeParse(ip).success; +} + +/** + * Converts IPv4-mapped IPv6 address to IPv4 + * e.g., "::ffff:192.0.2.1" -> "192.0.2.1" + */ +function extractIPv4FromMapped(ipv6: string): string | null { + const lower = ipv6.toLowerCase(); + + // Handle ::ffff:192.0.2.1 format + if (lower.startsWith("::ffff:")) { + const ipv4Part = lower.substring(7); + // Check if it's a valid IPv4 + if (z.ipv4().safeParse(ipv4Part).success) { + return ipv4Part; + } + } + + // Handle full form: 0:0:0:0:0:ffff:192.0.2.1 + const parts = ipv6.split(":"); + if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") { + const ipv4Part = parts[6]; + if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) { + return ipv4Part; + } + } + + // Handle hex-encoded IPv4 in mapped address + // e.g., ::ffff:c000:0201 -> 192.0.2.1 + if (lower.includes("::ffff:") || lower.includes(":ffff:")) { + const groups = expandIPv6(ipv6); + if ( + groups.length === 8 && + groups[0] === "0000" && + groups[1] === "0000" && + groups[2] === "0000" && + groups[3] === "0000" && + groups[4] === "0000" && + groups[5] === "ffff" && + groups[6] && + groups[7] + ) { + // Convert last two groups to IPv4 + const byte1 = Number.parseInt(groups[6].substring(0, 2), 16); + const byte2 = Number.parseInt(groups[6].substring(2, 4), 16); + const byte3 = Number.parseInt(groups[7].substring(0, 2), 16); + const byte4 = Number.parseInt(groups[7].substring(2, 4), 16); + return `${byte1}.${byte2}.${byte3}.${byte4}`; + } + } + + return null; +} + +/** + * Expands a compressed IPv6 address to full form + * e.g., "2001:db8::1" -> ["2001", "0db8", "0000", "0000", "0000", "0000", "0000", "0001"] + */ +function expandIPv6(ipv6: string): string[] { + // Handle :: notation (zero compression) + if (ipv6.includes("::")) { + const sides = ipv6.split("::"); + const left = sides[0] ? sides[0].split(":") : []; + const right = sides[1] ? sides[1].split(":") : []; + + // Calculate missing groups + const totalGroups = 8; + const missingGroups = totalGroups - left.length - right.length; + const zeros = Array(missingGroups).fill("0000"); + + // Pad existing groups to 4 digits + const paddedLeft = left.map((g) => g.padStart(4, "0")); + const paddedRight = right.map((g) => g.padStart(4, "0")); + + return [...paddedLeft, ...zeros, ...paddedRight]; + } + + // No compression, just pad each group + return ipv6.split(":").map((g) => g.padStart(4, "0")); +} + +/** + * Normalizes an IPv6 address to canonical form + * e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001" + */ +function normalizeIPv6( + ipv6: string, + subnetPrefix?: 128 | 32 | 48 | 64, +): string { + const groups = expandIPv6(ipv6); + + if (subnetPrefix && subnetPrefix < 128) { + // Apply subnet mask + const prefix = subnetPrefix; + let bitsRemaining: number = prefix; + + const maskedGroups = groups.map((group) => { + if (bitsRemaining <= 0) { + return "0000"; + } + if (bitsRemaining >= 16) { + bitsRemaining -= 16; + return group; + } + + // Partial mask for this group + const value = Number.parseInt(group, 16); + const mask = (0xffff << (16 - bitsRemaining)) & 0xffff; + const masked = value & mask; + bitsRemaining = 0; + return masked.toString(16).padStart(4, "0"); + }); + + return maskedGroups.join(":").toLowerCase(); + } + + return groups.join(":").toLowerCase(); +} + +/** + * Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting. + * + * @param ip - The IP address to normalize + * @param options - Normalization options + * @returns Normalized IP address + * + * @example + * normalizeIP("2001:DB8::1") + * // -> "2001:0db8:0000:0000:0000:0000:0000:0001" + * + * @example + * normalizeIP("::ffff:192.0.2.1") + * // -> "192.0.2.1" (converted to IPv4) + * + * @example + * normalizeIP("2001:db8::1", { ipv6Subnet: 64 }) + * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64) + */ +export function normalizeIP( + ip: string, + options: NormalizeIPOptions = {}, +): string { + // IPv4 addresses are already normalized + if (z.ipv4().safeParse(ip).success) { + return ip.toLowerCase(); + } + + // Check if it's IPv6 + if (!isIPv6(ip)) { + // Return as-is if not valid (shouldn't happen due to prior validation) + return ip.toLowerCase(); + } + + // Check for IPv4-mapped IPv6 + const ipv4 = extractIPv4FromMapped(ip); + if (ipv4) { + return ipv4.toLowerCase(); + } + + // Normalize IPv6 + const subnetPrefix = options.ipv6Subnet || 128; + return normalizeIPv6(ip, subnetPrefix); +} + +/** + * Creates a rate limit key from IP and path + * Uses a separator to prevent collision attacks + * + * @param ip - The IP address (should be normalized) + * @param path - The request path + * @returns Rate limit key + */ +export function createRateLimitKey(ip: string, path: string): string { + // Use | as separator to prevent collision attacks + // e.g., "192.0.2.1" + "/sign-in" vs "192.0.2" + ".1/sign-in" + return `${ip}|${path}`; +}
pnpm-lock.yaml+32 −8 modified@@ -419,7 +419,7 @@ importers: version: 12.23.12(react-dom@19.2.3(react@19.2.3))(react@19.2.3) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -531,7 +531,7 @@ importers: version: 2.1.1 geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.2.3) @@ -773,7 +773,7 @@ importers: version: 16.4.3(@oramacloud/client@2.1.4)(@tanstack/react-router@1.151.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(algoliasearch@5.46.2)(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.4) geist: specifier: ^1.4.2 - version: 1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) + version: 1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -1027,6 +1027,18 @@ importers: specifier: ^0.27.2 version: 0.27.2 + e2e/smoke/test/fixtures/ipv6: + dependencies: + '@hono/node-server': + specifier: ^1.19.9 + version: 1.19.9(hono@4.11.4) + better-auth: + specifier: workspace:* + version: link:../../../../../packages/better-auth + hono: + specifier: ^4.11.4 + version: 4.11.4 + e2e/smoke/test/fixtures/tsconfig-declaration: dependencies: '@better-auth/oauth-provider': @@ -4068,6 +4080,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -18486,7 +18504,7 @@ snapshots: postcss: 8.4.49 resolve-from: 5.0.0 optionalDependencies: - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - bufferutil - supports-color @@ -18573,7 +18591,7 @@ snapshots: '@expo/json-file': 10.0.8 '@react-native/normalize-colors': 0.81.5 debug: 4.4.3 - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) resolve-from: 5.0.0 semver: 7.7.3 xml2js: 0.6.0 @@ -18684,6 +18702,10 @@ snapshots: dependencies: hono: 4.11.4 + '@hono/node-server@1.19.9(hono@4.11.4)': + dependencies: + hono: 4.11.4 + '@hookform/resolvers@5.2.2(react-hook-form@7.68.0(react@19.2.3))': dependencies: '@standard-schema/utils': 0.3.0 @@ -21256,7 +21278,9 @@ snapshots: metro-runtime: 0.83.3 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} @@ -23539,7 +23563,7 @@ snapshots: resolve-from: 5.0.0 optionalDependencies: '@babel/runtime': 7.28.4 - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -25316,7 +25340,7 @@ snapshots: expo-keep-awake@15.0.8(expo@54.0.30)(react@19.2.3): dependencies: - expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) + expo: 54.0.30(@babel/core@7.28.5)(@expo/metro-runtime@6.1.2)(expo-router@6.0.21)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.5))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3) react: 19.2.3 expo-linking@7.1.7(expo@54.0.30)(react-native@0.80.2(@babel/core@7.28.5)(@react-native-community/cli@20.0.2(typescript@5.9.3))(@types/react@19.2.7)(react@19.2.3))(react@19.2.3): @@ -26084,7 +26108,7 @@ snapshots: function-bind@1.1.2: {} - geist@1.4.2(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)): + geist@1.4.2(next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)): dependencies: next: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.97.1)
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
6- github.com/advisories/GHSA-p6v2-xcpg-h6xwghsaADVISORY
- github.com/better-auth/better-auth/commit/43e719bcc0c223c7079fa0c611a9cf7ea1188254ghsa
- github.com/better-auth/better-auth/commit/57af0f7b910dcf7b1a5c0615d10b9bd56bb69befghsa
- github.com/better-auth/better-auth/pull/7470ghsa
- github.com/better-auth/better-auth/pull/7509ghsa
- github.com/better-auth/better-auth/security/advisories/GHSA-p6v2-xcpg-h6xwghsa
News mentions
0No linked articles in our index yet.