@extend-therapy/elysia-db-ratelimiter 0.0.6 → 0.0.8
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/CHANGELOG.md +23 -0
- package/README.md +52 -1
- package/dist/index.js +1 -0
- package/dist/src/dbRateLimitEncrypt.js +97 -0
- package/dist/src/dbRateLimitHandler.js +120 -21
- package/dist/src/defaults.js +1 -0
- package/dist/src/encryption.test.js +78 -0
- package/dist/src/helpers/redisStore.js +2 -2
- package/dist/{index.d.ts → types/index.d.ts} +1 -0
- package/dist/types/src/dbRateLimitEncrypt.d.ts +28 -0
- package/dist/types/src/encryption.test.d.ts +1 -0
- package/dist/{src → types/src}/helpers/redisStore.d.ts +1 -1
- package/dist/{src → types/src}/types.d.ts +21 -4
- package/package.json +6 -6
- package/AGENTS.md +0 -51
- /package/dist/{src → types/src}/dbRateLimitHandler.d.ts +0 -0
- /package/dist/{src → types/src}/dbRateLimiter.d.ts +0 -0
- /package/dist/{src → types/src}/dbRateLimiter.test.d.ts +0 -0
- /package/dist/{src → types/src}/defaults.d.ts +0 -0
- /package/dist/{src → types/src}/helpers/sqliteStore.d.ts +0 -0
- /package/dist/{src → types/src}/storeRegistry.d.ts +0 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog for @extend-therapy/elysia-db-ratelimiter
|
|
2
|
+
|
|
3
|
+
## 0.0.8
|
|
4
|
+
|
|
5
|
+
- simplify package.json
|
|
6
|
+
- add more tests
|
|
7
|
+
- add encryption to cookie
|
|
8
|
+
- make encryption or hashing optional and called cookieObfuscation strategy
|
|
9
|
+
- add alwaysCheckCookieValue option to check if the cookie value matches the IP address (set to true for `none` strategy)
|
|
10
|
+
- add cookie and ratelimit key transfer on IP change
|
|
11
|
+
- add cookie and ratelimit key transfer on invalid cookie value (if cookie is invalid, we generate a new key and transfer the cookie and ratelimit key to the new key)
|
|
12
|
+
|
|
13
|
+
## 0.0.7
|
|
14
|
+
|
|
15
|
+
- add declarationDir to tsconfig.json and types to package.json
|
|
16
|
+
|
|
17
|
+
## 0.0.6
|
|
18
|
+
|
|
19
|
+
- return false if failOpen is false and we can't get an IP
|
|
20
|
+
|
|
21
|
+
## 0.0.5
|
|
22
|
+
|
|
23
|
+
- don't return true on failure to get IP
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ const app = new Elysia()
|
|
|
37
37
|
|
|
38
38
|
### Using Redis
|
|
39
39
|
|
|
40
|
-
Inject your Redis client directly into the options.
|
|
40
|
+
Inject your Redis client directly into the options. Or use a global redis client for all instances of dbRateLimiter.
|
|
41
41
|
|
|
42
42
|
```typescript
|
|
43
43
|
import { Elysia } from 'elysia';
|
|
@@ -75,6 +75,57 @@ const app = new Elysia()
|
|
|
75
75
|
| `message` | `string` | `'Too many requests'` | Response body on limit exceed. |
|
|
76
76
|
| `seed` | `string` | `undefined` | Optional seed for ID generation. |
|
|
77
77
|
| `loggerOptions` | `LoggerOptions` | `undefined` | Custom Pino logger configuration. |
|
|
78
|
+
| `cookieObfuscation` | `'aes-gcm' \| 'hash' \| 'none'` | `'aes-gcm'` | Cookie value obfuscation strategy. |
|
|
79
|
+
| `alwaysCheckCookieValue` | `boolean` | `false` (except `none`) | Verifies cookie matches IP. Transfers count if mismatch. |
|
|
80
|
+
|
|
81
|
+
## IP Privacy & Cookie Obfuscation
|
|
82
|
+
|
|
83
|
+
The plugin uses a cookie-based identification system to track rate limits. To protect user privacy, the `rateLimitCookie` value is obfuscated using one of three strategies:
|
|
84
|
+
|
|
85
|
+
### Obfuscation Strategies
|
|
86
|
+
|
|
87
|
+
| Strategy | Description | Use Case |
|
|
88
|
+
| :--- | :--- | :--- |
|
|
89
|
+
| `aes-gcm` (Default) | AES-256-GCM encryption. Reversible and most secure. | Production (recommended) |
|
|
90
|
+
| `hash` | SHA-256 hashing. One-way function, cannot retrieve original value. | When you don't need to recover the original IP |
|
|
91
|
+
| `none` | No obfuscation. Plaintext storage. | Development/testing only |
|
|
92
|
+
|
|
93
|
+
### Configuration of Cookie Obfuscation
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
app.use(dbRateLimiter({
|
|
97
|
+
cookieObfuscation: 'hash', // Use hashing instead of encryption
|
|
98
|
+
limit: 100,
|
|
99
|
+
window: 60 * 1000
|
|
100
|
+
}));
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Cookie Verification (`alwaysCheckCookieValue`)
|
|
104
|
+
|
|
105
|
+
When enabled, the plugin verifies that the cookie value matches the current IP address. This is especially important for the `none` strategy (where it defaults to `true`) to prevent users from tampering with their cookies since it is easy to know the value and meaning of their cookie.
|
|
106
|
+
|
|
107
|
+
**Behavior when cookie doesn't match IP (and `alwaysCheckCookieValue` is true):**
|
|
108
|
+
|
|
109
|
+
1. The rate limit count is transferred from the old cookie to a new one
|
|
110
|
+
2. A new cookie is set with the current IP's processed value
|
|
111
|
+
3. The user continues with their existing count (not reset to zero)
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
app.use(dbRateLimiter({
|
|
115
|
+
cookieObfuscation: 'none',
|
|
116
|
+
alwaysCheckCookieValue: true, // Defaults to true for 'none' strategy
|
|
117
|
+
limit: 100,
|
|
118
|
+
window: 60 * 1000
|
|
119
|
+
}));
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Environment Variables
|
|
123
|
+
|
|
124
|
+
When using `hash` strategy, you should set environment variable to ensure cookies remain valid across server restarts:
|
|
125
|
+
|
|
126
|
+
- **`DB_RATE_LIMIT_HASH_PADDING`** (for `hash`): Set to any string value used as padding before hashing. If not provided, a random 16-byte to 32-byte padding is generated and cookies will be invalidated on restart.
|
|
127
|
+
|
|
128
|
+
**Security Note**: If this environment variable is not set, the plugin generates random transient values. This ensures security but means all existing rate limit cookies will be invalidated whenever the server restarts.
|
|
78
129
|
|
|
79
130
|
## Identification Patterns (`pattern`)
|
|
80
131
|
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { CryptoHasher } from "bun";
|
|
2
|
+
// No top-level guard here to allow for lazy-loaded fallback key if env is missing
|
|
3
|
+
// Lazy load the hash padding
|
|
4
|
+
let _hashPadding = null;
|
|
5
|
+
const getHashPadding = () => {
|
|
6
|
+
if (!_hashPadding) {
|
|
7
|
+
const envPadding = Bun.env.DB_RATE_LIMIT_HASH_PADDING;
|
|
8
|
+
if (envPadding) {
|
|
9
|
+
_hashPadding = envPadding;
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
// Generate a random padding if not provided
|
|
13
|
+
console.info("INFO: DB_RATE_LIMIT_HASH_PADDING environment variable is not set. Using transient random padding. Hashed cookies will be invalidated on server restart.");
|
|
14
|
+
const randomSize = Math.floor(Math.random() * (32 - 16)) + 16;
|
|
15
|
+
_hashPadding = Buffer.from(crypto.getRandomValues(new Uint8Array(randomSize))).toString("base64");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return _hashPadding;
|
|
19
|
+
};
|
|
20
|
+
export const resetKey = () => {
|
|
21
|
+
_hashPadding = null;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Hash a value using CryptoHasher("sha512-256") with padding
|
|
25
|
+
* This is a one-way function - the original value cannot be retrieved
|
|
26
|
+
*/
|
|
27
|
+
export const dbRateLimitHash = async (value) => {
|
|
28
|
+
const padding = getHashPadding();
|
|
29
|
+
// Prepend padding to the value before hashing
|
|
30
|
+
const hasher = new CryptoHasher("sha512-256");
|
|
31
|
+
hasher.update(padding + value);
|
|
32
|
+
return hasher.digest("base64");
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Process a cookie value based on the obfuscation strategy
|
|
36
|
+
* @param value - The value to process
|
|
37
|
+
* @param strategy - The obfuscation strategy to use
|
|
38
|
+
* @returns The processed value (always base64 encoded)
|
|
39
|
+
*/
|
|
40
|
+
export const processCookieValue = async (value, strategy) => {
|
|
41
|
+
switch (strategy) {
|
|
42
|
+
case "none":
|
|
43
|
+
return Buffer.from(value).toString("base64");
|
|
44
|
+
case "hash":
|
|
45
|
+
default: {
|
|
46
|
+
return dbRateLimitHash(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Validate that a value is valid base64 without extracting/decoding it
|
|
52
|
+
* @param value - The value to validate
|
|
53
|
+
* @returns Whether the value is valid base64
|
|
54
|
+
*/
|
|
55
|
+
export const isValidBase64 = (value) => {
|
|
56
|
+
try {
|
|
57
|
+
// Check if it's valid base64 by attempting to create a buffer
|
|
58
|
+
// This will throw if the string is not valid base64
|
|
59
|
+
const buffer = Buffer.from(value, "base64");
|
|
60
|
+
// Verify round-trip to ensure it's actually valid base64
|
|
61
|
+
return buffer.toString("base64") === value;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Verify that a cookie value matches the provided IP address
|
|
69
|
+
* @param cookieValue - The value from the cookie (base64 encoded)
|
|
70
|
+
* @param ip - The IP address to verify against
|
|
71
|
+
* @param strategy - The obfuscation strategy used
|
|
72
|
+
* @returns Whether the cookie value is valid for this IP
|
|
73
|
+
*/
|
|
74
|
+
export const verifyCookieValue = async (cookieValue, ip, strategy) => {
|
|
75
|
+
switch (strategy) {
|
|
76
|
+
case "none": {
|
|
77
|
+
try {
|
|
78
|
+
const decoded = Buffer.from(cookieValue, "base64").toString("utf8");
|
|
79
|
+
return decoded === ip;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
case "hash":
|
|
86
|
+
default: {
|
|
87
|
+
try {
|
|
88
|
+
const expectedHash = await dbRateLimitHash(ip);
|
|
89
|
+
return cookieValue === expectedHash;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Verification failed, cookie is invalid
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { InternalServerError } from "elysia";
|
|
2
2
|
import { getIP } from "elysia-ip";
|
|
3
|
+
import { isValidBase64, processCookieValue, verifyCookieValue } from "./dbRateLimitEncrypt";
|
|
3
4
|
export const dbRateLimitHandler = (options) => {
|
|
4
5
|
return async ({ cookie, log, path, request, set, query, ..._rest }) => {
|
|
6
|
+
const cookieObfuscation = options.cookieObfuscation || "hash";
|
|
7
|
+
// Default to true for 'none' strategy, false for others if alwaysCheckCookieValue is not explicitly set
|
|
8
|
+
const alwaysCheckCookieValue = options.alwaysCheckCookieValue ?? cookieObfuscation === "none";
|
|
5
9
|
let currentLimit = options.limit;
|
|
6
10
|
let currentWindow = options.window;
|
|
7
11
|
let currentPattern = options.pattern;
|
|
@@ -33,48 +37,143 @@ export const dbRateLimitHandler = (options) => {
|
|
|
33
37
|
currentPattern = pathConfig.pattern;
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
|
-
let
|
|
37
|
-
|
|
40
|
+
let cookieValue = cookie.rateLimitCookie?.value;
|
|
41
|
+
let oldCookieValue;
|
|
42
|
+
// Helper function to get IP lazily
|
|
43
|
+
const getClientIP = () => {
|
|
38
44
|
const ip = getIP(request.headers);
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
if (ip)
|
|
46
|
+
return ip;
|
|
47
|
+
// In test environment, use a default IP address to avoid log noise
|
|
48
|
+
const isTest = Bun.env.NODE_ENV === "test" || Bun.env.isTest === "true";
|
|
49
|
+
if (isTest) {
|
|
50
|
+
log.warn("Using default test IP for rate limiting");
|
|
51
|
+
return "127.0.0.1";
|
|
52
|
+
}
|
|
53
|
+
const errorMsg = "Could not get IP address for rate limiting";
|
|
54
|
+
log.error(errorMsg);
|
|
55
|
+
if (options.failOpen === false) {
|
|
56
|
+
throw new InternalServerError(errorMsg);
|
|
57
|
+
}
|
|
58
|
+
// create a random id for their cookie and we'll try to use that if it's there
|
|
59
|
+
return Bun.randomUUIDv7("base64url").replaceAll("-", "");
|
|
60
|
+
};
|
|
61
|
+
// The rate limit identifier is always the cookie value itself
|
|
62
|
+
// For 'hash': the cookie contains the hash, which IS the identifier
|
|
63
|
+
// For 'none': the cookie contains base64(IP), which IS the identifier
|
|
64
|
+
let rateLimitIdentifier;
|
|
65
|
+
if (!cookieValue) {
|
|
66
|
+
// No cookie exists - get IP and create new identifier
|
|
67
|
+
const ip = getClientIP();
|
|
68
|
+
rateLimitIdentifier = await processCookieValue(ip, cookieObfuscation);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
// Cookie exists - validate it and use it directly as the identifier
|
|
72
|
+
let isValid = false;
|
|
73
|
+
if (cookieObfuscation === "hash") {
|
|
74
|
+
// For hash, verify by hashing current IP and comparing (if alwaysCheckCookieValue)
|
|
75
|
+
// Otherwise trust the cookie value
|
|
76
|
+
if (alwaysCheckCookieValue) {
|
|
77
|
+
const ip = getClientIP();
|
|
78
|
+
isValid = await verifyCookieValue(cookieValue, ip, cookieObfuscation);
|
|
79
|
+
if (!isValid) {
|
|
80
|
+
oldCookieValue = cookieValue;
|
|
81
|
+
rateLimitIdentifier = await processCookieValue(ip, cookieObfuscation);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
rateLimitIdentifier = cookieValue;
|
|
85
|
+
}
|
|
45
86
|
}
|
|
46
87
|
else {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (options.failOpen === false) {
|
|
50
|
-
throw new InternalServerError(errorMsg);
|
|
51
|
-
}
|
|
52
|
-
return;
|
|
88
|
+
isValid = true;
|
|
89
|
+
rateLimitIdentifier = cookieValue;
|
|
53
90
|
}
|
|
54
91
|
}
|
|
55
92
|
else {
|
|
56
|
-
|
|
93
|
+
// For 'none' obfuscation
|
|
94
|
+
// First validate it's valid base64
|
|
95
|
+
isValid = isValidBase64(cookieValue);
|
|
96
|
+
if (isValid) {
|
|
97
|
+
if (alwaysCheckCookieValue) {
|
|
98
|
+
const ip = getClientIP();
|
|
99
|
+
const expectedValue = await processCookieValue(ip, cookieObfuscation);
|
|
100
|
+
if (cookieValue !== expectedValue) {
|
|
101
|
+
oldCookieValue = cookieValue;
|
|
102
|
+
rateLimitIdentifier = expectedValue;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
rateLimitIdentifier = cookieValue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
rateLimitIdentifier = cookieValue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
// Invalid base64 - treat as new cookie
|
|
114
|
+
oldCookieValue = cookieValue;
|
|
115
|
+
const ip = getClientIP();
|
|
116
|
+
rateLimitIdentifier = await processCookieValue(ip, cookieObfuscation);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Set the new cookie value (which is the rate limit identifier)
|
|
121
|
+
if (cookie.rateLimitCookie) {
|
|
122
|
+
cookie.rateLimitCookie.value = rateLimitIdentifier;
|
|
123
|
+
}
|
|
124
|
+
// If we have an old cookie value, transfer the rate limit to the new identifier
|
|
125
|
+
if (oldCookieValue && options.rateLimitStore) {
|
|
126
|
+
try {
|
|
127
|
+
const queryStr = new URLSearchParams(query).toString();
|
|
128
|
+
// Build the old rate limit ID (using old identifier)
|
|
129
|
+
const oldRateLimitId = `${oldCookieValue}:${path}${queryStr ? "?" + queryStr : ""}`;
|
|
130
|
+
const oldRateLimit = await options.rateLimitStore.get(oldRateLimitId);
|
|
131
|
+
if (oldRateLimit) {
|
|
132
|
+
// Build the new rate limit ID
|
|
133
|
+
let newRateLimitId;
|
|
134
|
+
switch (currentPattern) {
|
|
135
|
+
case "IP":
|
|
136
|
+
newRateLimitId = rateLimitIdentifier;
|
|
137
|
+
break;
|
|
138
|
+
case "Route":
|
|
139
|
+
newRateLimitId = path;
|
|
140
|
+
break;
|
|
141
|
+
case "IPRouteNoParams":
|
|
142
|
+
newRateLimitId = `${rateLimitIdentifier}:${path}`;
|
|
143
|
+
break;
|
|
144
|
+
case "IPFullRoute":
|
|
145
|
+
default:
|
|
146
|
+
newRateLimitId = `${rateLimitIdentifier}:${path}${queryStr ? "?" + queryStr : ""}`;
|
|
147
|
+
}
|
|
148
|
+
// Transfer the rate limit count and reset time
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
await options.rateLimitStore.set(newRateLimitId, {
|
|
151
|
+
count: oldRateLimit.count,
|
|
152
|
+
resetTime: oldRateLimit.resetTime,
|
|
153
|
+
}, Math.ceil((oldRateLimit.resetTime - now) / 1000));
|
|
154
|
+
log.debug(`Transferred rate limit from ${oldCookieValue} to new identifier`);
|
|
155
|
+
}
|
|
57
156
|
}
|
|
58
|
-
|
|
59
|
-
|
|
157
|
+
catch (e) {
|
|
158
|
+
log.warn(`Failed to transfer rate limit from old cookie: ${e}`);
|
|
60
159
|
}
|
|
61
160
|
}
|
|
161
|
+
// Build the final rate limit ID using the consistent identifier
|
|
62
162
|
let finalRateLimitId;
|
|
63
|
-
const safeBaseId = baseId;
|
|
64
163
|
switch (currentPattern) {
|
|
65
164
|
case "IP":
|
|
66
|
-
finalRateLimitId =
|
|
165
|
+
finalRateLimitId = rateLimitIdentifier;
|
|
67
166
|
break;
|
|
68
167
|
case "Route":
|
|
69
168
|
finalRateLimitId = path;
|
|
70
169
|
break;
|
|
71
170
|
case "IPRouteNoParams":
|
|
72
|
-
finalRateLimitId = `${
|
|
171
|
+
finalRateLimitId = `${rateLimitIdentifier}:${path}`;
|
|
73
172
|
break;
|
|
74
173
|
case "IPFullRoute":
|
|
75
174
|
default: {
|
|
76
175
|
const queryStr = new URLSearchParams(query).toString();
|
|
77
|
-
finalRateLimitId = `${
|
|
176
|
+
finalRateLimitId = `${rateLimitIdentifier}:${path}${queryStr ? "?" + queryStr : ""}`;
|
|
78
177
|
break;
|
|
79
178
|
}
|
|
80
179
|
}
|
package/dist/src/defaults.js
CHANGED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { dbRateLimitHash, isValidBase64, processCookieValue, resetKey, } from "./dbRateLimitEncrypt";
|
|
3
|
+
describe("dbRateLimitEncrypt", () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
resetKey();
|
|
6
|
+
});
|
|
7
|
+
describe("dbRateLimitHash", () => {
|
|
8
|
+
it("should produce consistent hashes for the same input", async () => {
|
|
9
|
+
Bun.env.DB_RATE_LIMIT_HASH_PADDING = "test-padding-value";
|
|
10
|
+
const value = "127.0.0.1";
|
|
11
|
+
const hash1 = await dbRateLimitHash(value);
|
|
12
|
+
const hash2 = await dbRateLimitHash(value);
|
|
13
|
+
expect(hash1).toBe(hash2);
|
|
14
|
+
expect(typeof hash1).toBe("string");
|
|
15
|
+
expect(hash1.length).toBeGreaterThan(0);
|
|
16
|
+
delete Bun.env.DB_RATE_LIMIT_HASH_PADDING;
|
|
17
|
+
});
|
|
18
|
+
it("should produce different hashes for different inputs", async () => {
|
|
19
|
+
Bun.env.DB_RATE_LIMIT_HASH_PADDING = "test-padding-value";
|
|
20
|
+
const hash1 = await dbRateLimitHash("127.0.0.1");
|
|
21
|
+
const hash2 = await dbRateLimitHash("127.0.0.2");
|
|
22
|
+
expect(hash1).not.toBe(hash2);
|
|
23
|
+
delete Bun.env.DB_RATE_LIMIT_HASH_PADDING;
|
|
24
|
+
});
|
|
25
|
+
it("should use DB_RATE_LIMIT_HASH_PADDING when provided", async () => {
|
|
26
|
+
Bun.env.DB_RATE_LIMIT_HASH_PADDING = "my-secret-padding";
|
|
27
|
+
const value = "127.0.0.1";
|
|
28
|
+
const hash = await dbRateLimitHash(value);
|
|
29
|
+
// Reset and use same padding again
|
|
30
|
+
resetKey();
|
|
31
|
+
Bun.env.DB_RATE_LIMIT_HASH_PADDING = "my-secret-padding";
|
|
32
|
+
const hash2 = await dbRateLimitHash(value);
|
|
33
|
+
expect(hash).toBe(hash2);
|
|
34
|
+
// Different padding should produce different hash
|
|
35
|
+
resetKey();
|
|
36
|
+
Bun.env.DB_RATE_LIMIT_HASH_PADDING = "different-padding";
|
|
37
|
+
const hash3 = await dbRateLimitHash(value);
|
|
38
|
+
expect(hash).not.toBe(hash3);
|
|
39
|
+
delete Bun.env.DB_RATE_LIMIT_HASH_PADDING;
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe("processCookieValue", () => {
|
|
43
|
+
it("should return base64 encoded value for 'none' strategy", async () => {
|
|
44
|
+
const value = "127.0.0.1";
|
|
45
|
+
const result = await processCookieValue(value, "none");
|
|
46
|
+
expect(result).toBe(Buffer.from(value).toString("base64"));
|
|
47
|
+
});
|
|
48
|
+
it("should hash value for 'hash' strategy", async () => {
|
|
49
|
+
Bun.env.DB_RATE_LIMIT_HASH_PADDING = "test-padding";
|
|
50
|
+
const value = "127.0.0.1";
|
|
51
|
+
const result = await processCookieValue(value, "hash");
|
|
52
|
+
expect(result).not.toBe(value);
|
|
53
|
+
// Hash should be consistent
|
|
54
|
+
const result2 = await processCookieValue(value, "hash");
|
|
55
|
+
expect(result).toBe(result2);
|
|
56
|
+
delete Bun.env.DB_RATE_LIMIT_HASH_PADDING;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe("isValidBase64", () => {
|
|
60
|
+
it("should return true for valid base64 strings", () => {
|
|
61
|
+
const value = "127.0.0.1";
|
|
62
|
+
const encoded = Buffer.from(value).toString("base64");
|
|
63
|
+
expect(isValidBase64(encoded)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it("should return false for invalid base64 strings", () => {
|
|
66
|
+
expect(isValidBase64("not-valid-base64!!!")).toBe(false);
|
|
67
|
+
expect(isValidBase64("hello world")).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it("should return true for empty base64 string", () => {
|
|
70
|
+
expect(isValidBase64("")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
it("should return true for base64 encoded binary data", () => {
|
|
73
|
+
const binary = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
|
74
|
+
const encoded = binary.toString("base64");
|
|
75
|
+
expect(isValidBase64(encoded)).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -17,7 +17,7 @@ export class RedisRateLimitStore {
|
|
|
17
17
|
async set(key, value, ttlSeconds) {
|
|
18
18
|
if (!this.client)
|
|
19
19
|
return;
|
|
20
|
-
await this.client.set(`rl:${key}`, JSON.stringify(value),
|
|
20
|
+
await this.client.set(`rl:${key}`, JSON.stringify(value), "EX", ttlSeconds);
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Clears all rate limit data from Redis by deleting keys with rl: prefix
|
|
@@ -25,7 +25,7 @@ export class RedisRateLimitStore {
|
|
|
25
25
|
async reset() {
|
|
26
26
|
if (!this.client)
|
|
27
27
|
return;
|
|
28
|
-
const keys = await this.client.keys(
|
|
28
|
+
const keys = await this.client.keys("rl:*");
|
|
29
29
|
if (keys && keys.length > 0) {
|
|
30
30
|
await this.client.del(...keys);
|
|
31
31
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DBRLCookieObfuscation } from "./types";
|
|
2
|
+
export declare const resetKey: () => void;
|
|
3
|
+
/**
|
|
4
|
+
* Hash a value using CryptoHasher("sha512-256") with padding
|
|
5
|
+
* This is a one-way function - the original value cannot be retrieved
|
|
6
|
+
*/
|
|
7
|
+
export declare const dbRateLimitHash: (value: string) => Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Process a cookie value based on the obfuscation strategy
|
|
10
|
+
* @param value - The value to process
|
|
11
|
+
* @param strategy - The obfuscation strategy to use
|
|
12
|
+
* @returns The processed value (always base64 encoded)
|
|
13
|
+
*/
|
|
14
|
+
export declare const processCookieValue: (value: string, strategy: DBRLCookieObfuscation) => Promise<string>;
|
|
15
|
+
/**
|
|
16
|
+
* Validate that a value is valid base64 without extracting/decoding it
|
|
17
|
+
* @param value - The value to validate
|
|
18
|
+
* @returns Whether the value is valid base64
|
|
19
|
+
*/
|
|
20
|
+
export declare const isValidBase64: (value: string) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Verify that a cookie value matches the provided IP address
|
|
23
|
+
* @param cookieValue - The value from the cookie (base64 encoded)
|
|
24
|
+
* @param ip - The IP address to verify against
|
|
25
|
+
* @param strategy - The obfuscation strategy used
|
|
26
|
+
* @returns Whether the cookie value is valid for this IP
|
|
27
|
+
*/
|
|
28
|
+
export declare const verifyCookieValue: (cookieValue: string, ip: string, strategy: DBRLCookieObfuscation) => Promise<boolean>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RateLimitStore, RateLimitStoreValue } from
|
|
1
|
+
import type { RateLimitStore, RateLimitStoreValue } from "../types";
|
|
2
2
|
export declare class RedisRateLimitStore implements RateLimitStore<Bun.RedisClient> {
|
|
3
3
|
client: Bun.RedisClient;
|
|
4
4
|
constructor(client: Bun.RedisClient);
|
|
@@ -23,6 +23,12 @@ export type DBRLPattern = "IPFullRoute" | "IPRouteNoParams" | "IP" | "Route";
|
|
|
23
23
|
* - 'auto': Try Redis first, fallback to SQLite (default behavior)
|
|
24
24
|
*/
|
|
25
25
|
export type DBRLBackingDb = "redis" | "sqlite" | "auto";
|
|
26
|
+
/**
|
|
27
|
+
* Strategy for cookie value obfuscation (encryption/hashing).
|
|
28
|
+
* - 'hash': Hash the cookie value using sha512-256 (one-way, cannot retrieve original value)
|
|
29
|
+
* - 'none': Store cookie value as base64 encoded IP (reversible, not recommended for production)
|
|
30
|
+
*/
|
|
31
|
+
export type DBRLCookieObfuscation = "hash" | "none";
|
|
26
32
|
/**
|
|
27
33
|
* Data structure stored in the rate limit store.
|
|
28
34
|
*/
|
|
@@ -106,9 +112,20 @@ export type DBRLOptions = {
|
|
|
106
112
|
*/
|
|
107
113
|
whitelistMode?: boolean;
|
|
108
114
|
/**
|
|
109
|
-
*
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
* Cookie obfuscation strategy (Default: 'hash').
|
|
116
|
+
* - 'hash': Hash using sha512-256 (one-way, cannot retrieve original value)
|
|
117
|
+
* - 'none': No obfuscation (base64 encoded IP, not recommended for production)
|
|
118
|
+
*/
|
|
119
|
+
cookieObfuscation?: DBRLCookieObfuscation;
|
|
120
|
+
/**
|
|
121
|
+
* Whether to verify the cookie value matches the current IP address (Default: false for 'hash', true for 'none').
|
|
122
|
+
* When true and the cookie value doesn't match the IP, the rate limit count is transferred to a new cookie.
|
|
123
|
+
*/
|
|
124
|
+
alwaysCheckCookieValue?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Whether to allow the request if the rate limiter encounters an internal error (Default: true).
|
|
127
|
+
|
|
128
|
+
* Set to false for a more strict security posture.
|
|
129
|
+
*/
|
|
113
130
|
failOpen?: boolean;
|
|
114
131
|
};
|
package/package.json
CHANGED
|
@@ -6,29 +6,29 @@
|
|
|
6
6
|
},
|
|
7
7
|
"private": false,
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.0.
|
|
9
|
+
"version": "0.0.8",
|
|
10
10
|
"author": "Eli Selkin <eli@extendtherapy.com>",
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/types/index.d.ts",
|
|
13
14
|
"devDependencies": {
|
|
14
|
-
"@types/bun": "
|
|
15
|
+
"@types/bun": "^1.3.9",
|
|
16
|
+
"typescript": "^5"
|
|
15
17
|
},
|
|
16
18
|
"scripts": {
|
|
17
19
|
"prepublishOnly": "rm -rf dist || true && bunx tsc"
|
|
18
20
|
},
|
|
19
21
|
"peerDependencies": {
|
|
20
|
-
"@types/bun": "^1.3.0",
|
|
21
|
-
"typescript": "^5",
|
|
22
22
|
"elysia": "^1.4.22",
|
|
23
23
|
"pino": "^10.0.0",
|
|
24
24
|
"elysia-ip": "^1.0.0"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"dist/**/*",
|
|
28
|
-
"AGENTS.md",
|
|
29
28
|
"README.md",
|
|
30
29
|
"package.json",
|
|
31
|
-
"LICENSE"
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"CHANGELOG.md"
|
|
32
32
|
],
|
|
33
33
|
"engines": {
|
|
34
34
|
"bun": ">=1.3.0"
|
package/AGENTS.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# About the project
|
|
2
|
-
|
|
3
|
-
- This is a Bun project so ALWAYS use `bun` or `bunx` instead of `node`, `npm`, or `npx`!
|
|
4
|
-
- This is an [Elysia](https://elysiajs.com/) [plugin](https://elysiajs.com/essential/plugin.html).
|
|
5
|
-
- All Elysia methods (like `derive` or `macro` or `use`) should be chained and not separated into a declaration or assignment and then applied to the variable.
|
|
6
|
-
For example:
|
|
7
|
-
|
|
8
|
-
Do **NOT** do this:
|
|
9
|
-
|
|
10
|
-
```ts
|
|
11
|
-
const elysiaplugin = new Elysia();
|
|
12
|
-
elysiaplugin.use(someplugin);
|
|
13
|
-
elysiaplugin.resolve((ctx) => {
|
|
14
|
-
return { something: "abc" };
|
|
15
|
-
});
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
**DO THIS** instead:
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
const elysiaplugin = new Elysia().use(someplugin).resolve((ctx) => {
|
|
22
|
-
return { somethiong: "abc" };
|
|
23
|
-
});
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Commands
|
|
27
|
-
|
|
28
|
-
- Use `bun test` instead of `jest` or `vitest`
|
|
29
|
-
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
|
30
|
-
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
|
31
|
-
- Bun automatically loads .env, so don't use dotenv.
|
|
32
|
-
|
|
33
|
-
## APIs
|
|
34
|
-
|
|
35
|
-
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
|
36
|
-
- `Bun.redis` for Redis. Don't use `ioredis`.
|
|
37
|
-
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
|
38
|
-
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
|
39
|
-
- Bun.$`ls` instead of execa.
|
|
40
|
-
|
|
41
|
-
## Testing
|
|
42
|
-
|
|
43
|
-
Use `bun test` to run tests.
|
|
44
|
-
|
|
45
|
-
```ts#index.test.ts
|
|
46
|
-
import { test, expect } from "bun:test";
|
|
47
|
-
|
|
48
|
-
test("hello world", () => {
|
|
49
|
-
expect(1).toBe(1);
|
|
50
|
-
});
|
|
51
|
-
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|