aap-agent-server 2.0.0 → 2.6.0
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/challenges.js +318 -109
- package/index.js +24 -3
- package/middleware.js +60 -15
- package/package.json +2 -2
- package/persistence.js +238 -0
- package/ratelimit.js +117 -0
- package/whitelist.js +231 -0
package/index.js
CHANGED
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @aap/server
|
|
2
|
+
* @aap/server v2.5.0
|
|
3
3
|
*
|
|
4
4
|
* Server-side utilities for Agent Attestation Protocol.
|
|
5
|
-
*
|
|
5
|
+
* The Reverse Turing Test - CAPTCHAs block bots, AAP blocks humans.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export * from './middleware.js';
|
|
9
9
|
export * from './challenges.js';
|
|
10
|
+
export * from './ratelimit.js';
|
|
11
|
+
export * from './whitelist.js';
|
|
12
|
+
export * from './persistence.js';
|
|
13
|
+
export * from './errors.js';
|
|
14
|
+
export * as logger from './logger.js';
|
|
10
15
|
|
|
11
16
|
import { aapMiddleware, createRouter } from './middleware.js';
|
|
12
17
|
import challenges from './challenges.js';
|
|
18
|
+
import { createRateLimiter, createFailureLimiter } from './ratelimit.js';
|
|
19
|
+
import { createWhitelist, createKeyRotation } from './whitelist.js';
|
|
20
|
+
import { createStore, createMemoryStore, createFileStore, createRedisStore } from './persistence.js';
|
|
13
21
|
|
|
14
22
|
export { challenges };
|
|
15
23
|
|
|
16
24
|
export default {
|
|
25
|
+
// Core
|
|
17
26
|
aapMiddleware,
|
|
18
27
|
createRouter,
|
|
19
|
-
challenges
|
|
28
|
+
challenges,
|
|
29
|
+
|
|
30
|
+
// Security
|
|
31
|
+
createRateLimiter,
|
|
32
|
+
createFailureLimiter,
|
|
33
|
+
createWhitelist,
|
|
34
|
+
createKeyRotation,
|
|
35
|
+
|
|
36
|
+
// Persistence
|
|
37
|
+
createStore,
|
|
38
|
+
createMemoryStore,
|
|
39
|
+
createFileStore,
|
|
40
|
+
createRedisStore
|
|
20
41
|
};
|
package/middleware.js
CHANGED
|
@@ -15,14 +15,16 @@ import {
|
|
|
15
15
|
MAX_RESPONSE_TIME_MS,
|
|
16
16
|
CHALLENGE_EXPIRY_MS
|
|
17
17
|
} from './challenges.js';
|
|
18
|
+
import * as logger from './logger.js';
|
|
19
|
+
import { ErrorCodes, sendError } from './errors.js';
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Create AAP verification middleware/router
|
|
21
23
|
*
|
|
22
24
|
* @param {Object} [options]
|
|
23
25
|
* @param {number} [options.challengeExpiryMs=60000] - Challenge expiration time
|
|
24
|
-
* @param {number} [options.maxResponseTimeMs=
|
|
25
|
-
* @param {number} [options.batchSize=
|
|
26
|
+
* @param {number} [options.maxResponseTimeMs=8000] - Max response time for batch
|
|
27
|
+
* @param {number} [options.batchSize=5] - Number of challenges per batch
|
|
26
28
|
* @param {number} [options.minPassCount] - Minimum challenges to pass (default: all)
|
|
27
29
|
* @param {Function} [options.onVerified] - Callback when agent is verified
|
|
28
30
|
* @param {Function} [options.onFailed] - Callback when verification fails
|
|
@@ -38,7 +40,8 @@ export function aapMiddleware(options = {}) {
|
|
|
38
40
|
onFailed
|
|
39
41
|
} = options;
|
|
40
42
|
|
|
41
|
-
// In-memory challenge store
|
|
43
|
+
// In-memory challenge store with size limit (DoS protection)
|
|
44
|
+
const MAX_CHALLENGES = 10000;
|
|
42
45
|
const challenges = new Map();
|
|
43
46
|
|
|
44
47
|
// Cleanup expired challenges periodically
|
|
@@ -49,6 +52,15 @@ export function aapMiddleware(options = {}) {
|
|
|
49
52
|
challenges.delete(nonce);
|
|
50
53
|
}
|
|
51
54
|
}
|
|
55
|
+
|
|
56
|
+
// Emergency cleanup if still too many (keep newest)
|
|
57
|
+
if (challenges.size > MAX_CHALLENGES) {
|
|
58
|
+
const entries = [...challenges.entries()]
|
|
59
|
+
.sort((a, b) => b[1].timestamp - a[1].timestamp)
|
|
60
|
+
.slice(0, MAX_CHALLENGES / 2);
|
|
61
|
+
challenges.clear();
|
|
62
|
+
entries.forEach(([k, v]) => challenges.set(k, v));
|
|
63
|
+
}
|
|
52
64
|
};
|
|
53
65
|
|
|
54
66
|
// Return a function that creates routes
|
|
@@ -121,6 +133,7 @@ export function aapMiddleware(options = {}) {
|
|
|
121
133
|
} = req.body;
|
|
122
134
|
|
|
123
135
|
const checks = {
|
|
136
|
+
inputValid: false,
|
|
124
137
|
challengeExists: false,
|
|
125
138
|
notExpired: false,
|
|
126
139
|
solutionsExist: false,
|
|
@@ -130,7 +143,28 @@ export function aapMiddleware(options = {}) {
|
|
|
130
143
|
};
|
|
131
144
|
|
|
132
145
|
try {
|
|
133
|
-
// Check
|
|
146
|
+
// Check 0: Input validation (security)
|
|
147
|
+
if (!nonce || typeof nonce !== 'string' || nonce.length !== 32) {
|
|
148
|
+
return res.status(400).json({ verified: false, error: 'Invalid nonce format', checks });
|
|
149
|
+
}
|
|
150
|
+
if (!publicId || typeof publicId !== 'string' || publicId.length !== 20) {
|
|
151
|
+
return res.status(400).json({ verified: false, error: 'Invalid publicId format', checks });
|
|
152
|
+
}
|
|
153
|
+
if (!signature || typeof signature !== 'string' || signature.length < 50) {
|
|
154
|
+
return res.status(400).json({ verified: false, error: 'Invalid signature format', checks });
|
|
155
|
+
}
|
|
156
|
+
if (!publicKey || typeof publicKey !== 'string' || !publicKey.includes('BEGIN PUBLIC KEY')) {
|
|
157
|
+
return res.status(400).json({ verified: false, error: 'Invalid publicKey format', checks });
|
|
158
|
+
}
|
|
159
|
+
if (!timestamp || typeof timestamp !== 'number') {
|
|
160
|
+
return res.status(400).json({ verified: false, error: 'Invalid timestamp', checks });
|
|
161
|
+
}
|
|
162
|
+
if (!responseTimeMs || typeof responseTimeMs !== 'number' || responseTimeMs < 0) {
|
|
163
|
+
return res.status(400).json({ verified: false, error: 'Invalid responseTimeMs', checks });
|
|
164
|
+
}
|
|
165
|
+
checks.inputValid = true;
|
|
166
|
+
|
|
167
|
+
// Check 1: Challenge exists (check BEFORE delete for race condition fix)
|
|
134
168
|
const challenge = challenges.get(nonce);
|
|
135
169
|
if (!challenge) {
|
|
136
170
|
if (onFailed) onFailed({ error: 'Challenge not found', checks }, req);
|
|
@@ -142,12 +176,9 @@ export function aapMiddleware(options = {}) {
|
|
|
142
176
|
}
|
|
143
177
|
checks.challengeExists = true;
|
|
144
178
|
|
|
145
|
-
//
|
|
146
|
-
const { validators, batchSize: size } = challenge;
|
|
147
|
-
challenges.delete(nonce);
|
|
148
|
-
|
|
149
|
-
// Check 2: Not expired
|
|
179
|
+
// Check 2: Not expired (check BEFORE delete - race condition fix)
|
|
150
180
|
if (Date.now() > challenge.expiresAt) {
|
|
181
|
+
challenges.delete(nonce); // Clean up expired
|
|
151
182
|
if (onFailed) onFailed({ error: 'Challenge expired', checks }, req);
|
|
152
183
|
return res.status(400).json({
|
|
153
184
|
verified: false,
|
|
@@ -157,6 +188,10 @@ export function aapMiddleware(options = {}) {
|
|
|
157
188
|
}
|
|
158
189
|
checks.notExpired = true;
|
|
159
190
|
|
|
191
|
+
// Remove challenge (one-time use) - only after expiry check
|
|
192
|
+
const { validators, batchSize: size } = challenge;
|
|
193
|
+
challenges.delete(nonce);
|
|
194
|
+
|
|
160
195
|
// Check 3: Solutions exist
|
|
161
196
|
if (!solutions || !Array.isArray(solutions) || solutions.length !== size) {
|
|
162
197
|
if (onFailed) onFailed({ error: 'Invalid solutions array', checks }, req);
|
|
@@ -183,13 +218,17 @@ export function aapMiddleware(options = {}) {
|
|
|
183
218
|
}
|
|
184
219
|
checks.solutionsValid = true;
|
|
185
220
|
|
|
186
|
-
// Check 5: Response time (Proof of Liveness)
|
|
187
|
-
|
|
221
|
+
// Check 5: Response time (Proof of Liveness) - SERVER-SIDE validation
|
|
222
|
+
const serverResponseTime = Date.now() - challenge.timestamp;
|
|
223
|
+
const effectiveResponseTime = Math.max(responseTimeMs, serverResponseTime);
|
|
224
|
+
|
|
225
|
+
if (effectiveResponseTime > maxResponseTimeMs) {
|
|
188
226
|
if (onFailed) onFailed({ error: 'Response too slow', checks }, req);
|
|
189
227
|
return res.status(400).json({
|
|
190
228
|
verified: false,
|
|
191
|
-
error: `Response too slow: ${
|
|
192
|
-
checks
|
|
229
|
+
error: `Response too slow: ${effectiveResponseTime}ms > ${maxResponseTimeMs}ms (Proof of Liveness failed)`,
|
|
230
|
+
checks,
|
|
231
|
+
timing: { client: responseTimeMs, server: serverResponseTime }
|
|
193
232
|
});
|
|
194
233
|
}
|
|
195
234
|
checks.responseTimeValid = true;
|
|
@@ -361,8 +400,14 @@ export function aapMiddleware(options = {}) {
|
|
|
361
400
|
* const app = express();
|
|
362
401
|
* app.use('/aap/v1', createRouter());
|
|
363
402
|
*/
|
|
364
|
-
export function createRouter(options = {}) {
|
|
365
|
-
|
|
403
|
+
export async function createRouter(options = {}) {
|
|
404
|
+
// Dynamic import for optional express dependency
|
|
405
|
+
let express;
|
|
406
|
+
try {
|
|
407
|
+
express = (await import('express')).default;
|
|
408
|
+
} catch {
|
|
409
|
+
throw new Error('express is required for createRouter. Install with: npm install express');
|
|
410
|
+
}
|
|
366
411
|
const router = express.Router();
|
|
367
412
|
router.use(express.json());
|
|
368
413
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aap-agent-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Server middleware for Agent Attestation Protocol - verify AI agents",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"homepage": "https://github.com/ira-hash/agent-attestation-protocol#readme",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"aap-agent-core": "^2.
|
|
24
|
+
"aap-agent-core": "^2.6.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"express": "^4.18.0 || ^5.0.0"
|
package/persistence.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAP Challenge Persistence
|
|
3
|
+
*
|
|
4
|
+
* Optional: Persist challenges to survive server restarts
|
|
5
|
+
* Supports: Memory (default), File, Redis
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create in-memory store (default, no persistence)
|
|
13
|
+
*/
|
|
14
|
+
export function createMemoryStore() {
|
|
15
|
+
const challenges = new Map();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
type: 'memory',
|
|
19
|
+
|
|
20
|
+
async get(nonce) {
|
|
21
|
+
return challenges.get(nonce) || null;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async set(nonce, data) {
|
|
25
|
+
challenges.set(nonce, data);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async delete(nonce) {
|
|
29
|
+
challenges.delete(nonce);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async has(nonce) {
|
|
33
|
+
return challenges.has(nonce);
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async size() {
|
|
37
|
+
return challenges.size;
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async keys() {
|
|
41
|
+
return [...challenges.keys()];
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async clear() {
|
|
45
|
+
challenges.clear();
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async cleanup(now = Date.now()) {
|
|
49
|
+
for (const [nonce, data] of challenges.entries()) {
|
|
50
|
+
if (now > data.expiresAt) {
|
|
51
|
+
challenges.delete(nonce);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create file-based store (survives restarts)
|
|
60
|
+
* @param {string} filePath - Path to store file
|
|
61
|
+
*/
|
|
62
|
+
export function createFileStore(filePath = '.aap/challenges.json') {
|
|
63
|
+
const fullPath = join(process.cwd(), filePath);
|
|
64
|
+
let challenges = new Map();
|
|
65
|
+
|
|
66
|
+
// Load existing data
|
|
67
|
+
try {
|
|
68
|
+
if (existsSync(fullPath)) {
|
|
69
|
+
const data = JSON.parse(readFileSync(fullPath, 'utf8'));
|
|
70
|
+
challenges = new Map(Object.entries(data));
|
|
71
|
+
console.log(`[AAP] Loaded ${challenges.size} challenges from ${fullPath}`);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.warn('[AAP] Could not load challenges:', error.message);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Save to file
|
|
78
|
+
const save = () => {
|
|
79
|
+
try {
|
|
80
|
+
const dir = dirname(fullPath);
|
|
81
|
+
if (!existsSync(dir)) {
|
|
82
|
+
mkdirSync(dir, { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const data = Object.fromEntries(challenges);
|
|
86
|
+
// Remove validator functions (not serializable)
|
|
87
|
+
for (const key of Object.keys(data)) {
|
|
88
|
+
delete data[key].validators;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
writeFileSync(fullPath, JSON.stringify(data, null, 2));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('[AAP] Could not save challenges:', error.message);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Auto-save periodically
|
|
98
|
+
const saveInterval = setInterval(save, 30000);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
type: 'file',
|
|
102
|
+
|
|
103
|
+
async get(nonce) {
|
|
104
|
+
return challenges.get(nonce) || null;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async set(nonce, data) {
|
|
108
|
+
challenges.set(nonce, data);
|
|
109
|
+
save();
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async delete(nonce) {
|
|
113
|
+
challenges.delete(nonce);
|
|
114
|
+
save();
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async has(nonce) {
|
|
118
|
+
return challenges.has(nonce);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async size() {
|
|
122
|
+
return challenges.size;
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
async keys() {
|
|
126
|
+
return [...challenges.keys()];
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async clear() {
|
|
130
|
+
challenges.clear();
|
|
131
|
+
save();
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async cleanup(now = Date.now()) {
|
|
135
|
+
let cleaned = 0;
|
|
136
|
+
for (const [nonce, data] of challenges.entries()) {
|
|
137
|
+
if (now > data.expiresAt) {
|
|
138
|
+
challenges.delete(nonce);
|
|
139
|
+
cleaned++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (cleaned > 0) save();
|
|
143
|
+
return cleaned;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
close() {
|
|
147
|
+
clearInterval(saveInterval);
|
|
148
|
+
save();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Create Redis-based store (for distributed deployments)
|
|
155
|
+
* @param {Object} redisClient - Redis client instance (ioredis or redis)
|
|
156
|
+
* @param {string} [prefix='aap:challenge:'] - Key prefix
|
|
157
|
+
*/
|
|
158
|
+
export function createRedisStore(redisClient, prefix = 'aap:challenge:') {
|
|
159
|
+
return {
|
|
160
|
+
type: 'redis',
|
|
161
|
+
|
|
162
|
+
async get(nonce) {
|
|
163
|
+
const data = await redisClient.get(prefix + nonce);
|
|
164
|
+
return data ? JSON.parse(data) : null;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async set(nonce, data, ttlMs = 60000) {
|
|
168
|
+
// Store without validators (not serializable)
|
|
169
|
+
const { validators, ...storable } = data;
|
|
170
|
+
await redisClient.set(
|
|
171
|
+
prefix + nonce,
|
|
172
|
+
JSON.stringify(storable),
|
|
173
|
+
'PX',
|
|
174
|
+
ttlMs
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
async delete(nonce) {
|
|
179
|
+
await redisClient.del(prefix + nonce);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async has(nonce) {
|
|
183
|
+
return (await redisClient.exists(prefix + nonce)) === 1;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async size() {
|
|
187
|
+
const keys = await redisClient.keys(prefix + '*');
|
|
188
|
+
return keys.length;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async keys() {
|
|
192
|
+
const keys = await redisClient.keys(prefix + '*');
|
|
193
|
+
return keys.map(k => k.slice(prefix.length));
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
async clear() {
|
|
197
|
+
const keys = await redisClient.keys(prefix + '*');
|
|
198
|
+
if (keys.length > 0) {
|
|
199
|
+
await redisClient.del(...keys);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async cleanup() {
|
|
204
|
+
// Redis handles TTL automatically
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Auto-detect and create appropriate store
|
|
212
|
+
* @param {Object} options
|
|
213
|
+
* @param {'memory'|'file'|'redis'} [options.type='memory']
|
|
214
|
+
* @param {string} [options.filePath]
|
|
215
|
+
* @param {Object} [options.redisClient]
|
|
216
|
+
*/
|
|
217
|
+
export function createStore(options = {}) {
|
|
218
|
+
const { type = 'memory', filePath, redisClient } = options;
|
|
219
|
+
|
|
220
|
+
switch (type) {
|
|
221
|
+
case 'file':
|
|
222
|
+
return createFileStore(filePath);
|
|
223
|
+
case 'redis':
|
|
224
|
+
if (!redisClient) {
|
|
225
|
+
throw new Error('Redis client required for redis store');
|
|
226
|
+
}
|
|
227
|
+
return createRedisStore(redisClient);
|
|
228
|
+
default:
|
|
229
|
+
return createMemoryStore();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default {
|
|
234
|
+
createMemoryStore,
|
|
235
|
+
createFileStore,
|
|
236
|
+
createRedisStore,
|
|
237
|
+
createStore
|
|
238
|
+
};
|
package/ratelimit.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAP Rate Limiter
|
|
3
|
+
*
|
|
4
|
+
* Simple in-memory rate limiting (no external dependencies)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a rate limiter
|
|
9
|
+
* @param {Object} options
|
|
10
|
+
* @param {number} [options.windowMs=60000] - Time window in ms
|
|
11
|
+
* @param {number} [options.max=10] - Max requests per window
|
|
12
|
+
* @param {string} [options.message] - Error message
|
|
13
|
+
* @returns {Function} Express middleware
|
|
14
|
+
*/
|
|
15
|
+
export function createRateLimiter(options = {}) {
|
|
16
|
+
const {
|
|
17
|
+
windowMs = 60000,
|
|
18
|
+
max = 10,
|
|
19
|
+
message = 'Too many requests, please try again later'
|
|
20
|
+
} = options;
|
|
21
|
+
|
|
22
|
+
const requests = new Map();
|
|
23
|
+
|
|
24
|
+
// Cleanup old entries periodically
|
|
25
|
+
setInterval(() => {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
for (const [key, data] of requests.entries()) {
|
|
28
|
+
if (now - data.firstRequest > windowMs) {
|
|
29
|
+
requests.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}, windowMs);
|
|
33
|
+
|
|
34
|
+
return (req, res, next) => {
|
|
35
|
+
const key = req.ip || req.connection.remoteAddress || 'unknown';
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
|
|
38
|
+
let data = requests.get(key);
|
|
39
|
+
|
|
40
|
+
if (!data || now - data.firstRequest > windowMs) {
|
|
41
|
+
// New window
|
|
42
|
+
data = { count: 1, firstRequest: now };
|
|
43
|
+
requests.set(key, data);
|
|
44
|
+
} else {
|
|
45
|
+
data.count++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Set headers
|
|
49
|
+
const remaining = Math.max(0, max - data.count);
|
|
50
|
+
const resetTime = Math.ceil((data.firstRequest + windowMs) / 1000);
|
|
51
|
+
|
|
52
|
+
res.setHeader('X-RateLimit-Limit', max);
|
|
53
|
+
res.setHeader('X-RateLimit-Remaining', remaining);
|
|
54
|
+
res.setHeader('X-RateLimit-Reset', resetTime);
|
|
55
|
+
|
|
56
|
+
if (data.count > max) {
|
|
57
|
+
res.setHeader('Retry-After', Math.ceil((data.firstRequest + windowMs - now) / 1000));
|
|
58
|
+
return res.status(429).json({
|
|
59
|
+
error: message,
|
|
60
|
+
retryAfter: Math.ceil((data.firstRequest + windowMs - now) / 1000)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
next();
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create rate limiter for failed attempts
|
|
70
|
+
* Stricter limits after failures
|
|
71
|
+
*/
|
|
72
|
+
export function createFailureLimiter(options = {}) {
|
|
73
|
+
const {
|
|
74
|
+
windowMs = 60000,
|
|
75
|
+
maxFailures = 5,
|
|
76
|
+
message = 'Too many failed attempts'
|
|
77
|
+
} = options;
|
|
78
|
+
|
|
79
|
+
const failures = new Map();
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
middleware: (req, res, next) => {
|
|
83
|
+
const key = req.ip || req.connection.remoteAddress || 'unknown';
|
|
84
|
+
const data = failures.get(key);
|
|
85
|
+
|
|
86
|
+
if (data && data.count >= maxFailures && Date.now() - data.firstFailure < windowMs) {
|
|
87
|
+
return res.status(429).json({
|
|
88
|
+
error: message,
|
|
89
|
+
retryAfter: Math.ceil((data.firstFailure + windowMs - Date.now()) / 1000)
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
next();
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
recordFailure: (req) => {
|
|
97
|
+
const key = req.ip || req.connection.remoteAddress || 'unknown';
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
let data = failures.get(key);
|
|
100
|
+
|
|
101
|
+
if (!data || now - data.firstFailure > windowMs) {
|
|
102
|
+
data = { count: 1, firstFailure: now };
|
|
103
|
+
} else {
|
|
104
|
+
data.count++;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
failures.set(key, data);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
clearFailures: (req) => {
|
|
111
|
+
const key = req.ip || req.connection.remoteAddress || 'unknown';
|
|
112
|
+
failures.delete(key);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default { createRateLimiter, createFailureLimiter };
|