@better-auth/core 1.4.14 → 1.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -7
- package/dist/context/global.mjs +1 -1
- package/dist/types/context.d.mts +1 -0
- package/dist/types/init-options.d.mts +19 -0
- package/dist/utils/index.d.mts +3 -1
- package/dist/utils/index.mjs +3 -1
- package/dist/utils/ip.d.mts +54 -0
- package/dist/utils/ip.mjs +118 -0
- package/dist/utils/url.d.mts +20 -0
- package/dist/utils/url.mjs +32 -0
- package/package.json +1 -1
- package/src/types/context.ts +5 -1
- package/src/types/init-options.ts +19 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/ip.test.ts +243 -0
- package/src/utils/ip.ts +211 -0
- package/src/utils/url.ts +43 -0
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @better-auth/core@1.4.
|
|
2
|
+
> @better-auth/core@1.4.16 build /home/runner/work/better-auth/better-auth/packages/core
|
|
3
3
|
> tsdown
|
|
4
4
|
|
|
5
5
|
[34mℹ[39m tsdown [2mv0.17.2[22m powered by rolldown [2mv1.0.0-beta.53[22m
|
|
@@ -14,15 +14,16 @@
|
|
|
14
14
|
[34mℹ[39m [2mdist/[22m[1moauth2/index.mjs[22m [2m 0.87 kB[22m [2m│ gzip: 0.27 kB[22m
|
|
15
15
|
[34mℹ[39m [2mdist/[22m[1mcontext/index.mjs[22m [2m 0.79 kB[22m [2m│ gzip: 0.23 kB[22m
|
|
16
16
|
[34mℹ[39m [2mdist/[22m[1mdb/adapter/index.mjs[22m [2m 0.71 kB[22m [2m│ gzip: 0.22 kB[22m
|
|
17
|
+
[34mℹ[39m [2mdist/[22m[1mutils/index.mjs[22m [2m 0.51 kB[22m [2m│ gzip: 0.22 kB[22m
|
|
17
18
|
[34mℹ[39m [2mdist/[22m[1mdb/index.mjs[22m [2m 0.50 kB[22m [2m│ gzip: 0.18 kB[22m
|
|
18
19
|
[34mℹ[39m [2mdist/[22m[1menv/index.mjs[22m [2m 0.43 kB[22m [2m│ gzip: 0.21 kB[22m
|
|
19
|
-
[34mℹ[39m [2mdist/[22m[1mutils/index.mjs[22m [2m 0.33 kB[22m [2m│ gzip: 0.16 kB[22m
|
|
20
20
|
[34mℹ[39m [2mdist/[22m[1merror/index.mjs[22m [2m 0.33 kB[22m [2m│ gzip: 0.21 kB[22m
|
|
21
21
|
[34mℹ[39m [2mdist/[22m[1mindex.mjs[22m [2m 0.01 kB[22m [2m│ gzip: 0.03 kB[22m
|
|
22
22
|
[34mℹ[39m [2mdist/[22mdb/adapter/factory.mjs [2m30.11 kB[22m [2m│ gzip: 5.99 kB[22m
|
|
23
23
|
[34mℹ[39m [2mdist/[22mdb/get-tables.mjs [2m 6.89 kB[22m [2m│ gzip: 1.30 kB[22m
|
|
24
24
|
[34mℹ[39m [2mdist/[22msocial-providers/cognito.mjs [2m 5.93 kB[22m [2m│ gzip: 1.93 kB[22m
|
|
25
25
|
[34mℹ[39m [2mdist/[22msocial-providers/paypal.mjs [2m 5.03 kB[22m [2m│ gzip: 1.48 kB[22m
|
|
26
|
+
[34mℹ[39m [2mdist/[22mutils/ip.mjs [2m 3.81 kB[22m [2m│ gzip: 1.29 kB[22m
|
|
26
27
|
[34mℹ[39m [2mdist/[22msocial-providers/google.mjs [2m 3.79 kB[22m [2m│ gzip: 1.39 kB[22m
|
|
27
28
|
[34mℹ[39m [2mdist/[22moauth2/verify.mjs [2m 3.74 kB[22m [2m│ gzip: 1.32 kB[22m
|
|
28
29
|
[34mℹ[39m [2mdist/[22msocial-providers/apple.mjs [2m 3.71 kB[22m [2m│ gzip: 1.34 kB[22m
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
[34mℹ[39m [2mdist/[22mcontext/endpoint-context.mjs [2m 1.31 kB[22m [2m│ gzip: 0.53 kB[22m
|
|
72
73
|
[34mℹ[39m [2mdist/[22mdb/adapter/get-field-attributes.mjs [2m 1.26 kB[22m [2m│ gzip: 0.46 kB[22m
|
|
73
74
|
[34mℹ[39m [2mdist/[22mdb/adapter/utils.mjs [2m 1.15 kB[22m [2m│ gzip: 0.46 kB[22m
|
|
75
|
+
[34mℹ[39m [2mdist/[22mutils/url.mjs [2m 1.14 kB[22m [2m│ gzip: 0.51 kB[22m
|
|
74
76
|
[34mℹ[39m [2mdist/[22mdb/adapter/get-field-name.mjs [2m 1.07 kB[22m [2m│ gzip: 0.47 kB[22m
|
|
75
77
|
[34mℹ[39m [2mdist/[22moauth2/utils.mjs [2m 0.98 kB[22m [2m│ gzip: 0.50 kB[22m
|
|
76
78
|
[34mℹ[39m [2mdist/[22mcontext/global.mjs [2m 0.96 kB[22m [2m│ gzip: 0.47 kB[22m
|
|
@@ -94,12 +96,12 @@
|
|
|
94
96
|
[34mℹ[39m [2mdist/[22m[32m[1mdb/index.d.mts[22m[39m [2m 1.04 kB[22m [2m│ gzip: 0.34 kB[22m
|
|
95
97
|
[34mℹ[39m [2mdist/[22m[32m[1mcontext/index.d.mts[22m[39m [2m 0.92 kB[22m [2m│ gzip: 0.27 kB[22m
|
|
96
98
|
[34mℹ[39m [2mdist/[22m[32m[1menv/index.d.mts[22m[39m [2m 0.58 kB[22m [2m│ gzip: 0.27 kB[22m
|
|
97
|
-
[34mℹ[39m [2mdist/[22m[32m[1mutils/index.d.mts[22m[39m [2m 0.
|
|
99
|
+
[34mℹ[39m [2mdist/[22m[32m[1mutils/index.d.mts[22m[39m [2m 0.51 kB[22m [2m│ gzip: 0.22 kB[22m
|
|
98
100
|
[34mℹ[39m [2mdist/[22m[32m[1merror/index.d.mts[22m[39m [2m 0.27 kB[22m [2m│ gzip: 0.21 kB[22m
|
|
99
101
|
[34mℹ[39m [2mdist/[22m[32m[1masync_hooks/index.d.mts[22m[39m [2m 0.24 kB[22m [2m│ gzip: 0.16 kB[22m
|
|
100
102
|
[34mℹ[39m [2mdist/[22m[32m[1masync_hooks/pure.index.d.mts[22m[39m [2m 0.22 kB[22m [2m│ gzip: 0.16 kB[22m
|
|
101
|
-
[34mℹ[39m [2mdist/[22m[32mtypes/init-options.d.mts[39m [
|
|
102
|
-
[34mℹ[39m [2mdist/[22m[32mtypes/context.d.mts[39m [2m 9.
|
|
103
|
+
[34mℹ[39m [2mdist/[22m[32mtypes/init-options.d.mts[39m [2m39.01 kB[22m [2m│ gzip: 8.77 kB[22m
|
|
104
|
+
[34mℹ[39m [2mdist/[22m[32mtypes/context.d.mts[39m [2m 9.09 kB[22m [2m│ gzip: 2.66 kB[22m
|
|
103
105
|
[34mℹ[39m [2mdist/[22m[32msocial-providers/zoom.d.mts[39m [2m 6.71 kB[22m [2m│ gzip: 2.29 kB[22m
|
|
104
106
|
[34mℹ[39m [2mdist/[22m[32moauth2/oauth-provider.d.mts[39m [2m 5.92 kB[22m [2m│ gzip: 1.67 kB[22m
|
|
105
107
|
[34mℹ[39m [2mdist/[22m[32msocial-providers/microsoft-entra-id.d.mts[39m [2m 5.59 kB[22m [2m│ gzip: 1.96 kB[22m
|
|
@@ -136,6 +138,7 @@
|
|
|
136
138
|
[34mℹ[39m [2mdist/[22m[32msocial-providers/kick.d.mts[39m [2m 1.62 kB[22m [2m│ gzip: 0.59 kB[22m
|
|
137
139
|
[34mℹ[39m [2mdist/[22m[32msocial-providers/linkedin.d.mts[39m [2m 1.61 kB[22m [2m│ gzip: 0.60 kB[22m
|
|
138
140
|
[34mℹ[39m [2mdist/[22m[32msocial-providers/linear.d.mts[39m [2m 1.60 kB[22m [2m│ gzip: 0.59 kB[22m
|
|
141
|
+
[34mℹ[39m [2mdist/[22m[32mutils/ip.d.mts[39m [2m 1.58 kB[22m [2m│ gzip: 0.69 kB[22m
|
|
139
142
|
[34mℹ[39m [2mdist/[22m[32moauth2/validate-authorization-code.d.mts[39m [2m 1.57 kB[22m [2m│ gzip: 0.49 kB[22m
|
|
140
143
|
[34mℹ[39m [2mdist/[22m[32msocial-providers/notion.d.mts[39m [2m 1.56 kB[22m [2m│ gzip: 0.59 kB[22m
|
|
141
144
|
[34mℹ[39m [2mdist/[22m[32msocial-providers/reddit.d.mts[39m [2m 1.54 kB[22m [2m│ gzip: 0.57 kB[22m
|
|
@@ -153,6 +156,7 @@
|
|
|
153
156
|
[34mℹ[39m [2mdist/[22m[32moauth2/client-credentials-token.d.mts[39m [2m 0.91 kB[22m [2m│ gzip: 0.36 kB[22m
|
|
154
157
|
[34mℹ[39m [2mdist/[22m[32mcontext/transaction.d.mts[39m [2m 0.86 kB[22m [2m│ gzip: 0.38 kB[22m
|
|
155
158
|
[34mℹ[39m [2mdist/[22m[32mutils/error-codes.d.mts[39m [2m 0.84 kB[22m [2m│ gzip: 0.43 kB[22m
|
|
159
|
+
[34mℹ[39m [2mdist/[22m[32mutils/url.d.mts[39m [2m 0.84 kB[22m [2m│ gzip: 0.40 kB[22m
|
|
156
160
|
[34mℹ[39m [2mdist/[22m[32mdb/schema/session.d.mts[39m [2m 0.75 kB[22m [2m│ gzip: 0.40 kB[22m
|
|
157
161
|
[34mℹ[39m [2mdist/[22m[32mtypes/index.d.mts[39m [2m 0.74 kB[22m [2m│ gzip: 0.32 kB[22m
|
|
158
162
|
[34mℹ[39m [2mdist/[22m[32mdb/adapter/get-field-attributes.d.mts[39m [2m 0.72 kB[22m [2m│ gzip: 0.34 kB[22m
|
|
@@ -176,5 +180,5 @@
|
|
|
176
180
|
[34mℹ[39m [2mdist/[22m[32mutils/json.d.mts[39m [2m 0.13 kB[22m [2m│ gzip: 0.13 kB[22m
|
|
177
181
|
[34mℹ[39m [2mdist/[22m[32mutils/id.d.mts[39m [2m 0.12 kB[22m [2m│ gzip: 0.12 kB[22m
|
|
178
182
|
[34mℹ[39m [2mdist/[22m[32menv/color-depth.d.mts[39m [2m 0.12 kB[22m [2m│ gzip: 0.11 kB[22m
|
|
179
|
-
[34mℹ[39m
|
|
180
|
-
[32m✔[39m Build complete in [
|
|
183
|
+
[34mℹ[39m 173 files, total: 448.95 kB
|
|
184
|
+
[32m✔[39m Build complete in [32m6430ms[39m
|
package/dist/context/global.mjs
CHANGED
package/dist/types/context.d.mts
CHANGED
|
@@ -47,6 +47,7 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
|
|
|
47
47
|
deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>;
|
|
48
48
|
findOAuthUser(email: string, accountId: string, providerId: string): Promise<{
|
|
49
49
|
user: User;
|
|
50
|
+
linkedAccount: Account | null;
|
|
50
51
|
accounts: Account[];
|
|
51
52
|
} | null>;
|
|
52
53
|
findUserByEmail(email: string, options?: {
|
|
@@ -122,6 +122,25 @@ type BetterAuthAdvancedOptions = {
|
|
|
122
122
|
* ⚠︎ This is a security risk and it may expose your application to abuse
|
|
123
123
|
*/
|
|
124
124
|
disableIpTracking?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* IPv6 subnet prefix length for rate limiting.
|
|
127
|
+
*
|
|
128
|
+
* IPv6 addresses can be grouped by subnet to prevent attackers from
|
|
129
|
+
* bypassing rate limits by rotating through multiple addresses in
|
|
130
|
+
* their allocation.
|
|
131
|
+
*
|
|
132
|
+
* Common values:
|
|
133
|
+
* - 128 (default): Individual IPv6 address
|
|
134
|
+
* - 64: /64 subnet (typical home/business allocation)
|
|
135
|
+
* - 48: /48 subnet (larger network allocation)
|
|
136
|
+
* - 32: /32 subnet (ISP allocation)
|
|
137
|
+
*
|
|
138
|
+
* Note: This only affects IPv6 addresses. IPv4 addresses are always
|
|
139
|
+
* rate limited individually.
|
|
140
|
+
*
|
|
141
|
+
* @default 128 (individual address)
|
|
142
|
+
*/
|
|
143
|
+
ipv6Subnet?: 128 | 64 | 48 | 32 | undefined;
|
|
125
144
|
} | undefined;
|
|
126
145
|
/**
|
|
127
146
|
* Use secure cookies
|
package/dist/utils/index.d.mts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { deprecate } from "./deprecate.mjs";
|
|
2
2
|
import { defineErrorCodes } from "./error-codes.mjs";
|
|
3
3
|
import { generateId } from "./id.mjs";
|
|
4
|
+
import { createRateLimitKey, isValidIP, normalizeIP } from "./ip.mjs";
|
|
4
5
|
import { safeJSONParse } from "./json.mjs";
|
|
5
6
|
import { capitalizeFirstLetter } from "./string.mjs";
|
|
6
|
-
|
|
7
|
+
import { normalizePathname } from "./url.mjs";
|
|
8
|
+
export { capitalizeFirstLetter, createRateLimitKey, defineErrorCodes, deprecate, generateId, isValidIP, normalizeIP, normalizePathname, safeJSONParse };
|
package/dist/utils/index.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { deprecate } from "./deprecate.mjs";
|
|
2
2
|
import { defineErrorCodes } from "./error-codes.mjs";
|
|
3
3
|
import { generateId } from "./id.mjs";
|
|
4
|
+
import { createRateLimitKey, isValidIP, normalizeIP } from "./ip.mjs";
|
|
4
5
|
import { safeJSONParse } from "./json.mjs";
|
|
5
6
|
import { capitalizeFirstLetter } from "./string.mjs";
|
|
7
|
+
import { normalizePathname } from "./url.mjs";
|
|
6
8
|
|
|
7
|
-
export { capitalizeFirstLetter, defineErrorCodes, deprecate, generateId, safeJSONParse };
|
|
9
|
+
export { capitalizeFirstLetter, createRateLimitKey, defineErrorCodes, deprecate, generateId, isValidIP, normalizeIP, normalizePathname, safeJSONParse };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/utils/ip.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes an IP address for consistent rate limiting.
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Normalizes IPv6 to canonical lowercase form
|
|
7
|
+
* - Converts IPv4-mapped IPv6 to IPv4
|
|
8
|
+
* - Supports IPv6 subnet extraction
|
|
9
|
+
* - Handles all edge cases (::1, ::, etc.)
|
|
10
|
+
*/
|
|
11
|
+
interface NormalizeIPOptions {
|
|
12
|
+
/**
|
|
13
|
+
* For IPv6 addresses, extract the subnet prefix instead of full address.
|
|
14
|
+
* Common values: 32, 48, 64, 128 (default: 128 = full address)
|
|
15
|
+
*
|
|
16
|
+
* @default 128
|
|
17
|
+
*/
|
|
18
|
+
ipv6Subnet?: 128 | 64 | 48 | 32;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Checks if an IP is valid IPv4 or IPv6
|
|
22
|
+
*/
|
|
23
|
+
declare function isValidIP(ip: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
|
|
26
|
+
*
|
|
27
|
+
* @param ip - The IP address to normalize
|
|
28
|
+
* @param options - Normalization options
|
|
29
|
+
* @returns Normalized IP address
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* normalizeIP("2001:DB8::1")
|
|
33
|
+
* // -> "2001:0db8:0000:0000:0000:0000:0000:0001"
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* normalizeIP("::ffff:192.0.2.1")
|
|
37
|
+
* // -> "192.0.2.1" (converted to IPv4)
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
|
|
41
|
+
* // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
|
|
42
|
+
*/
|
|
43
|
+
declare function normalizeIP(ip: string, options?: NormalizeIPOptions): string;
|
|
44
|
+
/**
|
|
45
|
+
* Creates a rate limit key from IP and path
|
|
46
|
+
* Uses a separator to prevent collision attacks
|
|
47
|
+
*
|
|
48
|
+
* @param ip - The IP address (should be normalized)
|
|
49
|
+
* @param path - The request path
|
|
50
|
+
* @returns Rate limit key
|
|
51
|
+
*/
|
|
52
|
+
declare function createRateLimitKey(ip: string, path: string): string;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { createRateLimitKey, isValidIP, normalizeIP };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/utils/ip.ts
|
|
4
|
+
/**
|
|
5
|
+
* Checks if an IP is valid IPv4 or IPv6
|
|
6
|
+
*/
|
|
7
|
+
function isValidIP(ip) {
|
|
8
|
+
return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Checks if an IP is IPv6
|
|
12
|
+
*/
|
|
13
|
+
function isIPv6(ip) {
|
|
14
|
+
return z.ipv6().safeParse(ip).success;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Converts IPv4-mapped IPv6 address to IPv4
|
|
18
|
+
* e.g., "::ffff:192.0.2.1" -> "192.0.2.1"
|
|
19
|
+
*/
|
|
20
|
+
function extractIPv4FromMapped(ipv6) {
|
|
21
|
+
const lower = ipv6.toLowerCase();
|
|
22
|
+
if (lower.startsWith("::ffff:")) {
|
|
23
|
+
const ipv4Part = lower.substring(7);
|
|
24
|
+
if (z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
|
|
25
|
+
}
|
|
26
|
+
const parts = ipv6.split(":");
|
|
27
|
+
if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") {
|
|
28
|
+
const ipv4Part = parts[6];
|
|
29
|
+
if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) return ipv4Part;
|
|
30
|
+
}
|
|
31
|
+
if (lower.includes("::ffff:") || lower.includes(":ffff:")) {
|
|
32
|
+
const groups = expandIPv6(ipv6);
|
|
33
|
+
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]) return `${Number.parseInt(groups[6].substring(0, 2), 16)}.${Number.parseInt(groups[6].substring(2, 4), 16)}.${Number.parseInt(groups[7].substring(0, 2), 16)}.${Number.parseInt(groups[7].substring(2, 4), 16)}`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Expands a compressed IPv6 address to full form
|
|
39
|
+
* e.g., "2001:db8::1" -> ["2001", "0db8", "0000", "0000", "0000", "0000", "0000", "0001"]
|
|
40
|
+
*/
|
|
41
|
+
function expandIPv6(ipv6) {
|
|
42
|
+
if (ipv6.includes("::")) {
|
|
43
|
+
const sides = ipv6.split("::");
|
|
44
|
+
const left = sides[0] ? sides[0].split(":") : [];
|
|
45
|
+
const right = sides[1] ? sides[1].split(":") : [];
|
|
46
|
+
const missingGroups = 8 - left.length - right.length;
|
|
47
|
+
const zeros = Array(missingGroups).fill("0000");
|
|
48
|
+
const paddedLeft = left.map((g) => g.padStart(4, "0"));
|
|
49
|
+
const paddedRight = right.map((g) => g.padStart(4, "0"));
|
|
50
|
+
return [
|
|
51
|
+
...paddedLeft,
|
|
52
|
+
...zeros,
|
|
53
|
+
...paddedRight
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
return ipv6.split(":").map((g) => g.padStart(4, "0"));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Normalizes an IPv6 address to canonical form
|
|
60
|
+
* e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
|
|
61
|
+
*/
|
|
62
|
+
function normalizeIPv6(ipv6, subnetPrefix) {
|
|
63
|
+
const groups = expandIPv6(ipv6);
|
|
64
|
+
if (subnetPrefix && subnetPrefix < 128) {
|
|
65
|
+
let bitsRemaining = subnetPrefix;
|
|
66
|
+
return groups.map((group) => {
|
|
67
|
+
if (bitsRemaining <= 0) return "0000";
|
|
68
|
+
if (bitsRemaining >= 16) {
|
|
69
|
+
bitsRemaining -= 16;
|
|
70
|
+
return group;
|
|
71
|
+
}
|
|
72
|
+
const masked = Number.parseInt(group, 16) & (65535 << 16 - bitsRemaining & 65535);
|
|
73
|
+
bitsRemaining = 0;
|
|
74
|
+
return masked.toString(16).padStart(4, "0");
|
|
75
|
+
}).join(":").toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
return groups.join(":").toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
|
|
81
|
+
*
|
|
82
|
+
* @param ip - The IP address to normalize
|
|
83
|
+
* @param options - Normalization options
|
|
84
|
+
* @returns Normalized IP address
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* normalizeIP("2001:DB8::1")
|
|
88
|
+
* // -> "2001:0db8:0000:0000:0000:0000:0000:0001"
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* normalizeIP("::ffff:192.0.2.1")
|
|
92
|
+
* // -> "192.0.2.1" (converted to IPv4)
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
|
|
96
|
+
* // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
|
|
97
|
+
*/
|
|
98
|
+
function normalizeIP(ip, options = {}) {
|
|
99
|
+
if (z.ipv4().safeParse(ip).success) return ip.toLowerCase();
|
|
100
|
+
if (!isIPv6(ip)) return ip.toLowerCase();
|
|
101
|
+
const ipv4 = extractIPv4FromMapped(ip);
|
|
102
|
+
if (ipv4) return ipv4.toLowerCase();
|
|
103
|
+
return normalizeIPv6(ip, options.ipv6Subnet || 128);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Creates a rate limit key from IP and path
|
|
107
|
+
* Uses a separator to prevent collision attacks
|
|
108
|
+
*
|
|
109
|
+
* @param ip - The IP address (should be normalized)
|
|
110
|
+
* @param path - The request path
|
|
111
|
+
* @returns Rate limit key
|
|
112
|
+
*/
|
|
113
|
+
function createRateLimitKey(ip, path) {
|
|
114
|
+
return `${ip}|${path}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
//#endregion
|
|
118
|
+
export { createRateLimitKey, isValidIP, normalizeIP };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/utils/url.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes a request pathname by removing the basePath prefix and trailing slashes.
|
|
4
|
+
* This is useful for matching paths against configured path lists.
|
|
5
|
+
*
|
|
6
|
+
* @param requestUrl - The full request URL
|
|
7
|
+
* @param basePath - The base path of the auth API (e.g., "/api/auth")
|
|
8
|
+
* @returns The normalized path without basePath prefix or trailing slashes,
|
|
9
|
+
* or "/" if URL parsing fails
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth")
|
|
13
|
+
* // Returns: "/sso/saml2/callback/provider1"
|
|
14
|
+
*
|
|
15
|
+
* normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/")
|
|
16
|
+
* // Returns: "/sso/saml2/callback/provider1"
|
|
17
|
+
*/
|
|
18
|
+
declare function normalizePathname(requestUrl: string, basePath: string): string;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { normalizePathname };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/utils/url.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes a request pathname by removing the basePath prefix and trailing slashes.
|
|
4
|
+
* This is useful for matching paths against configured path lists.
|
|
5
|
+
*
|
|
6
|
+
* @param requestUrl - The full request URL
|
|
7
|
+
* @param basePath - The base path of the auth API (e.g., "/api/auth")
|
|
8
|
+
* @returns The normalized path without basePath prefix or trailing slashes,
|
|
9
|
+
* or "/" if URL parsing fails
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth")
|
|
13
|
+
* // Returns: "/sso/saml2/callback/provider1"
|
|
14
|
+
*
|
|
15
|
+
* normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/")
|
|
16
|
+
* // Returns: "/sso/saml2/callback/provider1"
|
|
17
|
+
*/
|
|
18
|
+
function normalizePathname(requestUrl, basePath) {
|
|
19
|
+
let pathname;
|
|
20
|
+
try {
|
|
21
|
+
pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
|
|
22
|
+
} catch {
|
|
23
|
+
return "/";
|
|
24
|
+
}
|
|
25
|
+
if (basePath === "/" || basePath === "") return pathname;
|
|
26
|
+
if (pathname === basePath) return "/";
|
|
27
|
+
if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
|
|
28
|
+
return pathname;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { normalizePathname };
|
package/package.json
CHANGED
package/src/types/context.ts
CHANGED
|
@@ -91,7 +91,11 @@ export interface InternalAdapter<
|
|
|
91
91
|
email: string,
|
|
92
92
|
accountId: string,
|
|
93
93
|
providerId: string,
|
|
94
|
-
): Promise<{
|
|
94
|
+
): Promise<{
|
|
95
|
+
user: User;
|
|
96
|
+
linkedAccount: Account | null;
|
|
97
|
+
accounts: Account[];
|
|
98
|
+
} | null>;
|
|
95
99
|
|
|
96
100
|
findUserByEmail(
|
|
97
101
|
email: string,
|
|
@@ -142,6 +142,25 @@ export type BetterAuthAdvancedOptions = {
|
|
|
142
142
|
* ⚠︎ This is a security risk and it may expose your application to abuse
|
|
143
143
|
*/
|
|
144
144
|
disableIpTracking?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* IPv6 subnet prefix length for rate limiting.
|
|
147
|
+
*
|
|
148
|
+
* IPv6 addresses can be grouped by subnet to prevent attackers from
|
|
149
|
+
* bypassing rate limits by rotating through multiple addresses in
|
|
150
|
+
* their allocation.
|
|
151
|
+
*
|
|
152
|
+
* Common values:
|
|
153
|
+
* - 128 (default): Individual IPv6 address
|
|
154
|
+
* - 64: /64 subnet (typical home/business allocation)
|
|
155
|
+
* - 48: /48 subnet (larger network allocation)
|
|
156
|
+
* - 32: /32 subnet (ISP allocation)
|
|
157
|
+
*
|
|
158
|
+
* Note: This only affects IPv6 addresses. IPv4 addresses are always
|
|
159
|
+
* rate limited individually.
|
|
160
|
+
*
|
|
161
|
+
* @default 128 (individual address)
|
|
162
|
+
*/
|
|
163
|
+
ipv6Subnet?: 128 | 64 | 48 | 32 | undefined;
|
|
145
164
|
}
|
|
146
165
|
| undefined;
|
|
147
166
|
/**
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { deprecate } from "./deprecate";
|
|
2
2
|
export { defineErrorCodes } from "./error-codes";
|
|
3
3
|
export { generateId } from "./id";
|
|
4
|
+
export { createRateLimitKey, isValidIP, normalizeIP } from "./ip";
|
|
4
5
|
export { safeJSONParse } from "./json";
|
|
5
6
|
export { capitalizeFirstLetter } from "./string";
|
|
7
|
+
export { normalizePathname } from "./url";
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createRateLimitKey, isValidIP, normalizeIP } from "./ip";
|
|
3
|
+
|
|
4
|
+
describe("IP Normalization", () => {
|
|
5
|
+
describe("isValidIP", () => {
|
|
6
|
+
it("should validate IPv4 addresses", () => {
|
|
7
|
+
expect(isValidIP("192.168.1.1")).toBe(true);
|
|
8
|
+
expect(isValidIP("127.0.0.1")).toBe(true);
|
|
9
|
+
expect(isValidIP("0.0.0.0")).toBe(true);
|
|
10
|
+
expect(isValidIP("255.255.255.255")).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should validate IPv6 addresses", () => {
|
|
14
|
+
expect(isValidIP("2001:db8::1")).toBe(true);
|
|
15
|
+
expect(isValidIP("::1")).toBe(true);
|
|
16
|
+
expect(isValidIP("::")).toBe(true);
|
|
17
|
+
expect(isValidIP("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should reject invalid IPs", () => {
|
|
21
|
+
expect(isValidIP("not-an-ip")).toBe(false);
|
|
22
|
+
expect(isValidIP("999.999.999.999")).toBe(false);
|
|
23
|
+
expect(isValidIP("gggg::1")).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("IPv4 Normalization", () => {
|
|
28
|
+
it("should return IPv4 addresses unchanged", () => {
|
|
29
|
+
expect(normalizeIP("192.168.1.1")).toBe("192.168.1.1");
|
|
30
|
+
expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1");
|
|
31
|
+
expect(normalizeIP("10.0.0.1")).toBe("10.0.0.1");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("IPv6 Normalization", () => {
|
|
36
|
+
it("should normalize compressed IPv6 to full form", () => {
|
|
37
|
+
expect(normalizeIP("2001:db8::1")).toBe(
|
|
38
|
+
"2001:0db8:0000:0000:0000:0000:0000:0001",
|
|
39
|
+
);
|
|
40
|
+
expect(normalizeIP("::1")).toBe(
|
|
41
|
+
"0000:0000:0000:0000:0000:0000:0000:0001",
|
|
42
|
+
);
|
|
43
|
+
expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should normalize uppercase to lowercase", () => {
|
|
47
|
+
expect(normalizeIP("2001:DB8::1")).toBe(
|
|
48
|
+
"2001:0db8:0000:0000:0000:0000:0000:0001",
|
|
49
|
+
);
|
|
50
|
+
expect(normalizeIP("2001:0DB8:ABCD:EF00::1")).toBe(
|
|
51
|
+
"2001:0db8:abcd:ef00:0000:0000:0000:0001",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should handle various IPv6 formats consistently", () => {
|
|
56
|
+
// All these represent the same address
|
|
57
|
+
const normalized = "2001:0db8:0000:0000:0000:0000:0000:0001";
|
|
58
|
+
expect(normalizeIP("2001:db8::1")).toBe(normalized);
|
|
59
|
+
expect(normalizeIP("2001:0db8:0:0:0:0:0:1")).toBe(normalized);
|
|
60
|
+
expect(normalizeIP("2001:db8:0::1")).toBe(normalized);
|
|
61
|
+
expect(normalizeIP("2001:0db8::0:0:0:1")).toBe(normalized);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should handle IPv6 with :: at different positions", () => {
|
|
65
|
+
expect(normalizeIP("2001:db8:85a3::8a2e:370:7334")).toBe(
|
|
66
|
+
"2001:0db8:85a3:0000:0000:8a2e:0370:7334",
|
|
67
|
+
);
|
|
68
|
+
expect(normalizeIP("::ffff:192.0.2.1")).not.toContain("::");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("IPv4-mapped IPv6 Conversion", () => {
|
|
73
|
+
it("should convert IPv4-mapped IPv6 to IPv4", () => {
|
|
74
|
+
expect(normalizeIP("::ffff:192.0.2.1")).toBe("192.0.2.1");
|
|
75
|
+
expect(normalizeIP("::ffff:127.0.0.1")).toBe("127.0.0.1");
|
|
76
|
+
expect(normalizeIP("::FFFF:10.0.0.1")).toBe("10.0.0.1");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should handle hex-encoded IPv4 in mapped addresses", () => {
|
|
80
|
+
// ::ffff:c000:0201 = ::ffff:192.0.2.1 = 192.0.2.1
|
|
81
|
+
expect(normalizeIP("::ffff:c000:0201")).toBe("192.0.2.1");
|
|
82
|
+
// ::ffff:7f00:0001 = ::ffff:127.0.0.1 = 127.0.0.1
|
|
83
|
+
expect(normalizeIP("::ffff:7f00:0001")).toBe("127.0.0.1");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should handle full form IPv4-mapped IPv6", () => {
|
|
87
|
+
expect(normalizeIP("0:0:0:0:0:ffff:192.0.2.1")).toBe("192.0.2.1");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("IPv6 Subnet Support", () => {
|
|
92
|
+
it("should extract /64 subnet", () => {
|
|
93
|
+
/* cspell:disable-next-line */
|
|
94
|
+
const ip1 = normalizeIP("2001:db8:0:0:1234:5678:90ab:cdef", {
|
|
95
|
+
ipv6Subnet: 64,
|
|
96
|
+
});
|
|
97
|
+
const ip2 = normalizeIP("2001:db8:0:0:ffff:ffff:ffff:ffff", {
|
|
98
|
+
ipv6Subnet: 64,
|
|
99
|
+
});
|
|
100
|
+
// Both should have same /64 prefix
|
|
101
|
+
expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
|
|
102
|
+
expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
|
|
103
|
+
expect(ip1).toBe(ip2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should extract /48 subnet", () => {
|
|
107
|
+
/* cspell:disable-next-line */
|
|
108
|
+
const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", {
|
|
109
|
+
ipv6Subnet: 48,
|
|
110
|
+
});
|
|
111
|
+
const ip2 = normalizeIP("2001:db8:1234:ffff:ffff:ffff:ffff:ffff", {
|
|
112
|
+
ipv6Subnet: 48,
|
|
113
|
+
});
|
|
114
|
+
// Both should have same /48 prefix
|
|
115
|
+
expect(ip1).toBe("2001:0db8:1234:0000:0000:0000:0000:0000");
|
|
116
|
+
expect(ip2).toBe("2001:0db8:1234:0000:0000:0000:0000:0000");
|
|
117
|
+
expect(ip1).toBe(ip2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("should extract /32 subnet", () => {
|
|
121
|
+
/* cspell:disable-next-line */
|
|
122
|
+
const ip1 = normalizeIP("2001:db8:1234:5678:90ab:cdef:1234:5678", {
|
|
123
|
+
ipv6Subnet: 32,
|
|
124
|
+
});
|
|
125
|
+
const ip2 = normalizeIP("2001:db8:ffff:ffff:ffff:ffff:ffff:ffff", {
|
|
126
|
+
ipv6Subnet: 32,
|
|
127
|
+
});
|
|
128
|
+
// Both should have same /32 prefix
|
|
129
|
+
expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
|
|
130
|
+
expect(ip2).toBe("2001:0db8:0000:0000:0000:0000:0000:0000");
|
|
131
|
+
expect(ip1).toBe(ip2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should handle /128 (full address) by default", () => {
|
|
135
|
+
const ip1 = normalizeIP("2001:db8::1");
|
|
136
|
+
const ip2 = normalizeIP("2001:db8::1", { ipv6Subnet: 128 });
|
|
137
|
+
expect(ip1).toBe(ip2);
|
|
138
|
+
expect(ip1).toBe("2001:0db8:0000:0000:0000:0000:0000:0001");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("should not affect IPv4 addresses when ipv6Subnet is set", () => {
|
|
142
|
+
expect(normalizeIP("192.168.1.1", { ipv6Subnet: 64 })).toBe(
|
|
143
|
+
"192.168.1.1",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("Rate Limit Key Creation", () => {
|
|
149
|
+
it("should create keys with separator", () => {
|
|
150
|
+
expect(createRateLimitKey("192.168.1.1", "/sign-in")).toBe(
|
|
151
|
+
"192.168.1.1|/sign-in",
|
|
152
|
+
);
|
|
153
|
+
expect(createRateLimitKey("2001:db8::1", "/api/auth")).toBe(
|
|
154
|
+
"2001:db8::1|/api/auth",
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("should prevent collision attacks", () => {
|
|
159
|
+
// Without separator: "192.0.2.1" + "/sign-in" = "192.0.2.1/sign-in"
|
|
160
|
+
// "192.0.2" + ".1/sign-in" = "192.0.2.1/sign-in"
|
|
161
|
+
// With separator: they're different
|
|
162
|
+
const key1 = createRateLimitKey("192.0.2.1", "/sign-in");
|
|
163
|
+
const key2 = createRateLimitKey("192.0.2", ".1/sign-in");
|
|
164
|
+
expect(key1).not.toBe(key2);
|
|
165
|
+
expect(key1).toBe("192.0.2.1|/sign-in");
|
|
166
|
+
expect(key2).toBe("192.0.2|.1/sign-in");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("Security: Bypass Prevention", () => {
|
|
171
|
+
it("should prevent IPv6 representation bypass", () => {
|
|
172
|
+
// Attacker tries different representations of same address
|
|
173
|
+
const representations = [
|
|
174
|
+
"2001:db8::1",
|
|
175
|
+
"2001:DB8::1",
|
|
176
|
+
"2001:0db8::1",
|
|
177
|
+
"2001:db8:0::1",
|
|
178
|
+
"2001:0db8:0:0:0:0:0:1",
|
|
179
|
+
"2001:db8::0:1",
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
const normalized = representations.map((ip) => normalizeIP(ip));
|
|
183
|
+
// All should normalize to the same value
|
|
184
|
+
const uniqueValues = new Set(normalized);
|
|
185
|
+
expect(uniqueValues.size).toBe(1);
|
|
186
|
+
expect(normalized[0]).toBe("2001:0db8:0000:0000:0000:0000:0000:0001");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should prevent IPv4-mapped bypass", () => {
|
|
190
|
+
// Attacker switches between IPv4 and IPv4-mapped IPv6
|
|
191
|
+
const ip1 = normalizeIP("192.0.2.1");
|
|
192
|
+
const ip2 = normalizeIP("::ffff:192.0.2.1");
|
|
193
|
+
const ip3 = normalizeIP("::FFFF:192.0.2.1");
|
|
194
|
+
const ip4 = normalizeIP("::ffff:c000:0201");
|
|
195
|
+
|
|
196
|
+
// All should normalize to the same IPv4
|
|
197
|
+
expect(ip1).toBe("192.0.2.1");
|
|
198
|
+
expect(ip2).toBe("192.0.2.1");
|
|
199
|
+
expect(ip3).toBe("192.0.2.1");
|
|
200
|
+
expect(ip4).toBe("192.0.2.1");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("should group IPv6 subnet attacks", () => {
|
|
204
|
+
// Attacker rotates through addresses in their /64 allocation
|
|
205
|
+
const attackIPs = [
|
|
206
|
+
"2001:db8:abcd:1234:0000:0000:0000:0001",
|
|
207
|
+
"2001:db8:abcd:1234:1111:2222:3333:4444",
|
|
208
|
+
"2001:db8:abcd:1234:ffff:ffff:ffff:ffff",
|
|
209
|
+
"2001:db8:abcd:1234:aaaa:bbbb:cccc:dddd",
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const normalized = attackIPs.map((ip) =>
|
|
213
|
+
normalizeIP(ip, { ipv6Subnet: 64 }),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// All should map to same /64 subnet
|
|
217
|
+
const uniqueValues = new Set(normalized);
|
|
218
|
+
expect(uniqueValues.size).toBe(1);
|
|
219
|
+
expect(normalized[0]).toBe("2001:0db8:abcd:1234:0000:0000:0000:0000");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("Edge Cases", () => {
|
|
224
|
+
it("should handle localhost addresses", () => {
|
|
225
|
+
expect(normalizeIP("127.0.0.1")).toBe("127.0.0.1");
|
|
226
|
+
expect(normalizeIP("::1")).toBe(
|
|
227
|
+
"0000:0000:0000:0000:0000:0000:0000:0001",
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should handle all-zeros address", () => {
|
|
232
|
+
expect(normalizeIP("0.0.0.0")).toBe("0.0.0.0");
|
|
233
|
+
expect(normalizeIP("::")).toBe("0000:0000:0000:0000:0000:0000:0000:0000");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("should handle link-local addresses", () => {
|
|
237
|
+
expect(normalizeIP("169.254.0.1")).toBe("169.254.0.1");
|
|
238
|
+
expect(normalizeIP("fe80::1")).toBe(
|
|
239
|
+
"fe80:0000:0000:0000:0000:0000:0000:0001",
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
package/src/utils/ip.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalizes an IP address for consistent rate limiting.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Normalizes IPv6 to canonical lowercase form
|
|
8
|
+
* - Converts IPv4-mapped IPv6 to IPv4
|
|
9
|
+
* - Supports IPv6 subnet extraction
|
|
10
|
+
* - Handles all edge cases (::1, ::, etc.)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface NormalizeIPOptions {
|
|
14
|
+
/**
|
|
15
|
+
* For IPv6 addresses, extract the subnet prefix instead of full address.
|
|
16
|
+
* Common values: 32, 48, 64, 128 (default: 128 = full address)
|
|
17
|
+
*
|
|
18
|
+
* @default 128
|
|
19
|
+
*/
|
|
20
|
+
ipv6Subnet?: 128 | 64 | 48 | 32;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Checks if an IP is valid IPv4 or IPv6
|
|
25
|
+
*/
|
|
26
|
+
export function isValidIP(ip: string): boolean {
|
|
27
|
+
return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Checks if an IP is IPv6
|
|
32
|
+
*/
|
|
33
|
+
function isIPv6(ip: string): boolean {
|
|
34
|
+
return z.ipv6().safeParse(ip).success;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Converts IPv4-mapped IPv6 address to IPv4
|
|
39
|
+
* e.g., "::ffff:192.0.2.1" -> "192.0.2.1"
|
|
40
|
+
*/
|
|
41
|
+
function extractIPv4FromMapped(ipv6: string): string | null {
|
|
42
|
+
const lower = ipv6.toLowerCase();
|
|
43
|
+
|
|
44
|
+
// Handle ::ffff:192.0.2.1 format
|
|
45
|
+
if (lower.startsWith("::ffff:")) {
|
|
46
|
+
const ipv4Part = lower.substring(7);
|
|
47
|
+
// Check if it's a valid IPv4
|
|
48
|
+
if (z.ipv4().safeParse(ipv4Part).success) {
|
|
49
|
+
return ipv4Part;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Handle full form: 0:0:0:0:0:ffff:192.0.2.1
|
|
54
|
+
const parts = ipv6.split(":");
|
|
55
|
+
if (parts.length === 7 && parts[5]?.toLowerCase() === "ffff") {
|
|
56
|
+
const ipv4Part = parts[6];
|
|
57
|
+
if (ipv4Part && z.ipv4().safeParse(ipv4Part).success) {
|
|
58
|
+
return ipv4Part;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Handle hex-encoded IPv4 in mapped address
|
|
63
|
+
// e.g., ::ffff:c000:0201 -> 192.0.2.1
|
|
64
|
+
if (lower.includes("::ffff:") || lower.includes(":ffff:")) {
|
|
65
|
+
const groups = expandIPv6(ipv6);
|
|
66
|
+
if (
|
|
67
|
+
groups.length === 8 &&
|
|
68
|
+
groups[0] === "0000" &&
|
|
69
|
+
groups[1] === "0000" &&
|
|
70
|
+
groups[2] === "0000" &&
|
|
71
|
+
groups[3] === "0000" &&
|
|
72
|
+
groups[4] === "0000" &&
|
|
73
|
+
groups[5] === "ffff" &&
|
|
74
|
+
groups[6] &&
|
|
75
|
+
groups[7]
|
|
76
|
+
) {
|
|
77
|
+
// Convert last two groups to IPv4
|
|
78
|
+
const byte1 = Number.parseInt(groups[6].substring(0, 2), 16);
|
|
79
|
+
const byte2 = Number.parseInt(groups[6].substring(2, 4), 16);
|
|
80
|
+
const byte3 = Number.parseInt(groups[7].substring(0, 2), 16);
|
|
81
|
+
const byte4 = Number.parseInt(groups[7].substring(2, 4), 16);
|
|
82
|
+
return `${byte1}.${byte2}.${byte3}.${byte4}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Expands a compressed IPv6 address to full form
|
|
91
|
+
* e.g., "2001:db8::1" -> ["2001", "0db8", "0000", "0000", "0000", "0000", "0000", "0001"]
|
|
92
|
+
*/
|
|
93
|
+
function expandIPv6(ipv6: string): string[] {
|
|
94
|
+
// Handle :: notation (zero compression)
|
|
95
|
+
if (ipv6.includes("::")) {
|
|
96
|
+
const sides = ipv6.split("::");
|
|
97
|
+
const left = sides[0] ? sides[0].split(":") : [];
|
|
98
|
+
const right = sides[1] ? sides[1].split(":") : [];
|
|
99
|
+
|
|
100
|
+
// Calculate missing groups
|
|
101
|
+
const totalGroups = 8;
|
|
102
|
+
const missingGroups = totalGroups - left.length - right.length;
|
|
103
|
+
const zeros = Array(missingGroups).fill("0000");
|
|
104
|
+
|
|
105
|
+
// Pad existing groups to 4 digits
|
|
106
|
+
const paddedLeft = left.map((g) => g.padStart(4, "0"));
|
|
107
|
+
const paddedRight = right.map((g) => g.padStart(4, "0"));
|
|
108
|
+
|
|
109
|
+
return [...paddedLeft, ...zeros, ...paddedRight];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// No compression, just pad each group
|
|
113
|
+
return ipv6.split(":").map((g) => g.padStart(4, "0"));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Normalizes an IPv6 address to canonical form
|
|
118
|
+
* e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
|
|
119
|
+
*/
|
|
120
|
+
function normalizeIPv6(
|
|
121
|
+
ipv6: string,
|
|
122
|
+
subnetPrefix?: 128 | 32 | 48 | 64,
|
|
123
|
+
): string {
|
|
124
|
+
const groups = expandIPv6(ipv6);
|
|
125
|
+
|
|
126
|
+
if (subnetPrefix && subnetPrefix < 128) {
|
|
127
|
+
// Apply subnet mask
|
|
128
|
+
const prefix = subnetPrefix;
|
|
129
|
+
let bitsRemaining: number = prefix;
|
|
130
|
+
|
|
131
|
+
const maskedGroups = groups.map((group) => {
|
|
132
|
+
if (bitsRemaining <= 0) {
|
|
133
|
+
return "0000";
|
|
134
|
+
}
|
|
135
|
+
if (bitsRemaining >= 16) {
|
|
136
|
+
bitsRemaining -= 16;
|
|
137
|
+
return group;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Partial mask for this group
|
|
141
|
+
const value = Number.parseInt(group, 16);
|
|
142
|
+
const mask = (0xffff << (16 - bitsRemaining)) & 0xffff;
|
|
143
|
+
const masked = value & mask;
|
|
144
|
+
bitsRemaining = 0;
|
|
145
|
+
return masked.toString(16).padStart(4, "0");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return maskedGroups.join(":").toLowerCase();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return groups.join(":").toLowerCase();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Normalizes an IP address (IPv4 or IPv6) for consistent rate limiting.
|
|
156
|
+
*
|
|
157
|
+
* @param ip - The IP address to normalize
|
|
158
|
+
* @param options - Normalization options
|
|
159
|
+
* @returns Normalized IP address
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* normalizeIP("2001:DB8::1")
|
|
163
|
+
* // -> "2001:0db8:0000:0000:0000:0000:0000:0001"
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* normalizeIP("::ffff:192.0.2.1")
|
|
167
|
+
* // -> "192.0.2.1" (converted to IPv4)
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* normalizeIP("2001:db8::1", { ipv6Subnet: 64 })
|
|
171
|
+
* // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
|
|
172
|
+
*/
|
|
173
|
+
export function normalizeIP(
|
|
174
|
+
ip: string,
|
|
175
|
+
options: NormalizeIPOptions = {},
|
|
176
|
+
): string {
|
|
177
|
+
// IPv4 addresses are already normalized
|
|
178
|
+
if (z.ipv4().safeParse(ip).success) {
|
|
179
|
+
return ip.toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check if it's IPv6
|
|
183
|
+
if (!isIPv6(ip)) {
|
|
184
|
+
// Return as-is if not valid (shouldn't happen due to prior validation)
|
|
185
|
+
return ip.toLowerCase();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check for IPv4-mapped IPv6
|
|
189
|
+
const ipv4 = extractIPv4FromMapped(ip);
|
|
190
|
+
if (ipv4) {
|
|
191
|
+
return ipv4.toLowerCase();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Normalize IPv6
|
|
195
|
+
const subnetPrefix = options.ipv6Subnet || 128;
|
|
196
|
+
return normalizeIPv6(ip, subnetPrefix);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Creates a rate limit key from IP and path
|
|
201
|
+
* Uses a separator to prevent collision attacks
|
|
202
|
+
*
|
|
203
|
+
* @param ip - The IP address (should be normalized)
|
|
204
|
+
* @param path - The request path
|
|
205
|
+
* @returns Rate limit key
|
|
206
|
+
*/
|
|
207
|
+
export function createRateLimitKey(ip: string, path: string): string {
|
|
208
|
+
// Use | as separator to prevent collision attacks
|
|
209
|
+
// e.g., "192.0.2.1" + "/sign-in" vs "192.0.2" + ".1/sign-in"
|
|
210
|
+
return `${ip}|${path}`;
|
|
211
|
+
}
|
package/src/utils/url.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalizes a request pathname by removing the basePath prefix and trailing slashes.
|
|
3
|
+
* This is useful for matching paths against configured path lists.
|
|
4
|
+
*
|
|
5
|
+
* @param requestUrl - The full request URL
|
|
6
|
+
* @param basePath - The base path of the auth API (e.g., "/api/auth")
|
|
7
|
+
* @returns The normalized path without basePath prefix or trailing slashes,
|
|
8
|
+
* or "/" if URL parsing fails
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* normalizePathname("http://localhost:3000/api/auth/sso/saml2/callback/provider1", "/api/auth")
|
|
12
|
+
* // Returns: "/sso/saml2/callback/provider1"
|
|
13
|
+
*
|
|
14
|
+
* normalizePathname("http://localhost:3000/sso/saml2/callback/provider1/", "/")
|
|
15
|
+
* // Returns: "/sso/saml2/callback/provider1"
|
|
16
|
+
*/
|
|
17
|
+
export function normalizePathname(
|
|
18
|
+
requestUrl: string,
|
|
19
|
+
basePath: string,
|
|
20
|
+
): string {
|
|
21
|
+
let pathname: string;
|
|
22
|
+
try {
|
|
23
|
+
pathname = new URL(requestUrl).pathname.replace(/\/+$/, "") || "/";
|
|
24
|
+
} catch {
|
|
25
|
+
return "/";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (basePath === "/" || basePath === "") {
|
|
29
|
+
return pathname;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Check for exact match or proper path boundary (basePath followed by "/" or end)
|
|
33
|
+
// This prevents "/api/auth" from matching "/api/authevil/..."
|
|
34
|
+
if (pathname === basePath) {
|
|
35
|
+
return "/";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (pathname.startsWith(basePath + "/")) {
|
|
39
|
+
return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return pathname;
|
|
43
|
+
}
|