nextlimiter 1.0.4 → 1.0.6
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 +1 -1
- package/src/analytics/prometheus.js +122 -0
- package/src/core/accessControl.js +38 -0
- package/src/core/config.js +26 -0
- package/src/core/limiter.js +88 -0
- package/src/index.js +9 -0
- package/src/utils/cidr.js +136 -0
- package/src/utils/keyGenerator.js +10 -1
- package/types/index.d.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nextlimiter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure Node.js Prometheus Exposition Formatter
|
|
5
|
+
* Zero dependencies. Converts limiter stats to valid Prometheus text format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class PrometheusFormatter {
|
|
9
|
+
constructor(limiter) {
|
|
10
|
+
this.limiter = limiter;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
contentType() {
|
|
14
|
+
return 'text/plain; version=0.0.4; charset=utf-8';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Escape label values according to Prometheus rules:
|
|
19
|
+
* \ -> \\
|
|
20
|
+
* " -> \"
|
|
21
|
+
* \n -> \n
|
|
22
|
+
*/
|
|
23
|
+
_escapeLabel(str) {
|
|
24
|
+
return String(str)
|
|
25
|
+
.replace(/\\/g, '\\\\')
|
|
26
|
+
.replace(/"/g, '\\"')
|
|
27
|
+
.replace(/\n/g, '\\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format a single metric block (help, type, and samples).
|
|
32
|
+
*/
|
|
33
|
+
_metricBlock(name, help, type, samples) {
|
|
34
|
+
if (!samples || samples.length === 0) return '';
|
|
35
|
+
let block = `# HELP ${name} ${help}\n`;
|
|
36
|
+
block += `# TYPE ${name} ${type}\n`;
|
|
37
|
+
|
|
38
|
+
for (const sample of samples) {
|
|
39
|
+
const labelEntries = Object.entries(sample.labels || {});
|
|
40
|
+
if (labelEntries.length > 0) {
|
|
41
|
+
const labelStrs = labelEntries.map(([k, v]) => `${k}="${this._escapeLabel(v)}"`);
|
|
42
|
+
block += `${name}{${labelStrs.join(',')}} ${sample.value}\n`;
|
|
43
|
+
} else {
|
|
44
|
+
block += `${name} ${sample.value}\n`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return block + '\n';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
format() {
|
|
51
|
+
const stats = this.limiter.getStats();
|
|
52
|
+
let output = '';
|
|
53
|
+
|
|
54
|
+
// 1. Total requests (Counter)
|
|
55
|
+
output += this._metricBlock(
|
|
56
|
+
'nextlimiter_requests_total',
|
|
57
|
+
'Total requests processed by nextlimiter',
|
|
58
|
+
'counter',
|
|
59
|
+
[
|
|
60
|
+
{ labels: { status: 'allowed' }, value: stats.allowedRequests },
|
|
61
|
+
{ labels: { status: 'blocked' }, value: stats.blockedRequests }
|
|
62
|
+
]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// 2. Block rate (Gauge)
|
|
66
|
+
output += this._metricBlock(
|
|
67
|
+
'nextlimiter_block_rate',
|
|
68
|
+
'Ratio of blocked to total requests (0.0 to 1.0)',
|
|
69
|
+
'gauge',
|
|
70
|
+
[{ labels: {}, value: isNaN(stats.blockRate) ? 0 : stats.blockRate }]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// 3. Top blocked IPs (Gauge)
|
|
74
|
+
const topBlockedSamples = (stats.topBlocked || []).slice(0, 10).map((item) => {
|
|
75
|
+
// Clean up IP label
|
|
76
|
+
let ip = item.key.replace(/^nextlimiter:(ip:)?/, '');
|
|
77
|
+
return { labels: { ip }, value: item.count };
|
|
78
|
+
});
|
|
79
|
+
if (topBlockedSamples.length > 0) {
|
|
80
|
+
output += this._metricBlock(
|
|
81
|
+
'nextlimiter_top_blocked_ip',
|
|
82
|
+
'Block count per offending IP',
|
|
83
|
+
'gauge',
|
|
84
|
+
topBlockedSamples
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 4. Top keys by volume (Gauge)
|
|
89
|
+
const topKeysSamples = (stats.topKeys || []).slice(0, 10).map((item) => {
|
|
90
|
+
return { labels: { key: item.key }, value: item.count };
|
|
91
|
+
});
|
|
92
|
+
if (topKeysSamples.length > 0) {
|
|
93
|
+
output += this._metricBlock(
|
|
94
|
+
'nextlimiter_top_keys_total',
|
|
95
|
+
'Total request count per key',
|
|
96
|
+
'gauge',
|
|
97
|
+
topKeysSamples
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 5. Uptime seconds (Gauge)
|
|
102
|
+
output += this._metricBlock(
|
|
103
|
+
'nextlimiter_uptime_seconds',
|
|
104
|
+
'Seconds since limiter was created',
|
|
105
|
+
'gauge',
|
|
106
|
+
[{ labels: {}, value: stats.uptimeMs / 1000 }]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// 6. Info (Gauge = 1)
|
|
110
|
+
const version = '1.0.5'; // Current pkg version
|
|
111
|
+
output += this._metricBlock(
|
|
112
|
+
'nextlimiter_info',
|
|
113
|
+
'Static info about the limiter instance',
|
|
114
|
+
'gauge',
|
|
115
|
+
[{ labels: { strategy: stats.config.strategy, version }, value: 1 }]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return output;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = { PrometheusFormatter };
|
|
@@ -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 };
|
package/src/core/config.js
CHANGED
|
@@ -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
|
|
package/src/core/limiter.js
CHANGED
|
@@ -6,10 +6,13 @@ 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');
|
|
15
|
+
const { PrometheusFormatter } = require('../analytics/prometheus');
|
|
13
16
|
|
|
14
17
|
const STRATEGY_MAP = {
|
|
15
18
|
'fixed-window': fixedWindowCheck,
|
|
@@ -82,6 +85,23 @@ class Limiter {
|
|
|
82
85
|
return next();
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
// ── Access control (whitelist / blacklist) ───────────────────────────
|
|
89
|
+
const clientIp = extractIp(req);
|
|
90
|
+
const access = checkAccess(clientIp, this._config);
|
|
91
|
+
|
|
92
|
+
if (access.action === 'block') {
|
|
93
|
+
return res.status(403).json({
|
|
94
|
+
error: 'Forbidden',
|
|
95
|
+
message: 'Your IP address has been blocked.',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (access.action === 'skip') {
|
|
100
|
+
// Whitelisted — bypass all rate limiting, proceed immediately
|
|
101
|
+
return next();
|
|
102
|
+
}
|
|
103
|
+
// ────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
85
105
|
const rawKey = this._keyGenerator(req);
|
|
86
106
|
const key = `${this._config.keyPrefix}${rawKey}`;
|
|
87
107
|
|
|
@@ -127,6 +147,41 @@ class Limiter {
|
|
|
127
147
|
};
|
|
128
148
|
}
|
|
129
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Express route handler for serving Prometheus metrics.
|
|
152
|
+
* Exposes getStats() data in plain text format (version=0.0.4).
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* app.get('/metrics', limiter.metricsHandler());
|
|
156
|
+
*/
|
|
157
|
+
metricsHandler() {
|
|
158
|
+
return (req, res) => {
|
|
159
|
+
try {
|
|
160
|
+
const formatter = new PrometheusFormatter(this);
|
|
161
|
+
res.set('Content-Type', formatter.contentType());
|
|
162
|
+
res.send(formatter.format());
|
|
163
|
+
} catch (err) {
|
|
164
|
+
res.status(500).type('text/plain').send(`Error generating metrics: ${err.message}`);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Global middleware that automatically intercepts GET /metrics requests
|
|
171
|
+
* and serves the Prometheus exposition format. Passes through all other routes.
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* app.use(limiter.metricsMiddleware());
|
|
175
|
+
*/
|
|
176
|
+
metricsMiddleware() {
|
|
177
|
+
return (req, res, next) => {
|
|
178
|
+
if (req.method === 'GET' && req.path === '/metrics') {
|
|
179
|
+
return this.metricsHandler()(req, res);
|
|
180
|
+
}
|
|
181
|
+
next();
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
130
185
|
/**
|
|
131
186
|
* Programmatic rate limit check — use outside of HTTP middleware context.
|
|
132
187
|
*
|
|
@@ -138,6 +193,39 @@ class Limiter {
|
|
|
138
193
|
* if (!result.allowed) throw new Error('Rate limit exceeded');
|
|
139
194
|
*/
|
|
140
195
|
async check(key) {
|
|
196
|
+
// Apply access control if the key looks like a plain IP address
|
|
197
|
+
const looksLikeIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(String(key).trim());
|
|
198
|
+
if (looksLikeIp) {
|
|
199
|
+
const access = checkAccess(key, this._config);
|
|
200
|
+
if (access.action === 'block') {
|
|
201
|
+
return {
|
|
202
|
+
allowed: false,
|
|
203
|
+
limit: this._config.max,
|
|
204
|
+
remaining: 0,
|
|
205
|
+
resetAt: Date.now(),
|
|
206
|
+
retryAfter: 0,
|
|
207
|
+
key,
|
|
208
|
+
strategy: this._config.strategy,
|
|
209
|
+
smartBlocked: false,
|
|
210
|
+
blocked: true,
|
|
211
|
+
reason: 'blacklisted',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (access.action === 'skip') {
|
|
215
|
+
return {
|
|
216
|
+
allowed: true,
|
|
217
|
+
limit: this._config.max,
|
|
218
|
+
remaining: Infinity,
|
|
219
|
+
resetAt: Date.now() + this._config.windowMs,
|
|
220
|
+
retryAfter: 0,
|
|
221
|
+
key,
|
|
222
|
+
strategy: this._config.strategy,
|
|
223
|
+
smartBlocked: false,
|
|
224
|
+
reason: 'whitelisted',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
141
229
|
const fullKey = `${this._config.keyPrefix}${key}`;
|
|
142
230
|
const result = await this._runCheck(fullKey);
|
|
143
231
|
this._analytics.record(fullKey, result.allowed);
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,8 @@ 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');
|
|
8
|
+
const { PrometheusFormatter } = require('./analytics/prometheus');
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Create a fully configured rate limiter instance.
|
|
@@ -102,6 +104,13 @@ module.exports = {
|
|
|
102
104
|
MemoryStore,
|
|
103
105
|
RedisStore,
|
|
104
106
|
|
|
107
|
+
// CIDR utilities (for advanced use)
|
|
108
|
+
ipMatchesCidr,
|
|
109
|
+
ipMatchesList,
|
|
110
|
+
|
|
111
|
+
// Analytics export
|
|
112
|
+
PrometheusFormatter,
|
|
113
|
+
|
|
105
114
|
// Constants
|
|
106
115
|
PRESETS,
|
|
107
116
|
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 ────────────────────────────────────────────────────────
|
|
@@ -133,6 +139,12 @@ export interface RateLimitResult {
|
|
|
133
139
|
|
|
134
140
|
// ── Analytics ────────────────────────────────────────────────────────────────
|
|
135
141
|
|
|
142
|
+
export declare class PrometheusFormatter {
|
|
143
|
+
constructor(limiter: Limiter);
|
|
144
|
+
format(): string;
|
|
145
|
+
contentType(): string;
|
|
146
|
+
}
|
|
147
|
+
|
|
136
148
|
export interface KeyCount {
|
|
137
149
|
key: string;
|
|
138
150
|
count: number;
|
|
@@ -175,6 +187,18 @@ export declare class Limiter {
|
|
|
175
187
|
/** Returns an Express-compatible middleware function */
|
|
176
188
|
middleware(): (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
177
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Express route handler for serving Prometheus metrics.
|
|
192
|
+
* Exposes getStats() data in plain text format (version=0.0.4).
|
|
193
|
+
*/
|
|
194
|
+
metricsHandler(): (req: any, res: any) => void;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Global middleware that automatically intercepts GET /metrics requests.
|
|
198
|
+
* Passes through all other routes.
|
|
199
|
+
*/
|
|
200
|
+
metricsMiddleware(): (req: any, res: any, next: any) => void;
|
|
201
|
+
|
|
178
202
|
/** Programmatic rate limit check by key string */
|
|
179
203
|
check(key: string): Promise<RateLimitResult>;
|
|
180
204
|
|
|
@@ -271,6 +295,18 @@ export declare class RedisStore implements Store {
|
|
|
271
295
|
clear(): void;
|
|
272
296
|
}
|
|
273
297
|
|
|
298
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Check whether an IP matches a CIDR range string.
|
|
302
|
+
* Supports standard CIDR (10.0.0.0/8) and exact exact IPs (1.2.3.4).
|
|
303
|
+
*/
|
|
304
|
+
export declare function ipMatchesCidr(ip: string, cidr: string): boolean;
|
|
305
|
+
|
|
306
|
+
/** Check whether an IP matches ANY element in a list of IPs / CIDR ranges. */
|
|
307
|
+
export declare function ipMatchesList(ip: string, list: string[]): boolean;
|
|
308
|
+
|
|
309
|
+
|
|
274
310
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
275
311
|
|
|
276
312
|
export declare const PRESETS: Record<BuiltInPreset, LimiterOptions>;
|