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.
Files changed (52) hide show
  1. package/README.md +123 -6
  2. package/dist/block-manager.d.ts +134 -0
  3. package/dist/block-manager.d.ts.map +1 -0
  4. package/dist/block-manager.js +425 -0
  5. package/dist/block-manager.js.map +1 -0
  6. package/dist/cli.d.ts +141 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +784 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +15 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +148 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/detector.d.ts +56 -0
  15. package/dist/detector.d.ts.map +1 -0
  16. package/dist/detector.js +81 -0
  17. package/dist/detector.js.map +1 -0
  18. package/dist/index.d.ts +29 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +120 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/logger.d.ts +141 -0
  23. package/dist/logger.d.ts.map +1 -0
  24. package/dist/logger.js +347 -0
  25. package/dist/logger.js.map +1 -0
  26. package/dist/middleware.d.ts +68 -0
  27. package/dist/middleware.d.ts.map +1 -0
  28. package/dist/middleware.js +272 -0
  29. package/dist/middleware.js.map +1 -0
  30. package/dist/next.d.ts +73 -0
  31. package/dist/next.d.ts.map +1 -0
  32. package/dist/next.js +486 -0
  33. package/dist/next.js.map +1 -0
  34. package/dist/rate-limiter.d.ts +75 -0
  35. package/dist/rate-limiter.d.ts.map +1 -0
  36. package/dist/rate-limiter.js +153 -0
  37. package/dist/rate-limiter.js.map +1 -0
  38. package/dist/types.d.ts +199 -0
  39. package/dist/types.d.ts.map +1 -0
  40. package/dist/types.js +6 -0
  41. package/dist/types.js.map +1 -0
  42. package/next.d.ts +1 -0
  43. package/next.js +2 -0
  44. package/package.json +34 -5
  45. package/src/block-manager.js +0 -326
  46. package/src/cli.js +0 -764
  47. package/src/config.js +0 -165
  48. package/src/detector.js +0 -88
  49. package/src/index.js +0 -97
  50. package/src/logger.js +0 -280
  51. package/src/middleware.js +0 -288
  52. 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
- };
@@ -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
- };