nextlimiter 1.0.4 → 1.0.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nextlimiter",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Production-ready rate limiting for Node.js — sliding window, token bucket, SaaS plans, smart limiting, and built-in analytics.",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const { ipMatchesList } = require('../utils/cidr');
4
+
5
+ /**
6
+ * Access control check — evaluated BEFORE rate limiting.
7
+ *
8
+ * Precedence: blacklist > whitelist > allow
9
+ *
10
+ * @param {string} ip - Client's real IP address
11
+ * @param {object} config - Resolved NextLimiter config
12
+ * @returns {{ action: 'allow'|'block'|'skip', reason: string }}
13
+ *
14
+ * @example
15
+ * checkAccess('5.6.1.1', { blacklist: ['5.6.0.0/16'] })
16
+ * // → { action: 'block', reason: 'blacklisted' }
17
+ *
18
+ * checkAccess('10.0.5.5', { whitelist: ['10.0.0.0/8'] })
19
+ * // → { action: 'skip', reason: 'whitelisted' }
20
+ *
21
+ * checkAccess('1.2.3.4', {})
22
+ * // → { action: 'allow', reason: 'proceed' }
23
+ */
24
+ function checkAccess(ip, config) {
25
+ // Blacklist wins — always checked first regardless of whitelist
26
+ if (config.blacklist && ipMatchesList(ip, config.blacklist)) {
27
+ return { action: 'block', reason: 'blacklisted' };
28
+ }
29
+
30
+ // Whitelist — bypass all rate limiting
31
+ if (config.whitelist && ipMatchesList(ip, config.whitelist)) {
32
+ return { action: 'skip', reason: 'whitelisted' };
33
+ }
34
+
35
+ return { action: 'allow', reason: 'proceed' };
36
+ }
37
+
38
+ module.exports = { checkAccess };
@@ -85,6 +85,8 @@ const DEFAULT_CONFIG = {
85
85
  plans: DEFAULT_PLANS, // plan map — override to define custom plans
86
86
  preset: null, // 'strict' | 'relaxed' | 'api' | 'auth'
87
87
  keyGenerator: null, // (req) => string — custom key fn
88
+ whitelist: null, // string[] — IPs/CIDRs that bypass rate limiting
89
+ blacklist: null, // string[] — IPs/CIDRs that always get 403
88
90
  };
89
91
 
90
92
  /**
@@ -121,6 +123,30 @@ function resolveConfig(userOptions = {}) {
121
123
  if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
122
124
  if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
123
125
 
126
+ // Validate whitelist / blacklist (warn, never throw)
127
+ for (const listName of ['whitelist', 'blacklist']) {
128
+ const list = base[listName];
129
+ if (list == null) continue;
130
+ if (!Array.isArray(list)) {
131
+ console.warn(`[NextLimiter] config.${listName} must be an array. Ignoring.`);
132
+ base[listName] = null;
133
+ continue;
134
+ }
135
+ const valid = [];
136
+ for (const entry of list) {
137
+ if (typeof entry !== 'string' || entry.trim() === '') {
138
+ console.warn(`[NextLimiter] config.${listName}: skipping invalid entry:`, entry);
139
+ continue;
140
+ }
141
+ // Loose format check: must look like x.x.x.x or x.x.x.x/n
142
+ if (!/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/.test(entry.trim())) {
143
+ console.warn(`[NextLimiter] config.${listName}: entry "${entry}" doesn't look like a valid IP or CIDR. It will be attempted anyway.`);
144
+ }
145
+ valid.push(entry.trim());
146
+ }
147
+ base[listName] = valid.length > 0 ? valid : null;
148
+ }
149
+
124
150
  return base;
125
151
  }
126
152
 
@@ -6,10 +6,12 @@ const { fixedWindowCheck } = require('../strategies/fixedWindow');
6
6
  const { slidingWindowCheck } = require('../strategies/slidingWindow');
7
7
  const { tokenBucketCheck } = require('../strategies/tokenBucket');
8
8
  const { resolveKeyGenerator } = require('../utils/keyGenerator');
9
+ const { extractIp } = require('../utils/keyGenerator');
9
10
  const { createLogger } = require('../utils/logger');
10
11
  const { AnalyticsTracker } = require('../analytics/tracker');
11
12
  const { SmartDetector } = require('../smart/detector');
12
13
  const { setHeaders } = require('../middleware/headers');
14
+ const { checkAccess } = require('./accessControl');
13
15
 
14
16
  const STRATEGY_MAP = {
15
17
  'fixed-window': fixedWindowCheck,
@@ -82,6 +84,23 @@ class Limiter {
82
84
  return next();
83
85
  }
84
86
 
87
+ // ── Access control (whitelist / blacklist) ───────────────────────────
88
+ const clientIp = extractIp(req);
89
+ const access = checkAccess(clientIp, this._config);
90
+
91
+ if (access.action === 'block') {
92
+ return res.status(403).json({
93
+ error: 'Forbidden',
94
+ message: 'Your IP address has been blocked.',
95
+ });
96
+ }
97
+
98
+ if (access.action === 'skip') {
99
+ // Whitelisted — bypass all rate limiting, proceed immediately
100
+ return next();
101
+ }
102
+ // ────────────────────────────────────────────────────────────────────
103
+
85
104
  const rawKey = this._keyGenerator(req);
86
105
  const key = `${this._config.keyPrefix}${rawKey}`;
87
106
 
@@ -138,6 +157,39 @@ class Limiter {
138
157
  * if (!result.allowed) throw new Error('Rate limit exceeded');
139
158
  */
