z-shield 0.1.1 → 0.1.2
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/README.md +123 -6
- package/dist/block-manager.d.ts +134 -0
- package/dist/block-manager.d.ts.map +1 -0
- package/dist/block-manager.js +425 -0
- package/dist/block-manager.js.map +1 -0
- package/dist/cli.d.ts +141 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +784 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +148 -0
- package/dist/config.js.map +1 -0
- package/dist/detector.d.ts +56 -0
- package/dist/detector.d.ts.map +1 -0
- package/dist/detector.js +81 -0
- package/dist/detector.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +120 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +141 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +347 -0
- package/dist/logger.js.map +1 -0
- package/dist/middleware.d.ts +68 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +272 -0
- package/dist/middleware.js.map +1 -0
- package/dist/next.d.ts +73 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +486 -0
- package/dist/next.js.map +1 -0
- package/dist/rate-limiter.d.ts +75 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +153 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/next.d.ts +1 -0
- package/next.js +2 -0
- package/package.json +34 -5
- package/src/block-manager.js +0 -326
- package/src/cli.js +0 -764
- package/src/config.js +0 -165
- package/src/detector.js +0 -88
- package/src/index.js +0 -97
- package/src/logger.js +0 -280
- package/src/middleware.js +0 -288
- package/src/rate-limiter.js +0 -186
package/src/middleware.js
DELETED
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { resolveConfig } = require('./config');
|
|
4
|
-
const { RateLimiter } = require('./rate-limiter');
|
|
5
|
-
const { Detector } = require('./detector');
|
|
6
|
-
const { BlockManager, isValidIp, normalizeIp } = require('./block-manager');
|
|
7
|
-
const { Logger } = require('./logger');
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Safely extracts the client's IP address and normalizes IPv4 and IPv6 representations.
|
|
11
|
-
*
|
|
12
|
-
* Security Note:
|
|
13
|
-
* An attacker can easily send spoofed headers like `X-Forwarded-For: 1.2.3.4`.
|
|
14
|
-
* Therefore, we NEVER inspect `X-Forwarded-For` directly unless `trustProxy` is true
|
|
15
|
-
* AND Express itself has verified it through its own `trust proxy` configuration.
|
|
16
|
-
*
|
|
17
|
-
* @param {import('express').Request} req - The incoming Express request object
|
|
18
|
-
* @param {boolean} trustProxy - Whether proxy headers are explicitly trusted
|
|
19
|
-
* @returns {string} The normalized client IP address
|
|
20
|
-
*/
|
|
21
|
-
function extractClientIp(req, trustProxy) {
|
|
22
|
-
let ip = '';
|
|
23
|
-
|
|
24
|
-
if (trustProxy && typeof req.ip === 'string' && req.ip.length > 0) {
|
|
25
|
-
ip = req.ip;
|
|
26
|
-
} else if (req.socket && typeof req.socket.remoteAddress === 'string') {
|
|
27
|
-
ip = req.socket.remoteAddress;
|
|
28
|
-
} else if (req.connection && typeof req.connection.remoteAddress === 'string') {
|
|
29
|
-
ip = req.connection.remoteAddress;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return normalizeIp(ip);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Determines whether a given request path matches any of the excluded route patterns.
|
|
37
|
-
* Respects path-segment boundaries so that excluding '/health' matches '/health' or '/health/live'
|
|
38
|
-
* but does NOT match '/health-admin' or '/healthcare'.
|
|
39
|
-
* Also normalizes trailing slashes and ignores query strings and fragments.
|
|
40
|
-
*
|
|
41
|
-
* @param {string} requestPath - The incoming request path (or raw url)
|
|
42
|
-
* @param {string[]} excludeRoutes - Configured excluded route prefixes
|
|
43
|
-
* @returns {boolean}
|
|
44
|
-
*/
|
|
45
|
-
function isRouteExcluded(requestPath, excludeRoutes) {
|
|
46
|
-
if (!Array.isArray(excludeRoutes) || excludeRoutes.length === 0) {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Strip query string and fragment if present (e.g. from req.url)
|
|
51
|
-
const cleanPath = (requestPath || '/').split('?')[0].split('#')[0];
|
|
52
|
-
|
|
53
|
-
return excludeRoutes.some((route) => {
|
|
54
|
-
if (typeof route !== 'string' || route.length === 0) {
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (route === '/') {
|
|
59
|
-
return cleanPath === '/';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Strip trailing slash from route prefix for normalized segment comparison
|
|
63
|
-
const normalizedPrefix = route.endsWith('/') ? route.slice(0, -1) : route;
|
|
64
|
-
|
|
65
|
-
// Exact match (e.g. '/health' === '/health' or '/status' === '/status')
|
|
66
|
-
if (cleanPath === normalizedPrefix) {
|
|
67
|
-
return true;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Sub-path segment match (e.g. '/health/check'.startsWith('/health/'))
|
|
71
|
-
if (cleanPath.startsWith(normalizedPrefix + '/')) {
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return false;
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Sets a response header only if headers have not already been sent to the client.
|
|
81
|
-
*
|
|
82
|
-
* @param {import('express').Response} res
|
|
83
|
-
* @param {string} name
|
|
84
|
-
* @param {string} value
|
|
85
|
-
*/
|
|
86
|
-
function safeSetHeader(res, name, value) {
|
|
87
|
-
if (res && !res.headersSent && typeof res.setHeader === 'function') {
|
|
88
|
-
res.setHeader(name, value);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Safely terminates an open HTTP response without calling next() or setting headers.
|
|
94
|
-
* Used when a blocked request in protect mode already had its headers committed upstream.
|
|
95
|
-
*
|
|
96
|
-
* @param {import('express').Response} res
|
|
97
|
-
*/
|
|
98
|
-
function safelyEndResponse(res) {
|
|
99
|
-
if (res && !res.writableEnded && typeof res.end === 'function') {
|
|
100
|
-
res.end();
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Factory function to create the Z-Shield Express middleware.
|
|
106
|
-
*
|
|
107
|
-
* @param {Object} [options={}] - Custom configuration options
|
|
108
|
-
* @param {Object} [dependencies={}] - Injected dependencies (useful for testing)
|
|
109
|
-
* @param {RateLimiter} [dependencies.rateLimiter]
|
|
110
|
-
* @param {Detector} [dependencies.detector]
|
|
111
|
-
* @param {BlockManager} [dependencies.blockManager]
|
|
112
|
-
* @param {Logger} [dependencies.logger]
|
|
113
|
-
* @returns {Function} Express middleware function: (req, res, next) => void
|
|
114
|
-
*/
|
|
115
|
-
function createMiddleware(options = {}, dependencies = {}) {
|
|
116
|
-
const config = resolveConfig(options);
|
|
117
|
-
const rateLimiter = dependencies.rateLimiter || new RateLimiter(config);
|
|
118
|
-
const detector = dependencies.detector || new Detector({ rateLimiter, config });
|
|
119
|
-
const blockManager = dependencies.blockManager || new BlockManager(config);
|
|
120
|
-
const logger = dependencies.logger || new Logger(config);
|
|
121
|
-
|
|
122
|
-
return function zShieldMiddleware(req, res, next) {
|
|
123
|
-
try {
|
|
124
|
-
const clientIp = extractClientIp(req, config.trustProxy);
|
|
125
|
-
const rawPath = (typeof req.path === 'string' && req.path.length > 0)
|
|
126
|
-
? req.path
|
|
127
|
-
: (req.url || '/');
|
|
128
|
-
const requestPath = rawPath.split('?')[0].split('#')[0] || '/';
|
|
129
|
-
const requestMethod = req.method || 'GET';
|
|
130
|
-
const now = Date.now();
|
|
131
|
-
|
|
132
|
-
// Check if this route is in the exclusion list (respecting segment boundaries)
|
|
133
|
-
const isExcluded = isRouteExcluded(requestPath, config.excludeRoutes);
|
|
134
|
-
|
|
135
|
-
if (isExcluded) {
|
|
136
|
-
req.zShield = {
|
|
137
|
-
ip: clientIp,
|
|
138
|
-
mode: config.mode,
|
|
139
|
-
appName: config.appName,
|
|
140
|
-
isExcluded: true,
|
|
141
|
-
startTime: now,
|
|
142
|
-
detection: null,
|
|
143
|
-
blocked: false
|
|
144
|
-
};
|
|
145
|
-
safeSetHeader(res, 'X-Z-Shield-Protected', '1');
|
|
146
|
-
safeSetHeader(res, 'X-Z-Shield-Mode', config.mode);
|
|
147
|
-
return next();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Step 1: Check active temporary blocks
|
|
151
|
-
const isIpBlocked = blockManager.isBlocked(clientIp, now);
|
|
152
|
-
|
|
153
|
-
if (isIpBlocked) {
|
|
154
|
-
const blockInfo = blockManager.getBlockInfo(clientIp, now);
|
|
155
|
-
const retryAfterSeconds = Math.max(1, Math.ceil(blockInfo.remainingMs / 1000));
|
|
156
|
-
const action = config.mode === 'protect' ? 'blocked' : 'warned';
|
|
157
|
-
|
|
158
|
-
// Log the security event asynchronously
|
|
159
|
-
const logPromise = logger.log({
|
|
160
|
-
appName: config.appName,
|
|
161
|
-
type: 'blocked_ip',
|
|
162
|
-
severity: 'warning',
|
|
163
|
-
ip: clientIp,
|
|
164
|
-
method: requestMethod,
|
|
165
|
-
route: requestPath,
|
|
166
|
-
reason: blockInfo.reason,
|
|
167
|
-
action
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
req.zShield = {
|
|
171
|
-
ip: clientIp,
|
|
172
|
-
mode: config.mode,
|
|
173
|
-
appName: config.appName,
|
|
174
|
-
isExcluded: false,
|
|
175
|
-
startTime: now,
|
|
176
|
-
detection: null,
|
|
177
|
-
blocked: true,
|
|
178
|
-
blockReason: blockInfo.reason,
|
|
179
|
-
logPromise
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
safeSetHeader(res, 'X-Z-Shield-Protected', '1');
|
|
183
|
-
safeSetHeader(res, 'X-Z-Shield-Mode', config.mode);
|
|
184
|
-
safeSetHeader(res, 'X-Z-Shield-Blocked', '1');
|
|
185
|
-
safeSetHeader(res, 'X-Z-Shield-Reason', blockInfo.reason);
|
|
186
|
-
safeSetHeader(res, 'Retry-After', String(retryAfterSeconds));
|
|
187
|
-
|
|
188
|
-
if (config.mode === 'protect') {
|
|
189
|
-
if (!res.headersSent) {
|
|
190
|
-
return res.status(429).json({
|
|
191
|
-
error: 'Too Many Requests',
|
|
192
|
-
message: 'Your IP address has been temporarily blocked due to suspicious activity.',
|
|
193
|
-
retryAfter: retryAfterSeconds
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
// If headers were already committed upstream, we cannot send 429.
|
|
197
|
-
// Safely terminate the open response without calling next().
|
|
198
|
-
safelyEndResponse(res);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Monitor mode: mark warning header, but allow request through
|
|
203
|
-
safeSetHeader(res, 'X-Z-Shield-Suspicious', '1');
|
|
204
|
-
return next();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Step 2: Rate limit tracking and abuse detection
|
|
208
|
-
const detection = detector.check(clientIp, req, now);
|
|
209
|
-
|
|
210
|
-
let logPromise = null;
|
|
211
|
-
if (detection.isSuspicious) {
|
|
212
|
-
const action = config.mode === 'protect' ? 'blocked' : 'warned';
|
|
213
|
-
logPromise = logger.log({
|
|
214
|
-
appName: config.appName,
|
|
215
|
-
type: detection.type || 'rate_limit',
|
|
216
|
-
severity: detection.severity || 'warning',
|
|
217
|
-
ip: clientIp,
|
|
218
|
-
method: requestMethod,
|
|
219
|
-
route: requestPath,
|
|
220
|
-
reason: detection.reason,
|
|
221
|
-
action
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
req.zShield = {
|
|
226
|
-
ip: clientIp,
|
|
227
|
-
mode: config.mode,
|
|
228
|
-
appName: config.appName,
|
|
229
|
-
isExcluded: false,
|
|
230
|
-
startTime: now,
|
|
231
|
-
detection,
|
|
232
|
-
blocked: false,
|
|
233
|
-
logPromise
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
// Set informative headers
|
|
237
|
-
safeSetHeader(res, 'X-Z-Shield-Protected', '1');
|
|
238
|
-
safeSetHeader(res, 'X-Z-Shield-Mode', config.mode);
|
|
239
|
-
safeSetHeader(res, 'X-RateLimit-Limit', String(config.maxRequests));
|
|
240
|
-
safeSetHeader(res, 'X-RateLimit-Remaining', String(detection.remaining));
|
|
241
|
-
safeSetHeader(res, 'X-RateLimit-Reset', String(Math.ceil(detection.resetTime / 1000)));
|
|
242
|
-
|
|
243
|
-
if (detection.isSuspicious) {
|
|
244
|
-
safeSetHeader(res, 'X-Z-Shield-Suspicious', '1');
|
|
245
|
-
safeSetHeader(res, 'X-Z-Shield-Reason', detection.reason);
|
|
246
|
-
|
|
247
|
-
if (config.mode === 'protect') {
|
|
248
|
-
// If autoBlock is enabled and IP is valid, add to block manager.
|
|
249
|
-
// Unknown or invalid IPs must not be passed to blockManager.block()
|
|
250
|
-
// as that would throw a TypeError and cause an unexpected HTTP 500.
|
|
251
|
-
if (config.autoBlock && isValidIp(clientIp)) {
|
|
252
|
-
blockManager.block(clientIp, config.blockDurationMs, detection.reason, now);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const retryAfterSeconds = Math.max(1, Math.ceil((detection.resetTime - now) / 1000));
|
|
256
|
-
safeSetHeader(res, 'Retry-After', String(retryAfterSeconds));
|
|
257
|
-
|
|
258
|
-
if (!res.headersSent) {
|
|
259
|
-
return res.status(429).json({
|
|
260
|
-
error: 'Too Many Requests',
|
|
261
|
-
message: 'Request threshold exceeded. Please try again later.',
|
|
262
|
-
retryAfter: retryAfterSeconds
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
// If headers were already committed upstream, we cannot send 429.
|
|
266
|
-
// Safely terminate the open response without calling next().
|
|
267
|
-
safelyEndResponse(res);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// In monitor mode or normal traffic in protect mode: allow through
|
|
273
|
-
return next();
|
|
274
|
-
} catch (err) {
|
|
275
|
-
// If an unexpected error occurs in middleware execution, pass it safely to Express
|
|
276
|
-
// error handling without crashing the entire Node.js server process.
|
|
277
|
-
return next(err);
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
module.exports = {
|
|
283
|
-
createMiddleware,
|
|
284
|
-
extractClientIp,
|
|
285
|
-
isRouteExcluded,
|
|
286
|
-
normalizeIp,
|
|
287
|
-
safelyEndResponse
|
|
288
|
-
};
|
package/src/rate-limiter.js
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const { normalizeIp } = require('./block-manager');
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* RateLimiter - Bounded In-Memory Request Counter
|
|
7
|
-
*
|
|
8
|
-
* Algorithm: Fixed Window with Per-IP Dynamic Reset Time
|
|
9
|
-
* - On an IP's first request, we establish a time window (now -> now + windowMs).
|
|
10
|
-
* - Every subsequent request within the window increments the counter.
|
|
11
|
-
* - When the window expires, the counter resets to 1 with a new window.
|
|
12
|
-
*
|
|
13
|
-
* Memory Protection (DoS defense):
|
|
14
|
-
* - JavaScript's Map preserves insertion order.
|
|
15
|
-
* - By deleting and re-setting accessed keys, the Map functions as an LRU (Least Recently Used) cache.
|
|
16
|
-
* - If the number of tracked IPs reaches `maxTrackedIps`, we evict the oldest entry.
|
|
17
|
-
* This ensures memory consumption is strictly bounded and cannot crash the Node.js process.
|
|
18
|
-
*/
|
|
19
|
-
class RateLimiter {
|
|
20
|
-
/**
|
|
21
|
-
* @param {Object} options
|
|
22
|
-
* @param {number} [options.windowMs=60000] - Window length in milliseconds
|
|
23
|
-
* @param {number} [options.maxRequests=100] - Max allowed requests per window
|
|
24
|
-
* @param {number} [options.maxTrackedIps=10000] - Maximum unique IPs tracked in memory
|
|
25
|
-
*/
|
|
26
|
-
constructor(options = {}) {
|
|
27
|
-
this.windowMs = options.windowMs || 60000;
|
|
28
|
-
this.maxRequests = options.maxRequests || 100;
|
|
29
|
-
this.maxTrackedIps = options.maxTrackedIps || 10000;
|
|
30
|
-
|
|
31
|
-
/** @type {Map<string, { count: number, resetTime: number }>} */
|
|
32
|
-
this.records = new Map();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Records an incoming request from an IP address.
|
|
37
|
-
*
|
|
38
|
-
* @param {string} ip - Client IP address
|
|
39
|
-
* @param {number} [now=Date.now()] - Current timestamp (injectable for testing)
|
|
40
|
-
* @returns {{ count: number, remaining: number, resetTime: number, isExceeded: boolean }}
|
|
41
|
-
*/
|
|
42
|
-
record(ip, now = Date.now()) {
|
|
43
|
-
const cleanIp = normalizeIp(ip);
|
|
44
|
-
|
|
45
|
-
let entry = this.records.get(cleanIp);
|
|
46
|
-
|
|
47
|
-
// If an existing entry has expired, treat it as a fresh window
|
|
48
|
-
if (entry && now >= entry.resetTime) {
|
|
49
|
-
entry = null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (entry) {
|
|
53
|
-
// Existing active window: increment count
|
|
54
|
-
entry.count += 1;
|
|
55
|
-
|
|
56
|
-
// Refresh LRU order: delete and re-set so this IP is marked as most recently used
|
|
57
|
-
this.records.delete(cleanIp);
|
|
58
|
-
this.records.set(cleanIp, entry);
|
|
59
|
-
} else {
|
|
60
|
-
// New window or expired window: only evict if adding a NEW IP at capacity
|
|
61
|
-
if (!this.records.has(cleanIp) && this.records.size >= this.maxTrackedIps) {
|
|
62
|
-
this._evictOldest(now);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
entry = {
|
|
66
|
-
count: 1,
|
|
67
|
-
resetTime: now + this.windowMs
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Delete first to ensure refreshed LRU order in Map
|
|
71
|
-
this.records.delete(cleanIp);
|
|
72
|
-
this.records.set(cleanIp, entry);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const remaining = Math.max(0, this.maxRequests - entry.count);
|
|
76
|
-
const isExceeded = entry.count > this.maxRequests;
|
|
77
|
-
|
|
78
|
-
return {
|
|
79
|
-
count: entry.count,
|
|
80
|
-
remaining,
|
|
81
|
-
resetTime: entry.resetTime,
|
|
82
|
-
isExceeded
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Inspects current status for an IP without incrementing its counter.
|
|
88
|
-
*
|
|
89
|
-
* @param {string} ip
|
|
90
|
-
* @param {number} [now=Date.now()]
|
|
91
|
-
* @returns {{ count: number, remaining: number, resetTime: number, isExceeded: boolean }}
|
|
92
|
-
*/
|
|
93
|
-
get(ip, now = Date.now()) {
|
|
94
|
-
const cleanIp = normalizeIp(ip);
|
|
95
|
-
const entry = this.records.get(cleanIp);
|
|
96
|
-
|
|
97
|
-
if (!entry || now >= entry.resetTime) {
|
|
98
|
-
return {
|
|
99
|
-
count: 0,
|
|
100
|
-
remaining: this.maxRequests,
|
|
101
|
-
resetTime: now + this.windowMs,
|
|
102
|
-
isExceeded: false
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
count: entry.count,
|
|
108
|
-
remaining: Math.max(0, this.maxRequests - entry.count),
|
|
109
|
-
resetTime: entry.resetTime,
|
|
110
|
-
isExceeded: entry.count > this.maxRequests
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Resets counter for a specific IP.
|
|
116
|
-
* @param {string} ip
|
|
117
|
-
*/
|
|
118
|
-
reset(ip) {
|
|
119
|
-
const cleanIp = normalizeIp(ip);
|
|
120
|
-
this.records.delete(cleanIp);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Clears all tracked IP records from memory.
|
|
125
|
-
*/
|
|
126
|
-
clear() {
|
|
127
|
-
this.records.clear();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Number of IPs currently stored in memory.
|
|
132
|
-
* @returns {number}
|
|
133
|
-
*/
|
|
134
|
-
get size() {
|
|
135
|
-
return this.records.size;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Scans and removes any expired counters from memory.
|
|
140
|
-
* Because Map maintains insertion order, oldest entries are checked first.
|
|
141
|
-
*
|
|
142
|
-
* @param {number} [now=Date.now()]
|
|
143
|
-
* @returns {number} Count of removed expired entries
|
|
144
|
-
*/
|
|
145
|
-
pruneExpired(now = Date.now()) {
|
|
146
|
-
let pruned = 0;
|
|
147
|
-
for (const [ip, entry] of this.records.entries()) {
|
|
148
|
-
if (now >= entry.resetTime) {
|
|
149
|
-
this.records.delete(ip);
|
|
150
|
-
pruned++;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
return pruned;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Evicts the oldest entry to maintain bounded memory.
|
|
158
|
-
* Prefers to evict an expired entry first; if none are expired, evicts the LRU entry.
|
|
159
|
-
*
|
|
160
|
-
* @private
|
|
161
|
-
* @param {number} now
|
|
162
|
-
*/
|
|
163
|
-
_evictOldest(now) {
|
|
164
|
-
const iterator = this.records.entries();
|
|
165
|
-
const first = iterator.next().value;
|
|
166
|
-
|
|
167
|
-
if (!first) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const [oldestIp, oldestEntry] = first;
|
|
172
|
-
|
|
173
|
-
// If oldest is already expired, delete it
|
|
174
|
-
if (now >= oldestEntry.resetTime) {
|
|
175
|
-
this.records.delete(oldestIp);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Otherwise evict least recently used entry to strictly uphold memory bound
|
|
180
|
-
this.records.delete(oldestIp);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
module.exports = {
|
|
185
|
-
RateLimiter
|
|
186
|
-
};
|