nextlimiter 1.0.3 → 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 +20 -2
- package/src/adapters/fastify.js +80 -0
- package/src/adapters/hono.js +87 -0
- package/src/adapters/next.js +157 -0
- package/src/core/accessControl.js +38 -0
- package/src/core/config.js +26 -0
- package/src/core/limiter.js +52 -0
- package/src/index.js +5 -0
- package/src/utils/cidr.js +136 -0
- package/src/utils/keyGenerator.js +10 -1
- package/types/index.d.ts +67 -0
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nextlimiter",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./fastify": "./src/adapters/fastify.js",
|
|
10
|
+
"./next": "./src/adapters/next.js",
|
|
11
|
+
"./hono": "./src/adapters/hono.js"
|
|
12
|
+
},
|
|
7
13
|
"files": [
|
|
8
14
|
"src/",
|
|
9
15
|
"types/",
|
|
@@ -36,7 +42,10 @@
|
|
|
36
42
|
},
|
|
37
43
|
"peerDependencies": {
|
|
38
44
|
"express": ">=4.0.0",
|
|
39
|
-
"ioredis": ">=4.0.0"
|
|
45
|
+
"ioredis": ">=4.0.0",
|
|
46
|
+
"fastify": ">=4.0.0",
|
|
47
|
+
"fastify-plugin": ">=4.0.0",
|
|
48
|
+
"hono": ">=3.0.0"
|
|
40
49
|
},
|
|
41
50
|
"peerDependenciesMeta": {
|
|
42
51
|
"express": {
|
|
@@ -44,6 +53,15 @@
|
|
|
44
53
|
},
|
|
45
54
|
"ioredis": {
|
|
46
55
|
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"fastify": {
|
|
58
|
+
"optional": true
|
|
59
|
+
},
|
|
60
|
+
"fastify-plugin": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"hono": {
|
|
64
|
+
"optional": true
|
|
47
65
|
}
|
|
48
66
|
},
|
|
49
67
|
"devDependencies": {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NextLimiter — Fastify adapter
|
|
5
|
+
*
|
|
6
|
+
* Registers a Fastify plugin that applies rate limiting via an onRequest hook.
|
|
7
|
+
* Uses the same createLimiter() core — no rate limit logic lives here.
|
|
8
|
+
*
|
|
9
|
+
* Peer dependency: fastify-plugin (npm install fastify-plugin)
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const fastify = require('fastify')();
|
|
13
|
+
* const fastifyRateLimit = require('nextlimiter/fastify');
|
|
14
|
+
*
|
|
15
|
+
* fastify.register(fastifyRateLimit, { max: 100, windowMs: 60_000 });
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { createLimiter } = require('../index');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extract the best available IP from a Fastify request.
|
|
22
|
+
* @param {object} request - Fastify request object
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function extractIp(request) {
|
|
26
|
+
return (
|
|
27
|
+
request.headers['cf-connecting-ip'] ||
|
|
28
|
+
request.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
29
|
+
request.ip ||
|
|
30
|
+
'unknown'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fastify plugin factory.
|
|
36
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
37
|
+
* @param {object} options - Same options as createLimiter()
|
|
38
|
+
* @param {function} done
|
|
39
|
+
*/
|
|
40
|
+
async function plugin(fastify, options) {
|
|
41
|
+
const limiter = createLimiter(options);
|
|
42
|
+
|
|
43
|
+
fastify.addHook('onRequest', async (request, reply) => {
|
|
44
|
+
try {
|
|
45
|
+
const ip = extractIp(request);
|
|
46
|
+
const result = await limiter.check(ip);
|
|
47
|
+
|
|
48
|
+
if (!result.allowed) {
|
|
49
|
+
return reply.code(429).send({
|
|
50
|
+
error: 'Too Many Requests',
|
|
51
|
+
retryAfter: result.retryAfter,
|
|
52
|
+
limit: result.limit,
|
|
53
|
+
resetAt: new Date(result.resetAt).toISOString(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Set rate limit headers on allowed requests
|
|
58
|
+
reply.header('X-RateLimit-Limit', String(result.limit));
|
|
59
|
+
reply.header('X-RateLimit-Remaining', String(result.remaining));
|
|
60
|
+
reply.header('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
|
|
61
|
+
reply.header('X-RateLimit-Strategy', result.strategy);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
// Fail open — never let the rate limiter take down the app
|
|
64
|
+
request.log.warn(`[NextLimiter] Rate limiter error: ${err.message}. Failing open.`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let fastifyPlugin;
|
|
70
|
+
try {
|
|
71
|
+
fastifyPlugin = require('fastify-plugin');
|
|
72
|
+
} catch {
|
|
73
|
+
// If fastify-plugin is not installed, wrap minimally so the plugin still works
|
|
74
|
+
fastifyPlugin = (fn, meta) => fn;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = fastifyPlugin(plugin, {
|
|
78
|
+
fastify: '>=4.0.0',
|
|
79
|
+
name: 'nextlimiter',
|
|
80
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NextLimiter — Hono adapter
|
|
5
|
+
*
|
|
6
|
+
* Provides a Hono-compatible middleware factory for Cloudflare Workers,
|
|
7
|
+
* Bun, Deno, and any edge runtime supported by Hono.
|
|
8
|
+
*
|
|
9
|
+
* Uses Web APIs only (fetch/Request/Response/Headers) — no Node.js built-ins.
|
|
10
|
+
* Safe to deploy on Cloudflare Workers, Vercel Edge, and Deno Deploy.
|
|
11
|
+
*
|
|
12
|
+
* No peer dependencies required beyond Hono itself.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const { Hono } = require('hono');
|
|
16
|
+
* const { rateLimitMiddleware } = require('nextlimiter/hono');
|
|
17
|
+
*
|
|
18
|
+
* const app = new Hono();
|
|
19
|
+
* app.use('*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
|
|
20
|
+
*
|
|
21
|
+
* app.get('/', (c) => c.text('Hello World!'));
|
|
22
|
+
* export default app;
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { createLimiter } = require('../index');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract the real client IP from a Hono context.
|
|
29
|
+
* Prioritises Cloudflare's CF-Connecting-IP header, then X-Forwarded-For.
|
|
30
|
+
*
|
|
31
|
+
* @param {import('hono').Context} c
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function extractIp(c) {
|
|
35
|
+
return (
|
|
36
|
+
c.req.header('cf-connecting-ip') ||
|
|
37
|
+
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
38
|
+
c.req.header('x-real-ip') ||
|
|
39
|
+
'unknown'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hono middleware factory.
|
|
45
|
+
*
|
|
46
|
+
* @param {object} options - Same options as createLimiter()
|
|
47
|
+
* @returns {import('hono').MiddlewareHandler}
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000, strategy: 'sliding-window' }));
|
|
51
|
+
*/
|
|
52
|
+
function rateLimitMiddleware(options = {}) {
|
|
53
|
+
const limiter = createLimiter(options);
|
|
54
|
+
|
|
55
|
+
return async function honoRateLimitMiddleware(c, next) {
|
|
56
|
+
try {
|
|
57
|
+
const ip = extractIp(c);
|
|
58
|
+
const result = await limiter.check(ip);
|
|
59
|
+
|
|
60
|
+
if (!result.allowed) {
|
|
61
|
+
return c.json(
|
|
62
|
+
{
|
|
63
|
+
error: 'Too Many Requests',
|
|
64
|
+
retryAfter: result.retryAfter,
|
|
65
|
+
limit: result.limit,
|
|
66
|
+
resetAt: new Date(result.resetAt).toISOString(),
|
|
67
|
+
},
|
|
68
|
+
429
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Set rate limit headers before passing to next handler
|
|
73
|
+
c.header('X-RateLimit-Limit', String(result.limit));
|
|
74
|
+
c.header('X-RateLimit-Remaining', String(result.remaining));
|
|
75
|
+
c.header('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
|
|
76
|
+
c.header('X-RateLimit-Strategy', result.strategy);
|
|
77
|
+
|
|
78
|
+
await next();
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Fail open — never let the rate limiter crash the Hono app
|
|
81
|
+
console.warn(`[NextLimiter] Rate limiter error: ${err.message}. Failing open.`);
|
|
82
|
+
await next();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { rateLimitMiddleware };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* NextLimiter — Next.js adapter
|
|
5
|
+
*
|
|
6
|
+
* Provides two higher-order wrappers:
|
|
7
|
+
* withRateLimit() — for Next.js Pages Router API routes (Node.js runtime)
|
|
8
|
+
* withRateLimitEdge() — for App Router / middleware.js (Edge runtime, Web APIs only)
|
|
9
|
+
*
|
|
10
|
+
* No peer dependencies required.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Pages Router (pages/api/hello.js)
|
|
14
|
+
* const { withRateLimit } = require('nextlimiter/next');
|
|
15
|
+
* export default withRateLimit(handler, { max: 100, windowMs: 60_000 });
|
|
16
|
+
*
|
|
17
|
+
* // App Router / Edge (middleware.js)
|
|
18
|
+
* const { withRateLimitEdge } = require('nextlimiter/next');
|
|
19
|
+
* export default withRateLimitEdge(handler, { max: 50 });
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { createLimiter } = require('../index');
|
|
23
|
+
|
|
24
|
+
// ── Shared helpers ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Memoize limiter instances per options reference so the limiter is only
|
|
28
|
+
* created once per HOC call, not on every request.
|
|
29
|
+
*/
|
|
30
|
+
const limiterCache = new WeakMap();
|
|
31
|
+
|
|
32
|
+
function getLimiter(options) {
|
|
33
|
+
if (limiterCache.has(options)) return limiterCache.get(options);
|
|
34
|
+
const limiter = createLimiter(options);
|
|
35
|
+
limiterCache.set(options, limiter);
|
|
36
|
+
return limiter;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Pages Router (Node.js runtime) ───────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Higher-order function for Next.js Pages Router API routes.
|
|
43
|
+
* Wraps a handler with rate limiting using the Node.js HTTP req/res API.
|
|
44
|
+
*
|
|
45
|
+
* @param {function} handler - Next.js API route handler (req, res) => void
|
|
46
|
+
* @param {object} options - createLimiter() options
|
|
47
|
+
* @returns {function} wrapped async handler
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* export default withRateLimit(async (req, res) => {
|
|
51
|
+
* res.json({ hello: 'world' });
|
|
52
|
+
* }, { max: 100, windowMs: 60_000 });
|
|
53
|
+
*/
|
|
54
|
+
function withRateLimit(handler, options = {}) {
|
|
55
|
+
const limiter = getLimiter(options);
|
|
56
|
+
|
|
57
|
+
return async function rateLimitedHandler(req, res) {
|
|
58
|
+
try {
|
|
59
|
+
const ip =
|
|
60
|
+
(req.headers['x-forwarded-for']?.split(',')[0]?.trim()) ||
|
|
61
|
+
req.socket?.remoteAddress ||
|
|
62
|
+
'unknown';
|
|
63
|
+
|
|
64
|
+
const result = await limiter.check(ip);
|
|
65
|
+
|
|
66
|
+
if (!result.allowed) {
|
|
67
|
+
return res.status(429).json({
|
|
68
|
+
error: 'Too Many Requests',
|
|
69
|
+
retryAfter: result.retryAfter,
|
|
70
|
+
limit: result.limit,
|
|
71
|
+
resetAt: new Date(result.resetAt).toISOString(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Set rate limit headers
|
|
76
|
+
res.setHeader('X-RateLimit-Limit', String(result.limit));
|
|
77
|
+
res.setHeader('X-RateLimit-Remaining', String(result.remaining));
|
|
78
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
|
|
79
|
+
res.setHeader('X-RateLimit-Strategy', result.strategy);
|
|
80
|
+
|
|
81
|
+
return handler(req, res);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// Fail open — never let the rate limiter crash the API route
|
|
84
|
+
console.warn(`[NextLimiter] Rate limiter error: ${err.message}. Failing open.`);
|
|
85
|
+
return handler(req, res);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── App Router / Edge runtime (Web APIs only) ─────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Higher-order function for Next.js App Router or middleware.js.
|
|
94
|
+
* Uses Web Request/Response API only — no Node.js built-ins.
|
|
95
|
+
* Safe to run on Vercel Edge, Cloudflare Workers, and Deno.
|
|
96
|
+
*
|
|
97
|
+
* @param {function} handler - async (request: Request) => Response
|
|
98
|
+
* @param {object} options - createLimiter() options
|
|
99
|
+
* @returns {function} wrapped async handler
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* // middleware.js
|
|
103
|
+
* export default withRateLimitEdge(async (req) => {
|
|
104
|
+
* return new Response('OK');
|
|
105
|
+
* }, { max: 50, windowMs: 60_000 });
|
|
106
|
+
*/
|
|
107
|
+
function withRateLimitEdge(handler, options = {}) {
|
|
108
|
+
const limiter = getLimiter(options);
|
|
109
|
+
|
|
110
|
+
return async function rateLimitedEdgeHandler(request) {
|
|
111
|
+
try {
|
|
112
|
+
const ip =
|
|
113
|
+
request.headers.get('cf-connecting-ip') ||
|
|
114
|
+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
115
|
+
'unknown';
|
|
116
|
+
|
|
117
|
+
const result = await limiter.check(ip);
|
|
118
|
+
|
|
119
|
+
if (!result.allowed) {
|
|
120
|
+
return new Response(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
error: 'Too Many Requests',
|
|
123
|
+
retryAfter: result.retryAfter,
|
|
124
|
+
limit: result.limit,
|
|
125
|
+
resetAt: new Date(result.resetAt).toISOString(),
|
|
126
|
+
}),
|
|
127
|
+
{
|
|
128
|
+
status: 429,
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Call the handler and inject rate limit headers into the response
|
|
135
|
+
const response = await handler(request);
|
|
136
|
+
|
|
137
|
+
// Clone with additional headers (Response is immutable)
|
|
138
|
+
const headers = new Headers(response.headers);
|
|
139
|
+
headers.set('X-RateLimit-Limit', String(result.limit));
|
|
140
|
+
headers.set('X-RateLimit-Remaining', String(result.remaining));
|
|
141
|
+
headers.set('X-RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
|
|
142
|
+
headers.set('X-RateLimit-Strategy', result.strategy);
|
|
143
|
+
|
|
144
|
+
return new Response(response.body, {
|
|
145
|
+
status: response.status,
|
|
146
|
+
statusText: response.statusText,
|
|
147
|
+
headers,
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// Fail open
|
|
151
|
+
console.warn(`[NextLimiter] Rate limiter error: ${err.message}. Failing open.`);
|
|
152
|
+
return handler(request);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { withRateLimit, withRateLimitEdge };
|
|
@@ -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,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,7 +277,68 @@ 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>;
|
|
277
295
|
export declare const DEFAULT_PLANS: Record<BuiltInPlan, PlanDefinition>;
|
|
296
|
+
|
|
297
|
+
// ── Adapters ─────────────────────────────────────────────────────────────────
|
|
298
|
+
//
|
|
299
|
+
// These are available as subpath imports:
|
|
300
|
+
// import fastifyRateLimit from 'nextlimiter/fastify'
|
|
301
|
+
// import { withRateLimit, withRateLimitEdge } from 'nextlimiter/next'
|
|
302
|
+
// import { rateLimitMiddleware } from 'nextlimiter/hono'
|
|
303
|
+
|
|
304
|
+
// fastify adapter
|
|
305
|
+
declare module 'nextlimiter/fastify' {
|
|
306
|
+
import { FastifyPluginAsync } from 'fastify';
|
|
307
|
+
const fastifyRateLimit: FastifyPluginAsync<LimiterOptions>;
|
|
308
|
+
export default fastifyRateLimit;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// next.js adapter
|
|
312
|
+
declare module 'nextlimiter/next' {
|
|
313
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Wrap a Next.js Pages Router API handler with rate limiting (Node.js runtime).
|
|
317
|
+
*/
|
|
318
|
+
export function withRateLimit<T extends (req: NextApiRequest, res: NextApiResponse) => any>(
|
|
319
|
+
handler: T,
|
|
320
|
+
options?: LimiterOptions
|
|
321
|
+
): (req: NextApiRequest, res: NextApiResponse) => Promise<void>;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Wrap a Next.js App Router / middleware handler with rate limiting (Edge runtime).
|
|
325
|
+
* Uses Web Request / Response API only — no Node.js built-ins.
|
|
326
|
+
*/
|
|
327
|
+
export function withRateLimitEdge<T extends (req: Request) => Promise<Response>>(
|
|
328
|
+
handler: T,
|
|
329
|
+
options?: LimiterOptions
|
|
330
|
+
): (req: Request) => Promise<Response>;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// hono adapter
|
|
334
|
+
declare module 'nextlimiter/hono' {
|
|
335
|
+
/**
|
|
336
|
+
* Returns a Hono middleware that applies rate limiting.
|
|
337
|
+
* Safe for Cloudflare Workers, Bun, Deno — uses Web APIs only.
|
|
338
|
+
*
|
|
339
|
+
* @example
|
|
340
|
+
* app.use('*', rateLimitMiddleware({ max: 100, windowMs: 60_000 }));
|
|
341
|
+
*/
|
|
342
|
+
export function rateLimitMiddleware(options?: LimiterOptions): (c: any, next: () => Promise<void>) => Promise<void>;
|
|
343
|
+
}
|
|
344
|
+
|