140
159
  async check(key) {
160
+ // Apply access control if the key looks like a plain IP address
161
+ const looksLikeIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(String(key).trim());
162
+ if (looksLikeIp) {
163
+ const access = checkAccess(key, this._config);
164
+ if (access.action === 'block') {
165
+ return {
166
+ allowed: false,
167
+ limit: this._config.max,
168
+ remaining: 0,
169
+ resetAt: Date.now(),
170
+ retryAfter: 0,
171
+ key,
172
+ strategy: this._config.strategy,
173
+ smartBlocked: false,
174
+ blocked: true,
175
+ reason: 'blacklisted',
176
+ };
177
+ }
178
+ if (access.action === 'skip') {
179
+ return {
180
+ allowed: true,
181
+ limit: this._config.max,
182
+ remaining: Infinity,
183
+ resetAt: Date.now() + this._config.windowMs,
184
+ retryAfter: 0,
185
+ key,
186
+ strategy: this._config.strategy,
187
+ smartBlocked: false,
188
+ reason: 'whitelisted',
189
+ };
190
+ }
191
+ }
192
+
141
193
  const fullKey = `${this._config.keyPrefix}${key}`;
142
194
  const result = await this._runCheck(fullKey);
143
195
  this._analytics.record(fullKey, result.allowed);
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ const { Limiter } = require('./core/limiter');
4
4
  const { PRESETS, DEFAULT_PLANS } = require('./core/config');
5
5
  const { MemoryStore } = require('./store/memoryStore');
6
6
  const { RedisStore } = require('./store/redisStore');
7
+ const { ipMatchesCidr, ipMatchesList } = require('./utils/cidr');
7
8
 
8
9
  /**
9
10
  * Create a fully configured rate limiter instance.
@@ -102,6 +103,10 @@ module.exports = {
102
103
  MemoryStore,
103
104
  RedisStore,
104
105
 
106
+ // CIDR utilities (for advanced use)
107
+ ipMatchesCidr,
108
+ ipMatchesList,
109
+
105
110
  // Constants
106
111
  PRESETS,
107
112
  DEFAULT_PLANS,
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * CIDR IP matching utilities — zero dependencies, pure bitwise arithmetic.
5
+ * Works in any runtime: Node.js, Cloudflare Workers, Deno, Bun, Edge.
6
+ *
7
+ * No Buffer, no `net` module, no external libraries.
8
+ */
9
+
10
+ /**
11
+ * Convert an IPv4 dotted-decimal string to a 32-bit unsigned integer.
12
+ *
13
+ * @param {string} ip - e.g. '192.168.1.50'
14
+ * @returns {number} 32-bit unsigned integer
15
+ * @throws {Error} if the string is not a valid IPv4 address
16
+ *
17
+ * @example
18
+ * ipToInt('1.2.3.4') // → 16909060
19
+ * ipToInt('0.0.0.0') // → 0
20
+ * ipToInt('255.255.255.255') // → 4294967295
21
+ */
22
+ function ipToInt(ip) {
23
+ const parts = String(ip).split('.');
24
+ if (parts.length !== 4) {
25
+ throw new Error(`[NextLimiter] Invalid IPv4 address: "${ip}"`);
26
+ }
27
+ let result = 0;
28
+ for (const part of parts) {
29
+ const octet = parseInt(part, 10);
30
+ if (isNaN(octet) || octet < 0 || octet > 255 || String(octet) !== part.trim()) {
31
+ throw new Error(`[NextLimiter] Invalid IPv4 octet "${part}" in address "${ip}"`);
32
+ }
33
+ result = ((result << 8) | octet) >>> 0;
34
+ }
35
+ return result >>> 0; // ensure unsigned
36
+ }
37
+
38
+ /**
39
+ * Convert a CIDR string (or plain IP) to a { networkInt, maskInt } range.
40
+ *
41
+ * - '10.0.0.0/8' → { networkInt: 167772160, maskInt: 4278190080 }
42
+ * - '1.2.3.4' → treated as /32 (exact match only)
43
+ *
44
+ * @param {string} cidr - e.g. '192.168.1.0/24' or '1.2.3.4'
45
+ * @returns {{ networkInt: number, maskInt: number }}
46
+ * @throws {Error} on invalid CIDR
47
+ *
48
+ * @example
49
+ * cidrToRange('10.0.0.0/8')
50
+ * // → { networkInt: 167772160, maskInt: 4278190080 }
51
+ *
52
+ * cidrToRange('192.168.1.0/24')
53
+ * // → { networkInt: 3232235776, maskInt: 4294967040 }
54
+ */
55
+ function cidrToRange(cidr) {
56
+ const slashIdx = String(cidr).indexOf('/');
57
+
58
+ if (slashIdx === -1) {
59
+ // Plain IP — treat as /32
60
+ const networkInt = ipToInt(cidr);
61
+ return { networkInt, maskInt: 0xFFFFFFFF >>> 0 };
62
+ }
63
+
64
+ const ip = cidr.slice(0, slashIdx);
65
+ const prefix = parseInt(cidr.slice(slashIdx + 1), 10);
66
+
67
+ if (isNaN(prefix) || prefix < 0 || prefix > 32) {
68
+ throw new Error(`[NextLimiter] Invalid CIDR prefix in "${cidr}"`);
69
+ }
70
+
71
+ // Build the subnet mask: ( all 1s ) left-shifted by (32 - prefix), force unsigned
72
+ // Special case: prefix 0 → mask is 0 (matches everything)
73
+ const maskInt = prefix === 0 ? 0 : ((0xFFFFFFFF << (32 - prefix)) >>> 0);
74
+ const networkInt = (ipToInt(ip) & maskInt) >>> 0;
75
+
76
+ return { networkInt, maskInt };
77
+ }
78
+
79
+ /**
80
+ * Check whether an IP address falls within a CIDR range (or matches a plain IP).
81
+ *
82
+ * @param {string} ip - e.g. '10.0.1.5'
83
+ * @param {string} cidr - e.g. '10.0.0.0/8' or '10.0.1.5'
84
+ * @returns {boolean}
85
+ *
86
+ * @example
87
+ * ipMatchesCidr('10.0.1.5', '10.0.0.0/8') // → true
88
+ * ipMatchesCidr('192.168.1.50','192.168.1.0/24') // → true
89
+ * ipMatchesCidr('1.2.3.4', '1.2.3.4') // → true (exact /32)
90
+ * ipMatchesCidr('1.2.3.5', '1.2.3.4') // → false
91
+ * ipMatchesCidr('172.0.0.1', '10.0.0.0/8') // → false
92
+ */
93
+ function ipMatchesCidr(ip, cidr) {
94
+ try {
95
+ const ipInt = ipToInt(ip);
96
+ const range = cidrToRange(cidr);
97
+ return ((ipInt & range.maskInt) >>> 0) === range.networkInt;
98
+ } catch {
99
+ // Any parse failure → conservative answer: not a match
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Check whether an IP matches ANY entry in a list of IPs or CIDR ranges.
106
+ *
107
+ * @param {string} ip - Client IP to test
108
+ * @param {string[]} list - Array of IPs or CIDR strings
109
+ * @returns {boolean} true if ip matches at least one entry
110
+ *
111
+ * @example
112
+ * ipMatchesList('10.0.5.5', ['10.0.0.0/8', '192.168.1.1']) // → true
113
+ * ipMatchesList('11.0.0.1', ['10.0.0.0/8']) // → false
114
+ * ipMatchesList('1.2.3.4', []) // → false
115
+ */
116
+ function ipMatchesList(ip, list) {
117
+ if (!list || list.length === 0) return false;
118
+ for (const entry of list) {
119
+ if (ipMatchesCidr(ip, entry)) return true;
120
+ }
121
+ return false;
122
+ }
123
+
124
+ module.exports = { ipToInt, cidrToRange, ipMatchesCidr, ipMatchesList };
125
+
126
+ // ── Inline test cases (run with node src/utils/cidr.js) ──────────────────────
127
+ //
128
+ // ipMatchesCidr('10.0.1.5', '10.0.0.0/8') → true
129
+ // ipMatchesCidr('192.168.1.50','192.168.1.0/24') → true
130
+ // ipMatchesCidr('1.2.3.4', '1.2.3.4') → true (exact /32)
131
+ // ipMatchesCidr('1.2.3.5', '1.2.3.4') → false
132
+ // ipMatchesCidr('172.0.0.1', '10.0.0.0/8') → false
133
+ // ipMatchesList('10.0.5.5', ['10.0.0.0/8']) → true
134
+ // ipMatchesList('11.0.0.1', ['10.0.0.0/8']) → false
135
+ // ipMatchesList('5.6.1.1', ['5.6.0.0/16']) → true
136
+ // ipMatchesList('5.7.0.1', ['5.6.0.0/16']) → false
@@ -26,6 +26,15 @@ function getIP(req) {
26
26
  );
27
27
  }
28
28
 
29
+ /**
30
+ * Alias for getIP — exported for use by access control and other utils
31
+ * that need the raw client IP without a 'ip:' prefix.
32
+ *
33
+ * @param {object} req - Express / Node.js request object
34
+ * @returns {string}
35
+ */
36
+ const extractIp = getIP;
37
+
29
38
  /**
30
39
  * Rate limit by authenticated user ID.
31
40
  * Looks for userId in: req.user.id → req.user._id → req.userId → req.auth.userId
@@ -92,4 +101,4 @@ function resolveKeyGenerator(keyBy) {
92
101
  }
93
102
  }
94
103
 
95
- module.exports = { getIP, getUserId, getApiKey, resolveKeyGenerator };
104
+ module.exports = { getIP, extractIp, getUserId, getApiKey, resolveKeyGenerator };
package/types/index.d.ts CHANGED
@@ -96,6 +96,12 @@ export interface LimiterOptions {
96
96
 
97
97
  /** Fully custom key generator function */
98
98
  keyGenerator?: (req: Request) => string;
99
+
100
+ /** Array of IPs or CIDR ranges to bypass rate limiting */
101
+ whitelist?: string[];
102
+
103
+ /** Array of IPs or CIDR ranges to block immediately (403) */
104
+ blacklist?: string[];
99
105
  }
100
106
 
101
107
  // ── Rate limit result ────────────────────────────────────────────────────────
@@ -271,6 +277,18 @@ export declare class RedisStore implements Store {
271
277
  clear(): void;
272
278
  }
273
279
 
280
+ // ── Utilities ────────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Check whether an IP matches a CIDR range string.
284
+ * Supports standard CIDR (10.0.0.0/8) and exact exact IPs (1.2.3.4).
285
+ */
286
+ export declare function ipMatchesCidr(ip: string, cidr: string): boolean;
287
+
288
+ /** Check whether an IP matches ANY element in a list of IPs / CIDR ranges. */
289
+ export declare function ipMatchesList(ip: string, list: string[]): boolean;
290
+
291
+
274
292
  // ── Constants ────────────────────────────────────────────────────────────────
275
293
 
276
294
  export declare const PRESETS: Record<BuiltInPreset, LimiterOptions>;