@zudojs/security 0.0.1 → 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.
- package/README.md +254 -11
- package/dist/body/body.core.d.ts +50 -2
- package/dist/body/body.core.js +150 -19
- package/dist/body/index.d.ts +1 -1
- package/dist/body/index.js +1 -1
- package/dist/cookie/cookie.core.d.ts +12 -0
- package/dist/cookie/cookie.core.js +98 -8
- package/dist/cors/cors.core.d.ts +8 -1
- package/dist/cors/cors.core.js +66 -9
- package/dist/csrf/csrf.core.d.ts +150 -9
- package/dist/csrf/csrf.core.js +199 -41
- package/dist/csrf/index.d.ts +2 -1
- package/dist/csrf/index.js +1 -1
- package/dist/header/header.core.js +18 -5
- package/dist/headers/headers.core.js +36 -4
- package/dist/index.d.ts +14 -9
- package/dist/index.js +10 -8
- package/dist/input/index.d.ts +1 -1
- package/dist/input/index.js +1 -1
- package/dist/input/input.core.d.ts +27 -0
- package/dist/input/input.core.js +125 -37
- package/dist/rateLimit/index.d.ts +2 -1
- package/dist/rateLimit/index.js +1 -1
- package/dist/rateLimit/rateLimit.core.d.ts +56 -11
- package/dist/rateLimit/rateLimit.core.js +160 -47
- package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
- package/dist/rateLimit/rateLimit.namespace.js +2 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/security.type.d.ts +36 -19
- package/dist/types/security.type.js +26 -11
- package/dist/url/index.d.ts +2 -1
- package/dist/url/index.js +1 -1
- package/dist/url/url.core.d.ts +58 -2
- package/dist/url/url.core.js +270 -43
- package/package.json +14 -7
- package/dist/body/body.core.d.ts.map +0 -1
- package/dist/body/body.core.js.map +0 -1
- package/dist/body/index.d.ts.map +0 -1
- package/dist/body/index.js.map +0 -1
- package/dist/cookie/cookie.core.d.ts.map +0 -1
- package/dist/cookie/cookie.core.js.map +0 -1
- package/dist/cookie/index.d.ts.map +0 -1
- package/dist/cookie/index.js.map +0 -1
- package/dist/cors/cors.core.d.ts.map +0 -1
- package/dist/cors/cors.core.js.map +0 -1
- package/dist/cors/cors.namespace.d.ts.map +0 -1
- package/dist/cors/cors.namespace.js.map +0 -1
- package/dist/cors/index.d.ts.map +0 -1
- package/dist/cors/index.js.map +0 -1
- package/dist/csrf/csrf.core.d.ts.map +0 -1
- package/dist/csrf/csrf.core.js.map +0 -1
- package/dist/csrf/index.d.ts.map +0 -1
- package/dist/csrf/index.js.map +0 -1
- package/dist/header/header.core.d.ts.map +0 -1
- package/dist/header/header.core.js.map +0 -1
- package/dist/header/index.d.ts.map +0 -1
- package/dist/header/index.js.map +0 -1
- package/dist/headers/headers.core.d.ts.map +0 -1
- package/dist/headers/headers.core.js.map +0 -1
- package/dist/headers/index.d.ts.map +0 -1
- package/dist/headers/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/input/index.d.ts.map +0 -1
- package/dist/input/index.js.map +0 -1
- package/dist/input/input.core.d.ts.map +0 -1
- package/dist/input/input.core.js.map +0 -1
- package/dist/rateLimit/index.d.ts.map +0 -1
- package/dist/rateLimit/index.js.map +0 -1
- package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
- package/dist/rateLimit/rateLimit.core.js.map +0 -1
- package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
- package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/types/security.type.d.ts.map +0 -1
- package/dist/types/security.type.js.map +0 -1
- package/dist/url/index.d.ts.map +0 -1
- package/dist/url/index.js.map +0 -1
- package/dist/url/url.core.d.ts.map +0 -1
- package/dist/url/url.core.js.map +0 -1
package/dist/input/input.core.js
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
64
|
+
sanitized = sanitized.replace(NULL_BYTE_PATTERN_GLOBAL, "");
|
|
50
65
|
}
|
|
51
66
|
// Strip control characters
|
|
52
|
-
sanitized = sanitized.replace(
|
|
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
|
|
84
|
+
* Sanitizes a value of any shape, recursing into arrays and plain objects.
|
|
65
85
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
">": ">",
|
|
155
234
|
'"': """,
|
|
156
235
|
"'": "'",
|
|
236
|
+
"`": "`",
|
|
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
|
package/dist/rateLimit/index.js
CHANGED
|
@@ -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:
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
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"] =
|
|
30
|
-
response.headers["X-RateLimit-Remaining"] =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
+
const cutoff = Date.now() - config.windowMs;
|
|
52
95
|
for (const [key, entry] of store) {
|
|
53
|
-
if (
|
|
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
|
|
80
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
228
|
+
* @param options - Proxy trust configuration.
|
|
229
|
+
* @returns The client IP address, or "unknown".
|
|
155
230
|
*/
|
|
156
|
-
export function extractClientIp(headers) {
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
};
|