@zudojs/security 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +254 -11
  3. package/dist/body/body.core.d.ts +50 -2
  4. package/dist/body/body.core.js +150 -19
  5. package/dist/body/index.d.ts +1 -1
  6. package/dist/body/index.js +1 -1
  7. package/dist/cookie/cookie.core.d.ts +12 -0
  8. package/dist/cookie/cookie.core.js +98 -8
  9. package/dist/cors/cors.core.d.ts +8 -1
  10. package/dist/cors/cors.core.js +66 -9
  11. package/dist/csrf/csrf.core.d.ts +150 -9
  12. package/dist/csrf/csrf.core.js +199 -41
  13. package/dist/csrf/index.d.ts +2 -1
  14. package/dist/csrf/index.js +1 -1
  15. package/dist/header/header.core.js +18 -5
  16. package/dist/headers/headers.core.js +36 -4
  17. package/dist/index.d.ts +14 -9
  18. package/dist/index.js +10 -8
  19. package/dist/input/index.d.ts +1 -1
  20. package/dist/input/index.js +1 -1
  21. package/dist/input/input.core.d.ts +27 -0
  22. package/dist/input/input.core.js +125 -37
  23. package/dist/rateLimit/index.d.ts +2 -1
  24. package/dist/rateLimit/index.js +1 -1
  25. package/dist/rateLimit/rateLimit.core.d.ts +56 -11
  26. package/dist/rateLimit/rateLimit.core.js +160 -47
  27. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  28. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  29. package/dist/types/index.d.ts +1 -1
  30. package/dist/types/security.type.d.ts +36 -19
  31. package/dist/types/security.type.js +26 -11
  32. package/dist/url/index.d.ts +2 -1
  33. package/dist/url/index.js +1 -1
  34. package/dist/url/url.core.d.ts +58 -2
  35. package/dist/url/url.core.js +270 -43
  36. package/package.json +20 -13
  37. package/dist/body/body.core.d.ts.map +0 -1
  38. package/dist/body/body.core.js.map +0 -1
  39. package/dist/body/index.d.ts.map +0 -1
  40. package/dist/body/index.js.map +0 -1
  41. package/dist/cookie/cookie.core.d.ts.map +0 -1
  42. package/dist/cookie/cookie.core.js.map +0 -1
  43. package/dist/cookie/index.d.ts.map +0 -1
  44. package/dist/cookie/index.js.map +0 -1
  45. package/dist/cors/cors.core.d.ts.map +0 -1
  46. package/dist/cors/cors.core.js.map +0 -1
  47. package/dist/cors/cors.namespace.d.ts.map +0 -1
  48. package/dist/cors/cors.namespace.js.map +0 -1
  49. package/dist/cors/index.d.ts.map +0 -1
  50. package/dist/cors/index.js.map +0 -1
  51. package/dist/csrf/csrf.core.d.ts.map +0 -1
  52. package/dist/csrf/csrf.core.js.map +0 -1
  53. package/dist/csrf/index.d.ts.map +0 -1
  54. package/dist/csrf/index.js.map +0 -1
  55. package/dist/header/header.core.d.ts.map +0 -1
  56. package/dist/header/header.core.js.map +0 -1
  57. package/dist/header/index.d.ts.map +0 -1
  58. package/dist/header/index.js.map +0 -1
  59. package/dist/headers/headers.core.d.ts.map +0 -1
  60. package/dist/headers/headers.core.js.map +0 -1
  61. package/dist/headers/index.d.ts.map +0 -1
  62. package/dist/headers/index.js.map +0 -1
  63. package/dist/index.d.ts.map +0 -1
  64. package/dist/index.js.map +0 -1
  65. package/dist/input/index.d.ts.map +0 -1
  66. package/dist/input/index.js.map +0 -1
  67. package/dist/input/input.core.d.ts.map +0 -1
  68. package/dist/input/input.core.js.map +0 -1
  69. package/dist/rateLimit/index.d.ts.map +0 -1
  70. package/dist/rateLimit/index.js.map +0 -1
  71. package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
  72. package/dist/rateLimit/rateLimit.core.js.map +0 -1
  73. package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
  74. package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
  75. package/dist/types/index.d.ts.map +0 -1
  76. package/dist/types/index.js.map +0 -1
  77. package/dist/types/security.type.d.ts.map +0 -1
  78. package/dist/types/security.type.js.map +0 -1
  79. package/dist/url/index.d.ts.map +0 -1
  80. package/dist/url/index.js.map +0 -1
  81. package/dist/url/url.core.d.ts.map +0 -1
  82. package/dist/url/url.core.js.map +0 -1
