UNPKG

22.5 kBJavaScriptView Raw
1// source/headers.ts
2var getResetSeconds = (resetTime, windowMs) => {
3 let resetSeconds = void 0;
4 if (resetTime) {
5 const deltaSeconds = Math.ceil((resetTime.getTime() - Date.now()) / 1e3);
6 resetSeconds = Math.max(0, deltaSeconds);
7 } else if (windowMs) {
8 resetSeconds = Math.ceil(windowMs / 1e3);
9 }
10 return resetSeconds;
11};
12var setLegacyHeaders = (response, info) => {
13 if (response.headersSent)
14 return;
15 response.setHeader("X-RateLimit-Limit", info.limit);
16 response.setHeader("X-RateLimit-Remaining", info.remaining);
17 if (info.resetTime instanceof Date) {
18 response.setHeader("Date", (/* @__PURE__ */ new Date()).toUTCString());
19 response.setHeader(
20 "X-RateLimit-Reset",
21 Math.ceil(info.resetTime.getTime() / 1e3)
22 );
23 }
24};
25var setDraft6Headers = (response, info, windowMs) => {
26 if (response.headersSent)
27 return;
28 const windowSeconds = Math.ceil(windowMs / 1e3);
29 const resetSeconds = getResetSeconds(info.resetTime);
30 response.setHeader("RateLimit-Policy", `${info.limit};w=${windowSeconds}`);
31 response.setHeader("RateLimit-Limit", info.limit);
32 response.setHeader("RateLimit-Remaining", info.remaining);
33 if (resetSeconds)
34 response.setHeader("RateLimit-Reset", resetSeconds);
35};
36var setDraft7Headers = (response, info, windowMs) => {
37 if (response.headersSent)
38 return;
39 const windowSeconds = Math.ceil(windowMs / 1e3);
40 const resetSeconds = getResetSeconds(info.resetTime, windowMs);
41 response.setHeader("RateLimit-Policy", `${info.limit};w=${windowSeconds}`);
42 response.setHeader(
43 "RateLimit",
44 `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`
45 );
46};
47var setRetryAfterHeader = (response, info, windowMs) => {
48 if (response.headersSent)
49 return;
50 const resetSeconds = getResetSeconds(info.resetTime, windowMs);
51 response.setHeader("Retry-After", resetSeconds);
52};
53
54// source/validations.ts
55import { isIP } from "net";
56var ValidationError = class extends Error {
57 /**
58 * The code must be a string, in snake case and all capital, that starts with
59 * the substring `ERR_ERL_`.
60 *
61 * The message must be a string, starting with an uppercase character,
62 * describing the issue in detail.
63 */
64 constructor(code, message) {
65 const url = `https://express-rate-limit.github.io/${code}/`;
66 super(`${message} See ${url} for more information.`);
67 this.name = this.constructor.name;
68 this.code = code;
69 this.help = url;
70 }
71};
72var ChangeWarning = class extends ValidationError {
73};
74var singleCountKeys = /* @__PURE__ */ new WeakMap();
75var validations = {
76 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
77 enabled: {
78 default: true
79 },
80 // Should be EnabledValidations type, but that's a circular reference
81 disable() {
82 for (const k of Object.keys(this.enabled))
83 this.enabled[k] = false;
84 },
85 /**
86 * Checks whether the IP address is valid, and that it does not have a port
87 * number in it.
88 *
89 * See https://github.com/express-rate-limit/express-rate-limit/wiki/Error-Codes#err_erl_invalid_ip_address.
90 *
91 * @param ip {string | undefined} - The IP address provided by Express as request.ip.
92 *
93 * @returns {void}
94 */
95 ip(ip) {
96 if (ip === void 0) {
97 throw new ValidationError(
98 "ERR_ERL_UNDEFINED_IP_ADDRESS",
99 `An undefined 'request.ip' was detected. This might indicate a misconfiguration or the connection being destroyed prematurely.`
100 );
101 }
102 if (!isIP(ip)) {
103 throw new ValidationError(
104 "ERR_ERL_INVALID_IP_ADDRESS",
105 `An invalid 'request.ip' (${ip}) was detected. Consider passing a custom 'keyGenerator' function to the rate limiter.`
106 );
107 }
108 },
109 /**
110 * Makes sure the trust proxy setting is not set to `true`.
111 *
112 * See https://github.com/express-rate-limit/express-rate-limit/wiki/Error-Codes#err_erl_permissive_trust_proxy.
113 *
114 * @param request {Request} - The Express request object.
115 *
116 * @returns {void}
117 */
118 trustProxy(request) {
119 if (request.app.get("trust proxy") === true) {
120 throw new ValidationError(
121 "ERR_ERL_PERMISSIVE_TRUST_PROXY",
122 `The Express 'trust proxy' setting is true, which allows anyone to trivially bypass IP-based rate limiting.`
123 );
124 }
125 },
126 /**
127 * Makes sure the trust proxy setting is set in case the `X-Forwarded-For`
128 * header is present.
129 *
130 * See https://github.com/express-rate-limit/express-rate-limit/wiki/Error-Codes#err_erl_unset_trust_proxy.
131 *
132 * @param request {Request} - The Express request object.
133 *
134 * @returns {void}
135 */
136 xForwardedForHeader(request) {
137 if (request.headers["x-forwarded-for"] && request.app.get("trust proxy") === false) {
138 throw new ValidationError(
139 "ERR_ERL_UNEXPECTED_X_FORWARDED_FOR",
140 `The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false (default). This could indicate a misconfiguration which would prevent express-rate-limit from accurately identifying users.`
141 );
142 }
143 },
144 /**
145 * Ensures totalHits value from store is a positive integer.
146 *
147 * @param hits {any} - The `totalHits` returned by the store.
148 */
149 positiveHits(hits) {
150 if (typeof hits !== "number" || hits < 1 || hits !== Math.round(hits)) {
151 throw new ValidationError(
152 "ERR_ERL_INVALID_HITS",
153 `The totalHits value returned from the store must be a positive integer, got ${hits}`
154 );
155 }
156 },
157 /**
158 * Ensures a given key is incremented only once per request.
159 *
160 * @param request {Request} - The Express request object.
161 * @param store {Store} - The store class.
162 * @param key {string} - The key used to store the client's hit count.
163 *
164 * @returns {void}
165 */
166 singleCount(request, store, key) {
167 let storeKeys = singleCountKeys.get(request);
168 if (!storeKeys) {
169 storeKeys = /* @__PURE__ */ new Map();
170 singleCountKeys.set(request, storeKeys);
171 }
172 const storeKey = store.localKeys ? store : store.constructor.name;
173 let keys = storeKeys.get(storeKey);
174 if (!keys) {
175 keys = [];
176 storeKeys.set(storeKey, keys);
177 }
178 const prefixedKey = `${store.prefix ?? ""}${key}`;
179 if (keys.includes(prefixedKey)) {
180 throw new ValidationError(
181 "ERR_ERL_DOUBLE_COUNT",
182 `The hit count for ${key} was incremented more than once for a single request.`
183 );
184 }
185 keys.push(prefixedKey);
186 },
187 /**
188 * Warns the user that the behaviour for `max: 0` / `limit: 0` is changing in the next
189 * major release.
190 *
191 * @param limit {number} - The maximum number of hits per client.
192 *
193 * @returns {void}
194 */
195 limit(limit) {
196 if (limit === 0) {
197 throw new ChangeWarning(
198 "WRN_ERL_MAX_ZERO",
199 `Setting limit or max to 0 disables rate limiting in express-rate-limit v6 and older, but will cause all requests to be blocked in v7`
200 );
201 }
202 },
203 /**
204 * Warns the user that the `draft_polli_ratelimit_headers` option is deprecated
205 * and will be removed in the next major release.
206 *
207 * @param draft_polli_ratelimit_headers {any | undefined} - The now-deprecated setting that was used to enable standard headers.
208 *
209 * @returns {void}
210 */
211 draftPolliHeaders(draft_polli_ratelimit_headers) {
212 if (draft_polli_ratelimit_headers) {
213 throw new ChangeWarning(
214 "WRN_ERL_DEPRECATED_DRAFT_POLLI_HEADERS",
215 `The draft_polli_ratelimit_headers configuration option is deprecated and has been removed in express-rate-limit v7, please set standardHeaders: 'draft-6' instead.`
216 );
217 }
218 },
219 /**
220 * Warns the user that the `onLimitReached` option is deprecated and will be removed in the next
221 * major release.
222 *
223 * @param onLimitReached {any | undefined} - The maximum number of hits per client.
224 *
225 * @returns {void}
226 */
227 onLimitReached(onLimitReached) {
228 if (onLimitReached) {
229 throw new ChangeWarning(
230 "WRN_ERL_DEPRECATED_ON_LIMIT_REACHED",
231 `The onLimitReached configuration option is deprecated and has been removed in express-rate-limit v7.`
232 );
233 }
234 },
235 /**
236 * Warns the user when the selected headers option requires a reset time but
237 * the store does not provide one.
238 *
239 * @param resetTime {Date | undefined} - The timestamp when the client's hit count will be reset.
240 *
241 * @returns {void}
242 */
243 headersResetTime(resetTime) {
244 if (!resetTime) {
245 throw new ValidationError(
246 "ERR_ERL_HEADERS_NO_RESET",
247 `standardHeaders: 'draft-7' requires a 'resetTime', but the store did not provide one. The 'windowMs' value will be used instead, which may cause clients to wait longer than necessary.`
248 );
249 }
250 },
251 /**
252 * Checks the options.validate setting to ensure that only recognized validations are enabled or disabled.
253 *
254 * If any unrecognized values are found, an error is logged that includes the list of supported vaidations.
255 */
256 validationsConfig() {
257 const supportedValidations = Object.keys(this).filter(
258 (k) => !["enabled", "disable"].includes(k)
259 );
260 supportedValidations.push("default");
261 for (const key of Object.keys(this.enabled)) {
262 if (!supportedValidations.includes(key)) {
263 throw new ValidationError(
264 "ERR_ERL_UNKNOWN_VALIDATION",
265 `options.validate.${key} is not recognized. Supported validate options are: ${supportedValidations.join(
266 ", "
267 )}.`
268 );
269 }
270 }
271 }
272};
273var getValidations = (_enabled) => {
274 let enabled;
275 if (typeof _enabled === "boolean") {
276 enabled = {
277 default: _enabled
278 };
279 } else {
280 enabled = {
281 default: true,
282 ..._enabled
283 };
284 }
285 const wrappedValidations = {
286 enabled
287 };
288 for (const [name, validation] of Object.entries(validations)) {
289 if (typeof validation === "function")
290 wrappedValidations[name] = (...args) => {
291 if (!(enabled[name] ?? enabled.default)) {
292 return;
293 }
294 try {
295 ;
296 validation.apply(
297 wrappedValidations,
298 args
299 );
300 } catch (error) {
301 if (error instanceof ChangeWarning)
302 console.warn(error);
303 else
304 console.error(error);
305 }
306 };
307 }
308 return wrappedValidations;
309};
310
311// source/memory-store.ts
312var MemoryStore = class {
313 constructor() {
314 /**
315 * These two maps store usage (requests) and reset time by key (for example, IP
316 * addresses or API keys).
317 *
318 * They are split into two to avoid having to iterate through the entire set to
319 * determine which ones need reset. Instead, `Client`s are moved from `previous`
320 * to `current` as they hit the endpoint. Once `windowMs` has elapsed, all clients
321 * left in `previous`, i.e., those that have not made any recent requests, are
322 * known to be expired and can be deleted in bulk.
323 */
324 this.previous = /* @__PURE__ */ new Map();
325 this.current = /* @__PURE__ */ new Map();
326 /**
327 * Confirmation that the keys incremented in once instance of MemoryStore
328 * cannot affect other instances.
329 */
330 this.localKeys = true;
331 }
332 /**
333 * Method that initializes the store.
334 *
335 * @param options {Options} - The options used to setup the middleware.
336 */
337 init(options) {
338 this.windowMs = options.windowMs;
339 if (this.interval)
340 clearInterval(this.interval);
341 this.interval = setInterval(() => {
342 this.clearExpired();
343 }, this.windowMs);
344 if (this.interval.unref)
345 this.interval.unref();
346 }
347 /**
348 * Method to fetch a client's hit count and reset time.
349 *
350 * @param key {string} - The identifier for a client.
351 *
352 * @returns {ClientRateLimitInfo | undefined} - The number of hits and reset time for that client.
353 *
354 * @public
355 */
356 async get(key) {
357 return this.current.get(key) ?? this.previous.get(key);
358 }
359 /**
360 * Method to increment a client's hit counter.
361 *
362 * @param key {string} - The identifier for a client.
363 *
364 * @returns {ClientRateLimitInfo} - The number of hits and reset time for that client.
365 *
366 * @public
367 */
368 async increment(key) {
369 const client = this.getClient(key);
370 const now = Date.now();
371 if (client.resetTime.getTime() <= now) {
372 this.resetClient(client, now);
373 }
374 client.totalHits++;
375 return client;
376 }
377 /**
378 * Method to decrement a client's hit counter.
379 *
380 * @param key {string} - The identifier for a client.
381 *
382 * @public
383 */
384 async decrement(key) {
385 const client = this.getClient(key);
386 if (client.totalHits > 0)
387 client.totalHits--;
388 }
389 /**
390 * Method to reset a client's hit counter.
391 *
392 * @param key {string} - The identifier for a client.
393 *
394 * @public
395 */
396 async resetKey(key) {
397 this.current.delete(key);
398 this.previous.delete(key);
399 }
400 /**
401 * Method to reset everyone's hit counter.
402 *
403 * @public
404 */
405 async resetAll() {
406 this.current.clear();
407 this.previous.clear();
408 }
409 /**
410 * Method to stop the timer (if currently running) and prevent any memory
411 * leaks.
412 *
413 * @public
414 */
415 shutdown() {
416 clearInterval(this.interval);
417 void this.resetAll();
418 }
419 /**
420 * Recycles a client by setting its hit count to zero, and reset time to
421 * `windowMs` milliseconds from now.
422 *
423 * NOT to be confused with `#resetKey()`, which removes a client from both the
424 * `current` and `previous` maps.
425 *
426 * @param client {Client} - The client to recycle.
427 * @param now {number} - The current time, to which the `windowMs` is added to get the `resetTime` for the client.
428 *
429 * @return {Client} - The modified client that was passed in, to allow for chaining.
430 */
431 resetClient(client, now = Date.now()) {
432 client.totalHits = 0;
433 client.resetTime.setTime(now + this.windowMs);
434 return client;
435 }
436 /**
437 * Retrieves or creates a client, given a key. Also ensures that the client being
438 * returned is in the `current` map.
439 *
440 * @param key {string} - The key under which the client is (or is to be) stored.
441 *
442 * @returns {Client} - The requested client.
443 */
444 getClient(key) {
445 if (this.current.has(key))
446 return this.current.get(key);
447 let client;
448 if (this.previous.has(key)) {
449 client = this.previous.get(key);
450 this.previous.delete(key);
451 } else {
452 client = { totalHits: 0, resetTime: /* @__PURE__ */ new Date() };
453 this.resetClient(client);
454 }
455 this.current.set(key, client);
456 return client;
457 }
458 /**
459 * Move current clients to previous, create a new map for current.
460 *
461 * This function is called every `windowMs`.
462 */
463 clearExpired() {
464 this.previous = this.current;
465 this.current = /* @__PURE__ */ new Map();
466 }
467};
468
469// source/lib.ts
470var isLegacyStore = (store) => (
471 // Check that `incr` exists but `increment` does not - store authors might want
472 // to keep both around for backwards compatibility.
473 typeof store.incr === "function" && typeof store.increment !== "function"
474);
475var promisifyStore = (passedStore) => {
476 if (!isLegacyStore(passedStore)) {
477 return passedStore;
478 }
479 const legacyStore = passedStore;
480 class PromisifiedStore {
481 async increment(key) {
482 return new Promise((resolve, reject) => {
483 legacyStore.incr(
484 key,
485 (error, totalHits, resetTime) => {
486 if (error)
487 reject(error);
488 resolve({ totalHits, resetTime });
489 }
490 );
491 });
492 }
493 async decrement(key) {
494 return legacyStore.decrement(key);
495 }
496 async resetKey(key) {
497 return legacyStore.resetKey(key);
498 }
499 /* istanbul ignore next */
500 async resetAll() {
501 if (typeof legacyStore.resetAll === "function")
502 return legacyStore.resetAll();
503 }
504 }
505 return new PromisifiedStore();
506};
507var getOptionsFromConfig = (config) => {
508 const { validations: validations2, ...directlyPassableEntries } = config;
509 return {
510 ...directlyPassableEntries,
511 validate: validations2.enabled
512 };
513};
514var omitUndefinedOptions = (passedOptions) => {
515 const omittedOptions = {};
516 for (const k of Object.keys(passedOptions)) {
517 const key = k;
518 if (passedOptions[key] !== void 0) {
519 omittedOptions[key] = passedOptions[key];
520 }
521 }
522 return omittedOptions;
523};
524var parseOptions = (passedOptions) => {
525 const notUndefinedOptions = omitUndefinedOptions(passedOptions);
526 const validations2 = getValidations(notUndefinedOptions?.validate ?? true);
527 validations2.validationsConfig();
528 validations2.draftPolliHeaders(
529 // @ts-expect-error see the note above.
530 notUndefinedOptions.draft_polli_ratelimit_headers
531 );
532 validations2.onLimitReached(notUndefinedOptions.onLimitReached);
533 let standardHeaders = notUndefinedOptions.standardHeaders ?? false;
534 if (standardHeaders === true)
535 standardHeaders = "draft-6";
536 const config = {
537 windowMs: 60 * 1e3,
538 limit: passedOptions.max ?? 5,
539 // `max` is deprecated, but support it anyways.
540 message: "Too many requests, please try again later.",
541 statusCode: 429,
542 legacyHeaders: passedOptions.headers ?? true,
543 requestPropertyName: "rateLimit",
544 skipFailedRequests: false,
545 skipSuccessfulRequests: false,
546 requestWasSuccessful: (_request, response) => response.statusCode < 400,
547 skip: (_request, _response) => false,
548 keyGenerator(request, _response) {
549 validations2.ip(request.ip);
550 validations2.trustProxy(request);
551 validations2.xForwardedForHeader(request);
552 return request.ip;
553 },
554 async handler(request, response, _next, _optionsUsed) {
555 response.status(config.statusCode);
556 const message = typeof config.message === "function" ? await config.message(
557 request,
558 response
559 ) : config.message;
560 if (!response.writableEnded) {
561 response.send(message);
562 }
563 },
564 // Allow the default options to be overriden by the options passed to the middleware.
565 ...notUndefinedOptions,
566 // `standardHeaders` is resolved into a draft version above, use that.
567 standardHeaders,
568 // Note that this field is declared after the user's options are spread in,
569 // so that this field doesn't get overriden with an un-promisified store!
570 store: promisifyStore(notUndefinedOptions.store ?? new MemoryStore()),
571 // Print an error to the console if a few known misconfigurations are detected.
572 validations: validations2
573 };
574 if (typeof config.store.increment !== "function" || typeof config.store.decrement !== "function" || typeof config.store.resetKey !== "function" || config.store.resetAll !== void 0 && typeof config.store.resetAll !== "function" || config.store.init !== void 0 && typeof config.store.init !== "function") {
575 throw new TypeError(
576 "An invalid store was passed. Please ensure that the store is a class that implements the `Store` interface."
577 );
578 }
579 return config;
580};
581var handleAsyncErrors = (fn) => async (request, response, next) => {
582 try {
583 await Promise.resolve(fn(request, response, next)).catch(next);
584 } catch (error) {
585 next(error);
586 }
587};
588var rateLimit = (passedOptions) => {
589 const config = parseOptions(passedOptions ?? {});
590 const options = getOptionsFromConfig(config);
591 if (typeof config.store.init === "function")
592 config.store.init(options);
593 const middleware = handleAsyncErrors(
594 async (request, response, next) => {
595 const skip = await config.skip(request, response);
596 if (skip) {
597 next();
598 return;
599 }
600 const augmentedRequest = request;
601 const key = await config.keyGenerator(request, response);
602 const { totalHits, resetTime } = await config.store.increment(key);
603 config.validations.positiveHits(totalHits);
604 config.validations.singleCount(request, config.store, key);
605 const retrieveLimit = typeof config.limit === "function" ? config.limit(request, response) : config.limit;
606 const limit = await retrieveLimit;
607 config.validations.limit(limit);
608 const info = {
609 limit,
610 used: totalHits,
611 remaining: Math.max(limit - totalHits, 0),
612 resetTime
613 };
614 Object.defineProperty(info, "current", {
615 configurable: false,
616 enumerable: false,
617 value: totalHits
618 });
619 augmentedRequest[config.requestPropertyName] = info;
620 if (config.legacyHeaders && !response.headersSent) {
621 setLegacyHeaders(response, info);
622 }
623 if (config.standardHeaders && !response.headersSent) {
624 if (config.standardHeaders === "draft-6") {
625 setDraft6Headers(response, info, config.windowMs);
626 } else if (config.standardHeaders === "draft-7") {
627 config.validations.headersResetTime(info.resetTime);
628 setDraft7Headers(response, info, config.windowMs);
629 }
630 }
631 if (config.skipFailedRequests || config.skipSuccessfulRequests) {
632 let decremented = false;
633 const decrementKey = async () => {
634 if (!decremented) {
635 await config.store.decrement(key);
636 decremented = true;
637 }
638 };
639 if (config.skipFailedRequests) {
640 response.on("finish", async () => {
641 if (!config.requestWasSuccessful(request, response))
642 await decrementKey();
643 });
644 response.on("close", async () => {
645 if (!response.writableEnded)
646 await decrementKey();
647 });
648 response.on("error", async () => {
649 await decrementKey();
650 });
651 }
652 if (config.skipSuccessfulRequests) {
653 response.on("finish", async () => {
654 if (config.requestWasSuccessful(request, response))
655 await decrementKey();
656 });
657 }
658 }
659 config.validations.disable();
660 if (totalHits > limit) {
661 if (config.legacyHeaders || config.standardHeaders) {
662 setRetryAfterHeader(response, info, config.windowMs);
663 }
664 config.handler(request, response, next, options);
665 return;
666 }
667 next();
668 }
669 );
670 const getThrowFn = () => {
671 throw new Error("The current store does not support the get/getKey method");
672 };
673 middleware.resetKey = config.store.resetKey.bind(config.store);
674 middleware.getKey = typeof config.store.get === "function" ? config.store.get.bind(config.store) : getThrowFn;
675 return middleware;
676};
677var lib_default = rateLimit;
678export {
679 MemoryStore,
680 lib_default as default,
681 lib_default as rateLimit
682};