@@ -4,13 +4,28 @@
4
4
  * Sanitizes user input against common attack patterns.
5
5
  */
6
6
  import { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "../types/security.type.js";
7
- /** Null byte pattern. */
8
- const NULL_BYTE_PATTERN = /\x00/g;
9
- /** Control character pattern (except tab, newline, carriage return). */
10
- const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
7
+ /**
8
+ * Null byte and control character patterns.
9
+ *
10
+ * Two variants of each: the plain form is used with {@link RegExp.test}, the
11
+ * `g` form only with {@link String.replace}. A global regex keeps `lastIndex`
12
+ * between `test` calls and resumes from there on the next one, so sharing a
13
+ * single `/g` pattern across both uses makes the test report `false` for input
14
+ * it matched moments earlier.
15
+ */
16
+ const NULL_BYTE_PATTERN = /\x00/;
17
+ const NULL_BYTE_PATTERN_GLOBAL = /\x00/g;
18
+ /** Control characters, excluding tab, newline, and carriage return. */
19
+ const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
20
+ const CONTROL_CHAR_PATTERN_GLOBAL = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
21
+ /** Default maximum recursion depth for {@link sanitizeObject}. */
22
+ const DEFAULT_MAX_DEPTH = 32;
11
23
  /**
12
24
  * Checks if a string contains SQL injection patterns.
13
25
  *
26
+ * A heuristic with a high false-positive rate on ordinary prose — never the
27
+ * only defence against injection. Parameterise your queries.
28
+ *
14
29
  * @param input - The string to check.
15
30
  * @returns True if SQL injection patterns are detected.
16
31
  */
@@ -46,10 +61,15 @@ export function sanitizeString(input, config) {
46
61
  let sanitized = input;
47
62
  // Strip null bytes
48
63
  if (config?.stripNullBytes !== false) {
49
- sanitized = sanitized.replace(NULL_BYTE_PATTERN, "");
64
+ sanitized = sanitized.replace(NULL_BYTE_PATTERN_GLOBAL, "");
50
65
  }
51
66
  // Strip control characters
52
- sanitized = sanitized.replace(CONTROL_CHAR_PATTERN, "");
67
+ sanitized = sanitized.replace(CONTROL_CHAR_PATTERN_GLOBAL, "");
68
+ // Normalize Unicode — collapses visually identical sequences so that
69
+ // downstream comparisons and length checks see one canonical form.
70
+ if (config?.normalizeUnicode) {
71
+ sanitized = sanitized.normalize("NFC");
72
+ }
53
73
  // Truncate if max length configured
54
74
  if (config?.maxStringLength && sanitized.length > config.maxStringLength) {
55
75
  sanitized = sanitized.slice(0, config.maxStringLength);
@@ -61,41 +81,84 @@ export function sanitizeString(input, config) {
61
81
  return sanitized;
62
82
  }
63
83
  /**
64
- * Sanitizes an object by recursively cleaning its values.
84
+ * Sanitizes a value of any shape, recursing into arrays and plain objects.
65
85
  *
66
- * @param obj - The object to sanitize.
67
- * @param config - Optional sanitization configuration.
68
- * @returns The sanitized object.
86
+ * Arrays stay arrays at every level, cycles are detected and replaced with
87
+ * `undefined` rather than overflowing the stack, and recursion stops at
88
+ * `config.maxDepth`.
69
89
  */
70
- export function sanitizeObject(obj, config) {
71
- const sanitized = { ...obj };
72
- for (const [key, value] of Object.entries(sanitized)) {
73
- // Check for prototype pollution keys
74
- if (config?.preventPrototypePollution !== false) {
75
- if (containsPrototypePollution(key)) {
76
- delete sanitized[key];
77
- continue;
78
- }
90
+ function sanitizeValue(value, config, seen, depth, maxDepth) {
91
+ if (typeof value === "string") {
92
+ return sanitizeString(value, config);
93
+ }
94
+ if (typeof value !== "object" || value === null) {
95
+ return value;
96
+ }
97
+ if (depth >= maxDepth) {
98
+ return undefined;
99
+ }
100
+ // A repeat visit means a cycle: recursing would never terminate.
101
+ if (seen.has(value)) {
102
+ return undefined;
103
+ }
104
+ seen.add(value);
105
+ try {
106
+ if (Array.isArray(value)) {
107
+ return value.map((item) => sanitizeValue(item, config, seen, depth + 1, maxDepth));
79
108
  }
80
- // Sanitize string values
81
- if (typeof value === "string") {
82
- sanitized[key] = sanitizeString(value, config);
109
+ // Anything with its own semantics (Date, Map, RegExp, class instances) is
110
+ // passed through untouched — spreading it would silently turn it into a
111
+ // plain object and lose that behaviour.
112
+ if (!isPlainObject(value)) {
113
+ return value;
83
114
  }
84
- else if (typeof value === "object" && value !== null) {
85
- // Recursively sanitize nested objects
86
- if (Array.isArray(value)) {
87
- sanitized[key] = value.map((item) => typeof item === "object" && item !== null
88
- ? sanitizeObject(item, config)
89
- : typeof item === "string"
90
- ? sanitizeString(item, config)
91
- : item);
92
- }
93
- else {
94
- sanitized[key] = sanitizeObject(value, config);
115
+ const result = {};
116
+ for (const [key, child] of Object.entries(value)) {
117
+ if (config?.preventPrototypePollution !== false &&
118
+ containsPrototypePollution(key)) {
119
+ continue;
95
120
  }
121
+ // `result[key] = …` does not create an own property for `__proto__`:
122
+ // it calls the inherited setter and replaces the result's prototype, so
123
+ // the attacker's fields resolve on the returned object while
124
+ // `Object.keys` shows nothing. That is exactly what happened whenever a
125
+ // caller set `preventPrototypePollution: false`, which is documented as
126
+ // "keep these keys as data", not "let them reassign a prototype".
127
+ // `defineProperty` always creates a real own property.
128
+ Object.defineProperty(result, key, {
129
+ value: sanitizeValue(child, config, seen, depth + 1, maxDepth),
130
+ writable: true,
131
+ enumerable: true,
132
+ configurable: true,
133
+ });
96
134
  }
135
+ return result;
97
136
  }
98
- return sanitized;
137
+ finally {
138
+ // Leaving this branch: a sibling may legitimately reference the same
139
+ // object without that being a cycle.
140
+ seen.delete(value);
141
+ }
142
+ }
143
+ /** True when the value is a plain object (`{}` or `Object.create(null)`). */
144
+ function isPlainObject(value) {
145
+ const proto = Object.getPrototypeOf(value);
146
+ return proto === null || proto === Object.prototype;
147
+ }
148
+ /**
149
+ * Sanitizes an object by recursively cleaning its values.
150
+ *
151
+ * Cycles and over-deep structures are handled: a repeated reference or a
152
+ * branch past `config.maxDepth` (default 32) becomes `undefined` instead of
153
+ * exhausting the stack.
154
+ *
155
+ * @param obj - The object to sanitize.
156
+ * @param config - Optional sanitization configuration.
157
+ * @returns The sanitized object.
158
+ */
159
+ export function sanitizeObject(obj, config) {
160
+ const maxDepth = config?.maxDepth ?? DEFAULT_MAX_DEPTH;
161
+ return sanitizeValue(obj, config, new WeakSet(), 0, maxDepth);
99
162
  }
100
163
  /**
101
164
  * Validates that a string contains only safe characters.
@@ -113,12 +176,23 @@ export function isSafeString(input, allowedPattern) {
113
176
  if (CONTROL_CHAR_PATTERN.test(input)) {
114
177
  return false;
115
178
  }
116
- // Check custom pattern
117
- if (allowedPattern && !allowedPattern.test(input)) {
179
+ // Check custom pattern. A caller-supplied `g`/`y` regex carries `lastIndex`
180
+ // between calls, so it is normalised before use.
181
+ if (allowedPattern && !withoutStickyFlags(allowedPattern).test(input)) {
118
182
  return false;
119
183
  }
120
184
  return true;
121
185
  }
186
+ /**
187
+ * Returns an equivalent regex with the `g` and `y` flags removed.
188
+ *
189
+ * Both flags make `test` stateful via `lastIndex`; for a one-shot boolean
190
+ * check they only introduce order-dependent results.
191
+ */
192
+ export function withoutStickyFlags(pattern) {
193
+ const flags = pattern.flags.replace(/[gy]/g, "");
194
+ return flags === pattern.flags ? pattern : new RegExp(pattern.source, flags);
195
+ }
122
196
  /**
123
197
  * Checks for common attack patterns in a string.
124
198
  *
@@ -144,6 +218,11 @@ export function detectThreats(input) {
144
218
  /**
145
219
  * HTML-escapes a string to prevent XSS.
146
220
  *
221
+ * Escapes the five characters that matter in element text and quoted attribute
222
+ * values. It does not make a string safe for an unquoted attribute, inside a
223
+ * `<script>` or `<style>` block, or in a URL position — those contexts need
224
+ * their own encoding.
225
+ *
147
226
  * @param input - The string to escape.
148
227
  * @returns The escaped string.
149
228
  */
@@ -154,12 +233,21 @@ export function escapeHtml(input) {
154
233
  ">": "&gt;",
155
234
  '"': "&quot;",
156
235
  "'": "&#39;",
236
+ "`": "&#96;",
157
237
  };
158
- return input.replace(/[&<>"']/g, (char) => map[char] ?? char);
238
+ return input.replace(/[&<>"'`]/g, (char) => map[char] ?? char);
159
239
  }
160
240
  /**
161
241
  * Strips HTML tags from a string.
162
242
  *
243
+ * This removes tag syntax; it is **not** an HTML sanitizer. The result is safe
244
+ * to treat as plain text, but must still be escaped with {@link escapeHtml}
245
+ * before being inserted back into a document — use a dedicated sanitizer if
246
+ * you need to keep markup.
247
+ *
248
+ * An unterminated `<` consumes the remainder of the input, which is the safe
249
+ * direction: a truncated tag never survives into the output.
250
+ *
163
251
  * @param input - The string to strip.
164
252
  * @returns The string with HTML tags removed.
165
253
  */
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * @zudojs/security — Rate Limiting Barrel
3
3
  */
4
- export { defaultKeyGenerator, defaultHandler, createRateLimiter, extractClientIp, } from "./rateLimit.core.js";
4
+ export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit.core.js";
5
+ export type { RateLimiterOptions, ClientIpOptions } from "./rateLimit.core.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * @zudojs/security — Rate Limiting Barrel
3
3
  */
4
- export { defaultKeyGenerator, defaultHandler, createRateLimiter, extractClientIp, } from "./rateLimit.core.js";
4
+ export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit.core.js";
5
5
  //# sourceMappingURL=index.js.map
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Implements sliding window rate limiting to prevent abuse.
5
5
  */
6
- import type { RateLimitConfig, RateLimitRequest, RateLimitResult } from "../types/security.type.js";
6
+ import type { RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult } from "../types/security.type.js";
7
7
  /**
8
8
  * Default key generator using IP address.
9
9
  *
@@ -14,38 +14,83 @@ export declare function defaultKeyGenerator(request: RateLimitRequest): string;
14
14
  /**
15
15
  * Default handler when rate limit is exceeded.
16
16
  *
17
+ * `Retry-After` is derived from the decision that caused the rejection. It used
18
+ * to be the string `"60"` regardless of configuration, so a limiter with an
19
+ * hour-long window told every client to come back in a minute — and they did,
20
+ * to another rejection.
21
+ *
17
22
  * @param _request - The rate limit request.
18
23
  * @param response - The rate limit response to modify.
24
+ * @param result - The decision being rejected, used for `Retry-After`.
25
+ * @param message - The message to return, from `RateLimitConfig.message`.
19
26
  */
20
27
  export declare function defaultHandler(_request: RateLimitRequest, response: {
21
28
  statusCode: number;
22
29
  headers: Record<string, string>;
23
30
  body?: string;
24
- }): void;
31
+ }, result?: RateLimitResult, message?: string): void;
32
+ /**
33
+ * Whole seconds until the window frees up, as `Retry-After` requires.
34
+ *
35
+ * `RateLimitResult.resetAt` is a `Date`; putting it into a header or a JSON
36
+ * body directly yields an ISO string or an object where an integer count of
37
+ * seconds is expected. At least 1, never fractional, so a client never reads
38
+ * `Retry-After: 0` and retries instantly.
39
+ */
40
+ export declare function retryAfterSeconds(result?: Pick<RateLimitResult, "resetAt">, now?: number): number;
41
+ /** Extra options accepted by {@link createRateLimiter}. */
42
+ export interface RateLimiterOptions extends RateLimitConfig {
43
+ /** Maximum number of distinct keys to track (default: 100,000). */
44
+ readonly maxKeys?: number;
45
+ }
25
46
  /**
26
47
  * Creates an in-memory rate limiter.
27
48
  *
49
+ * The window genuinely slides: each check prunes timestamps older than
50
+ * `windowMs` and decides against what remains, so a client cannot spend a full
51
+ * allowance either side of a fixed boundary and get `2 × max` back to back.
52
+ *
28
53
  * @param config - Rate limit configuration.
29
54
  * @returns A function that checks rate limits.
30
55
  */
31
- export declare function createRateLimiter(config: RateLimitConfig): {
56
+ export declare function createRateLimiter(config: RateLimiterOptions): {
32
57
  check: (request: RateLimitRequest) => RateLimitResult;
33
- middleware: (request: RateLimitRequest, response?: {
34
- statusCode: number;
35
- headers: Record<string, string>;
36
- body?: string;
37
- }) => RateLimitResult;
58
+ middleware: (request: RateLimitRequest, response?: RateLimitResponse) => RateLimitResult;
38
59
  reset: (key: string) => void;
39
60
  clear: () => void;
40
61
  getCount: (key: string) => number;
41
62
  destroy: () => void;
63
+ /** Number of keys currently tracked. */
64
+ readonly size: number;
42
65
  };
66
+ /** Options controlling how far forwarding headers are trusted. */
67
+ export interface ClientIpOptions {
68
+ /**
69
+ * Number of reverse proxies you operate in front of this service.
70
+ *
71
+ * `X-Forwarded-For` is appended to by every hop, so the entries closest to
72
+ * the right are the ones your own infrastructure added. With `trustProxy: 1`
73
+ * the last entry is used, with `2` the second-to-last, and so on. Entries to
74
+ * the left of your proxies were supplied by the client and are ignored.
75
+ *
76
+ * Defaults to `0`: no forwarding header is trusted at all.
77
+ */
78
+ readonly trustProxy?: number;
79
+ /** The connection's remote address, used when no header is trusted. */
80
+ readonly remoteAddress?: string;
81
+ }
43
82
  /**
44
83
  * Extracts the client IP from request headers.
45
- * Used as a default key generator.
84
+ *
85
+ * **Forwarding headers are not trusted by default.** Any client can send
86
+ * `X-Forwarded-For`, so taking its leftmost entry — the historical behaviour —
87
+ * hands the caller control of their own rate-limit bucket, and rotating it
88
+ * defeats the limiter entirely. Pass `trustProxy` set to the number of proxies
89
+ * you actually run, together with the socket's `remoteAddress`.
46
90
  *
47
91
  * @param headers - Request headers.
48
- * @returns The client IP address.
92
+ * @param options - Proxy trust configuration.
93
+ * @returns The client IP address, or "unknown".
49
94
  */
50
- export declare function extractClientIp(headers: Record<string, string | string[] | undefined>): string;
95
+ export declare function extractClientIp(headers: Record<string, string | string[] | undefined>, options?: ClientIpOptions): string;
51
96
  //# sourceMappingURL=rateLimit.core.d.ts.map
@@ -3,12 +3,12 @@
3
3
  *
4
4
  * Implements sliding window rate limiting to prevent abuse.
5
5
  */
6
- /** Default rate limit: 100 requests per minute. */
7
- const DEFAULT_MAX = 100;
8
6
  /** Default window: 1 minute. */
9
7
  const DEFAULT_WINDOW_MS = 60_000;
10
8
  /** Default rate limit message. */
11
9
  const DEFAULT_MESSAGE = "Too many requests";
10
+ /** Default cap on tracked keys before least-recently-seen eviction. */
11
+ const DEFAULT_MAX_KEYS = 100_000;
12
12
  /**
13
13
  * Default key generator using IP address.
14
14
  *
@@ -21,44 +21,104 @@ export function defaultKeyGenerator(request) {
21
21
  /**
22
22
  * Default handler when rate limit is exceeded.
23
23
  *
24
+ * `Retry-After` is derived from the decision that caused the rejection. It used
25
+ * to be the string `"60"` regardless of configuration, so a limiter with an
26
+ * hour-long window told every client to come back in a minute — and they did,
27
+ * to another rejection.
28
+ *
24
29
  * @param _request - The rate limit request.
25
30
  * @param response - The rate limit response to modify.
31
+ * @param result - The decision being rejected, used for `Retry-After`.
32
+ * @param message - The message to return, from `RateLimitConfig.message`.
26
33
  */
27
- export function defaultHandler(_request, response) {
34
+ export function defaultHandler(_request, response, result, message = DEFAULT_MESSAGE) {
28
35
  response.statusCode = 429;
29
- response.headers["Retry-After"] = "60";
30
- response.headers["X-RateLimit-Remaining"] = "0";
36
+ response.headers["Retry-After"] = String(retryAfterSeconds(result));
37
+ response.headers["X-RateLimit-Remaining"] = String(result?.remaining ?? 0);
38
+ if (result) {
39
+ response.headers["X-RateLimit-Limit"] = String(result.total);
40
+ response.headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt.getTime() / 1000));
41
+ }
31
42
  response.body = JSON.stringify({
32
43
  error: {
33
44
  code: "RATE_LIMIT_EXCEEDED",
34
- message: DEFAULT_MESSAGE,
45
+ message,
35
46
  },
36
47
  });
37
48
  }
49
+ /**
50
+ * Whole seconds until the window frees up, as `Retry-After` requires.
51
+ *
52
+ * `RateLimitResult.resetAt` is a `Date`; putting it into a header or a JSON
53
+ * body directly yields an ISO string or an object where an integer count of
54
+ * seconds is expected. At least 1, never fractional, so a client never reads
55
+ * `Retry-After: 0` and retries instantly.
56
+ */
57
+ export function retryAfterSeconds(result, now = Date.now()) {
58
+ if (!result)
59
+ return Math.ceil(DEFAULT_WINDOW_MS / 1000);
60
+ const deltaMs = result.resetAt.getTime() - now;
61
+ return Math.max(1, Math.ceil(deltaMs / 1000));
62
+ }
38
63
  /**
39
64
  * Creates an in-memory rate limiter.
40
65
  *
66
+ * The window genuinely slides: each check prunes timestamps older than
67
+ * `windowMs` and decides against what remains, so a client cannot spend a full
68
+ * allowance either side of a fixed boundary and get `2 × max` back to back.
69
+ *
41
70
  * @param config - Rate limit configuration.
42
71
  * @returns A function that checks rate limits.
43
72
  */
44
73
  export function createRateLimiter(config) {
74
+ if (!Number.isFinite(config.max) || config.max < 1) {
75
+ throw new RangeError(`Rate limit max must be a positive number, got: ${config.max}`);
76
+ }
77
+ if (!Number.isFinite(config.windowMs) || config.windowMs < 1) {
78
+ throw new RangeError(`Rate limit windowMs must be a positive number, got: ${config.windowMs}`);
79
+ }
45
80
  const store = new Map();
46
81
  const keyGenerator = config.keyGenerator ?? defaultKeyGenerator;
47
- const handler = config.handler;
82
+ // `config.message` was declared and documented but never read: the default
83
+ // handler always emitted the built-in string.
84
+ const message = config.message ?? DEFAULT_MESSAGE;
85
+ const handler = config.handler ??
86
+ ((request, response, result) => {
87
+ defaultHandler(request, response, result, message);
88
+ });
48
89
  const skip = config.skip;
49
- // Cleanup old entries periodically
90
+ const maxKeys = config.maxKeys ?? DEFAULT_MAX_KEYS;
91
+ // Cleanup old entries periodically. Bounded so a very short window does not
92
+ // schedule a near-continuous timer.
50
93
  const cleanupInterval = setInterval(() => {
51
- const now = Date.now();
94
+ const cutoff = Date.now() - config.windowMs;
52
95
  for (const [key, entry] of store) {
53
- if (now - entry.windowStart > config.windowMs * 2) {
96
+ if (entry.lastSeen <= cutoff) {
54
97
  store.delete(key);
55
98
  }
56
99
  }
57
- }, config.windowMs);
100
+ }, Math.max(config.windowMs, 1_000));
58
101
  // Allow cleanup to not keep process alive
59
102
  if (cleanupInterval.unref) {
60
103
  cleanupInterval.unref();
61
104
  }
105
+ /**
106
+ * Evicts the least-recently-seen keys once the store exceeds its cap.
107
+ *
108
+ * Without this, a caller rotating the key (a spoofed forwarding header, a
109
+ * per-request identifier) grows the map without limit between sweeps.
110
+ */
111
+ function evictIfNeeded() {
112
+ if (store.size <= maxKeys)
113
+ return;
114
+ const entries = [...store.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen);
115
+ const excess = store.size - maxKeys;
116
+ for (let i = 0; i < excess; i++) {
117
+ const entry = entries[i];
118
+ if (entry)
119
+ store.delete(entry[0]);
120
+ }
121
+ }
62
122
  /**
63
123
  * Checks if a request is allowed and updates the counter.
64
124
  */
@@ -76,24 +136,30 @@ export function createRateLimiter(config) {
76
136
  const now = Date.now();
77
137
  const windowStart = now - config.windowMs;
78
138
  let entry = store.get(key);
79
- if (!entry || now - entry.windowStart >= config.windowMs) {
80
- // New window
81
- entry = { timestamps: [now], windowStart: now };
139
+ if (!entry) {
140
+ entry = { timestamps: [], lastSeen: now };
82
141
  store.set(key, entry);
142
+ evictIfNeeded();
83
143
  }
84
- else {
85
- // Existing window — remove expired timestamps
86
- const validTimestamps = entry.timestamps.filter((t) => t > windowStart);
87
- validTimestamps.push(now);
88
- entry = { timestamps: validTimestamps, windowStart: entry.windowStart };
89
- store.set(key, entry);
144
+ // Prune everything that has slid out of the window.
145
+ const timestamps = entry.timestamps.filter((t) => t > windowStart);
146
+ entry.lastSeen = now;
147
+ const allowed = timestamps.length < config.max;
148
+ // Only an allowed request consumes an allowance slot. Recording denied
149
+ // requests too would let a client already over the limit keep growing its
150
+ // own bucket, so the cost of an attack would scale with the attack.
151
+ if (allowed) {
152
+ timestamps.push(now);
90
153
  }
91
- const remaining = Math.max(0, config.max - entry.timestamps.length);
92
- const allowed = entry.timestamps.length <= config.max;
154
+ entry.timestamps = timestamps;
155
+ const remaining = Math.max(0, config.max - timestamps.length);
156
+ // The window frees up when its oldest surviving request ages out.
157
+ const oldest = timestamps[0];
158
+ const resetAt = new Date((oldest ?? now) + config.windowMs);
93
159
  return {
94
160
  allowed,
95
161
  remaining,
96
- resetAt: new Date(entry.windowStart + config.windowMs),
162
+ resetAt,
97
163
  total: config.max,
98
164
  };
99
165
  }
@@ -102,8 +168,8 @@ export function createRateLimiter(config) {
102
168
  */
103
169
  function middleware(request, response) {
104
170
  const result = check(request);
105
- if (!result.allowed && response && handler) {
106
- handler(request, response);
171
+ if (!result.allowed && response) {
172
+ handler(request, response, result);
107
173
  }
108
174
  return result;
109
175
  }
@@ -126,8 +192,7 @@ export function createRateLimiter(config) {
126
192
  const entry = store.get(key);
127
193
  if (!entry)
128
194
  return 0;
129
- const now = Date.now();
130
- const windowStart = now - config.windowMs;
195
+ const windowStart = Date.now() - config.windowMs;
131
196
  return entry.timestamps.filter((t) => t > windowStart).length;
132
197
  }
133
198
  /**
@@ -144,32 +209,80 @@ export function createRateLimiter(config) {
144
209
  clear,
145
210
  getCount,
146
211
  destroy,
212
+ /** Number of keys currently tracked. */
213
+ get size() {
214
+ return store.size;
215
+ },
147
216
  };
148
217
  }
149
218
  /**
150
219
  * Extracts the client IP from request headers.
151
- * Used as a default key generator.
220
+ *
221
+ * **Forwarding headers are not trusted by default.** Any client can send
222
+ * `X-Forwarded-For`, so taking its leftmost entry — the historical behaviour —
223
+ * hands the caller control of their own rate-limit bucket, and rotating it
224
+ * defeats the limiter entirely. Pass `trustProxy` set to the number of proxies
225
+ * you actually run, together with the socket's `remoteAddress`.
152
226
  *
153
227
  * @param headers - Request headers.
154
- * @returns The client IP address.
228
+ * @param options - Proxy trust configuration.
229
+ * @returns The client IP address, or "unknown".
155
230
  */
156
- export function extractClientIp(headers) {
157
- // X-Forwarded-For (first entry)
158
- const forwarded = headers["x-forwarded-for"];
159
- if (typeof forwarded === "string") {
160
- const first = forwarded.split(",")[0]?.trim();
161
- if (first)
162
- return first;
163
- }
164
- if (Array.isArray(forwarded) && forwarded.length > 0) {
165
- const first = forwarded[0]?.split(",")[0]?.trim();
166
- if (first)
167
- return first;
168
- }
169
- // X-Real-IP
170
- const realIp = headers["x-real-ip"];
171
- if (typeof realIp === "string")
172
- return realIp;
173
- return "unknown";
231
+ export function extractClientIp(headers, options) {
232
+ const trustProxy = options?.trustProxy ?? 0;
233
+ const fallback = options?.remoteAddress ?? "unknown";
234
+ if (trustProxy <= 0) {
235
+ return fallback;
236
+ }
237
+ const raw = lookupHeader(headers, "x-forwarded-for");
238
+ if (raw !== undefined) {
239
+ const chain = raw
240
+ .split(",")
241
+ .map((entry) => entry.trim())
242
+ .filter((entry) => entry.length > 0);
243
+ // Walk in from the right: index 0 from the end is the address our own
244
+ // outermost proxy observed, and each additional trusted hop steps left.
245
+ const index = chain.length - trustProxy;
246
+ const candidate = chain[Math.max(0, index)];
247
+ if (candidate && isPlausibleIp(candidate)) {
248
+ return candidate;
249
+ }
250
+ }
251
+ const realIp = lookupHeader(headers, "x-real-ip");
252
+ if (realIp !== undefined && isPlausibleIp(realIp.trim())) {
253
+ return realIp.trim();
254
+ }
255
+ return fallback;
256
+ }
257
+ /** Case-insensitive header lookup that flattens repeated fields. */
258
+ function lookupHeader(headers, name) {
259
+ for (const key of Object.keys(headers)) {
260
+ if (key.toLowerCase() !== name)
261
+ continue;
262
+ const value = headers[key];
263
+ if (typeof value === "string")
264
+ return value;
265
+ if (Array.isArray(value) && value.length > 0)
266
+ return value.join(",");
267
+ }
268
+ return undefined;
269
+ }
270
+ /**
271
+ * Rejects values that are not addresses at all.
272
+ *
273
+ * A forwarding header is text, and a hostname or arbitrary string in it would
274
+ * otherwise become a rate-limit key of the attacker's choosing.
275
+ */
276
+ function isPlausibleIp(value) {
277
+ const host = value.startsWith("[")
278
+ ? value.slice(1, value.indexOf("]") === -1 ? undefined : value.indexOf("]"))
279
+ : value.split(":").length > 2
280
+ ? value
281
+ : (value.split(":")[0] ?? value);
282
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
283
+ return host.split(".").every((octet) => Number(octet) <= 255);
284
+ }
285
+ // Any hex-and-colon string is accepted as an IPv6 candidate.
286
+ return /^[0-9a-fA-F:]+$/.test(host) && host.includes(":");
174
287
  }
175
288
  //# sourceMappingURL=rateLimit.core.js.map
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Convenience namespace for rate limiting utilities.
5
5
  */
6
- import { defaultKeyGenerator, defaultHandler, createRateLimiter, extractClientIp } from "./rateLimit.core.js";
6
+ import { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp } from "./rateLimit.core.js";
7
7
  export declare const rateLimit: {
8
8
  defaultKeyGenerator: typeof defaultKeyGenerator;
9
9
  defaultHandler: typeof defaultHandler;
10
+ retryAfterSeconds: typeof retryAfterSeconds;
10
11
  createRateLimiter: typeof createRateLimiter;
11
12
  extractClientIp: typeof extractClientIp;
12
13
  };
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Convenience namespace for rate limiting utilities.
5
5
  */
6
- import { defaultKeyGenerator, defaultHandler, createRateLimiter, extractClientIp, } from "./rateLimit.core.js";
6
+ import { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit.core.js";
7
7
  export const rateLimit = {
8
8
  defaultKeyGenerator,
9
9
  defaultHandler,
10
+ retryAfterSeconds,
10
11
  createRateLimiter,
11
12
  extractClientIp,
12
13
  };