@zudojs/security 1.0.1 → 1.2.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 +56 -5
- package/dist/body/body.core.d.ts +6 -0
- package/dist/body/body.core.js +15 -1
- package/dist/body/body.guard.d.ts +19 -0
- package/dist/body/body.guard.js +26 -0
- package/dist/cookie/cookie.core.d.ts +10 -6
- package/dist/cookie/cookie.core.js +22 -20
- package/dist/cookie/cookie.sensitive.d.ts +27 -0
- package/dist/cookie/cookie.sensitive.js +61 -0
- package/dist/cookie/index.d.ts +1 -0
- package/dist/cookie/index.js +1 -0
- package/dist/cors/cors.core.js +2 -1
- package/dist/csrf/csrf.core.d.ts +5 -2
- package/dist/csrf/csrf.core.js +50 -6
- package/dist/headers/headers.core.js +2 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/input/input.core.d.ts +2 -0
- package/dist/input/input.core.js +34 -1
- package/dist/input/input.decode.d.ts +25 -0
- package/dist/input/input.decode.js +72 -0
- package/dist/rateLimit/index.d.ts +9 -2
- package/dist/rateLimit/index.js +6 -1
- package/dist/rateLimit/rateLimit.clientIp.d.ts +40 -0
- package/dist/rateLimit/rateLimit.clientIp.js +71 -0
- package/dist/rateLimit/rateLimit.clientKey.d.ts +51 -0
- package/dist/rateLimit/rateLimit.clientKey.js +104 -0
- package/dist/rateLimit/rateLimit.core.d.ts +5 -30
- package/dist/rateLimit/rateLimit.core.js +12 -73
- package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
- package/dist/rateLimit/rateLimit.namespace.js +2 -1
- package/dist/types/security.type.d.ts +1 -1
- package/dist/types/security.type.js +3 -0
- package/dist/url/index.d.ts +1 -0
- package/dist/url/index.js +1 -0
- package/dist/url/url.core.d.ts +6 -0
- package/dist/url/url.core.js +29 -30
- package/dist/url/url.ipv6.d.ts +36 -0
- package/dist/url/url.ipv6.js +88 -0
- package/package.json +3 -3
package/dist/csrf/csrf.core.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* token minted for one user validates for every other user.
|
|
20
20
|
*/
|
|
21
21
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
22
|
+
import { ConfigurationError } from "@zudojs/errors";
|
|
22
23
|
/** Default token expiration (1 hour). */
|
|
23
24
|
const DEFAULT_EXPIRATION = 3600;
|
|
24
25
|
/** Default cookie name for CSRF token. */
|
|
@@ -37,14 +38,41 @@ const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
|
37
38
|
* the only check, which accepted a one-character secret in silence.
|
|
38
39
|
*/
|
|
39
40
|
export const MIN_CSRF_SECRET_LENGTH = 32;
|
|
41
|
+
/**
|
|
42
|
+
* Rejects a `methods` list that cannot protect anything.
|
|
43
|
+
*
|
|
44
|
+
* `methods.some(…)` over an empty list is always `false`, and `verify()`
|
|
45
|
+
* answers `true` for a method that needs no protection — so `methods: []`
|
|
46
|
+
* turned CSRF off for every request without an error or a warning. A list
|
|
47
|
+
* built from configuration (`process.env.CSRF_METHODS?.split(",") ?? []`) is
|
|
48
|
+
* empty exactly when the configuration is missing. An empty list is a
|
|
49
|
+
* configuration error, not a blanket grant; omit `methods` for the defaults.
|
|
50
|
+
*/
|
|
51
|
+
function assertUsableMethods(methods) {
|
|
52
|
+
if (methods === undefined)
|
|
53
|
+
return;
|
|
54
|
+
if (!Array.isArray(methods)) {
|
|
55
|
+
throw new ConfigurationError("CSRF methods must be an array of HTTP method names, " +
|
|
56
|
+
"or omitted for the defaults (POST, PUT, PATCH, DELETE).");
|
|
57
|
+
}
|
|
58
|
+
if (methods.length === 0) {
|
|
59
|
+
throw new ConfigurationError("CSRF methods cannot be empty: an empty list protects no request at " +
|
|
60
|
+
"all. Omit `methods` for the defaults (POST, PUT, PATCH, DELETE).");
|
|
61
|
+
}
|
|
62
|
+
for (const method of methods) {
|
|
63
|
+
if (typeof method !== "string" || method.trim().length === 0) {
|
|
64
|
+
throw new ConfigurationError("CSRF methods must be non-empty HTTP method names.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
40
68
|
/** Rejects a secret too short to be worth signing with. */
|
|
41
69
|
function assertUsableSecret(secret) {
|
|
42
|
-
if (secret.length === 0) {
|
|
43
|
-
throw new
|
|
70
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
71
|
+
throw new ConfigurationError("CSRF secret cannot be empty: pass a random string of at least " +
|
|
44
72
|
`${MIN_CSRF_SECRET_LENGTH} characters, e.g. randomBytes(32).toString("hex")`);
|
|
45
73
|
}
|
|
46
74
|
if (secret.length < MIN_CSRF_SECRET_LENGTH) {
|
|
47
|
-
throw new
|
|
75
|
+
throw new ConfigurationError(`CSRF secret is too short: got ${secret.length} characters, expected at least ` +
|
|
48
76
|
`${MIN_CSRF_SECRET_LENGTH}. The signature is HMAC-SHA256, so a shorter ` +
|
|
49
77
|
'secret adds no strength. Generate one with randomBytes(32).toString("hex").');
|
|
50
78
|
}
|
|
@@ -101,10 +129,18 @@ export function generateCsrfToken(secret, options) {
|
|
|
101
129
|
* @param options - Maximum lifetime and session binding, or a bare expiration
|
|
102
130
|
* in seconds for backwards compatibility.
|
|
103
131
|
* @returns True if the token is valid, unexpired, and bound to this session.
|
|
132
|
+
* @throws {ConfigurationError} when the secret is empty or shorter than
|
|
133
|
+
* {@link MIN_CSRF_SECRET_LENGTH}, exactly as `generateCsrfToken` does.
|
|
104
134
|
*/
|
|
105
135
|
export function validateCsrfToken(token, secret, options) {
|
|
136
|
+
// Same bar as `generateCsrfToken`: a verifier configured with
|
|
137
|
+
// `process.env.CSRF_SECRET ?? ""` otherwise accepted tokens anyone can sign
|
|
138
|
+
// with an empty HMAC key.
|
|
139
|
+
assertUsableSecret(secret);
|
|
106
140
|
const opts = typeof options === "number" ? { expiration: options } : (options ?? {});
|
|
107
141
|
const maxTtl = opts.expiration ?? DEFAULT_EXPIRATION;
|
|
142
|
+
if (typeof token !== "string")
|
|
143
|
+
return false;
|
|
108
144
|
const parts = token.split(":");
|
|
109
145
|
if (parts.length !== 3) {
|
|
110
146
|
return false;
|
|
@@ -150,6 +186,7 @@ export function validateCsrfToken(token, secret, options) {
|
|
|
150
186
|
* @returns True when the request carries a matching, valid token.
|
|
151
187
|
*/
|
|
152
188
|
export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
|
|
189
|
+
assertUsableSecret(secret);
|
|
153
190
|
if (!cookieToken || !requestToken) {
|
|
154
191
|
return false;
|
|
155
192
|
}
|
|
@@ -166,11 +203,16 @@ export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
|
|
|
166
203
|
* @returns True if CSRF protection is required.
|
|
167
204
|
*/
|
|
168
205
|
export function requiresCsrfProtection(method, config) {
|
|
206
|
+
assertUsableMethods(config?.methods);
|
|
169
207
|
if (SAFE_METHODS.includes(method.toUpperCase())) {
|
|
170
208
|
return false;
|
|
171
209
|
}
|
|
210
|
+
// HTTP methods are case-sensitive on the wire but configured by hand;
|
|
211
|
+
// comparing a lower-case `methods: ["post"]` against the upper-cased
|
|
212
|
+
// request method used to protect nothing, silently.
|
|
213
|
+
const upper = method.toUpperCase();
|
|
172
214
|
const methods = config?.methods ?? DEFAULT_METHODS;
|
|
173
|
-
return methods.
|
|
215
|
+
return methods.some((m) => String(m).toUpperCase() === upper);
|
|
174
216
|
}
|
|
175
217
|
/**
|
|
176
218
|
* Extracts the CSRF token from request headers.
|
|
@@ -257,11 +299,13 @@ export function generateCsrfCookie(token, config) {
|
|
|
257
299
|
*
|
|
258
300
|
* @param config - Secret, lifetime, cookie/header names, protected methods.
|
|
259
301
|
* @returns Protection bound to that configuration.
|
|
260
|
-
* @throws {
|
|
261
|
-
* {@link MIN_CSRF_SECRET_LENGTH}
|
|
302
|
+
* @throws {ConfigurationError} when the secret is missing or shorter than
|
|
303
|
+
* {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
|
|
304
|
+
* not an array, or contains a non-method entry.
|
|
262
305
|
*/
|
|
263
306
|
export function createCsrfProtection(config) {
|
|
264
307
|
assertUsableSecret(config.secret);
|
|
308
|
+
assertUsableMethods(config.methods);
|
|
265
309
|
const expiration = config.expiration ?? DEFAULT_EXPIRATION;
|
|
266
310
|
const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
267
311
|
const headerName = config.headerName ?? DEFAULT_HEADER_NAME;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Generates security-related HTTP response headers with secure defaults.
|
|
5
5
|
*/
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { ConfigurationError } from "@zudojs/errors";
|
|
7
8
|
/** Security header names. */
|
|
8
9
|
export const SECURITY_HEADER_NAMES = {
|
|
9
10
|
CONTENT_SECURITY_POLICY: "Content-Security-Policy",
|
|
@@ -58,7 +59,7 @@ export function generateSecurityHeaders(config) {
|
|
|
58
59
|
if (config) {
|
|
59
60
|
for (const [key, value] of Object.entries(config)) {
|
|
60
61
|
if (typeof value === "string" && /[\r\n\x00]/.test(value)) {
|
|
61
|
-
throw new
|
|
62
|
+
throw new ConfigurationError(`Security header "${key}" contains CRLF or null bytes (injection risk)`);
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -31,17 +31,17 @@
|
|
|
31
31
|
export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig, InputSanitizationConfig, } from "./types/security.type.js";
|
|
32
32
|
export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "./types/security.type.js";
|
|
33
33
|
export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header/index.js";
|
|
34
|
-
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url/index.js";
|
|
34
|
+
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, expandIpv6, embeddedIpv4, isNonPublicIpv6Range, } from "./url/index.js";
|
|
35
35
|
export type { RequestTargetConfig } from "./url/index.js";
|
|
36
36
|
export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body/index.js";
|
|
37
|
-
export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie/index.js";
|
|
37
|
+
export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie/index.js";
|
|
38
38
|
export type { CorsHeaders } from "./cors/index.js";
|
|
39
39
|
export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors/index.js";
|
|
40
40
|
export { cors } from "./cors/cors.namespace.js";
|
|
41
41
|
export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf/index.js";
|
|
42
42
|
export type { CsrfTokenOptions, CsrfCookieOptions, CsrfProtection, CsrfProtectionOptions, IssuedCsrfToken, CsrfVerifiableRequest, } from "./csrf/index.js";
|
|
43
|
-
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
|
|
44
|
-
export type { RateLimiterOptions, ClientIpOptions } from "./rateLimit/index.js";
|
|
43
|
+
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, createIpKeyGenerator, ipRateLimitKey, parseClientIp, DEFAULT_IPV6_PREFIX_LENGTH, } from "./rateLimit/index.js";
|
|
44
|
+
export type { RateLimiterOptions, ClientIpOptions, IpKeyOptions, } from "./rateLimit/index.js";
|
|
45
45
|
export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
|
|
46
46
|
export { SECURITY_HEADER_NAMES } from "./headers/index.js";
|
|
47
47
|
export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
|
package/dist/index.js
CHANGED
|
@@ -32,17 +32,17 @@ export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from
|
|
|
32
32
|
/* ─── Header Security ────────────────────────────────────────────────────── */
|
|
33
33
|
export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header/index.js";
|
|
34
34
|
/* ─── URL Validation ─────────────────────────────────────────────────────── */
|
|
35
|
-
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url/index.js";
|
|
35
|
+
export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, expandIpv6, embeddedIpv4, isNonPublicIpv6Range, } from "./url/index.js";
|
|
36
36
|
/* ─── Body Validation ────────────────────────────────────────────────────── */
|
|
37
37
|
export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body/index.js";
|
|
38
38
|
/* ─── Cookie Security ────────────────────────────────────────────────────── */
|
|
39
|
-
export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie/index.js";
|
|
39
|
+
export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie/index.js";
|
|
40
40
|
export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors/index.js";
|
|
41
41
|
export { cors } from "./cors/cors.namespace.js";
|
|
42
42
|
/* ─── CSRF ───────────────────────────────────────────────────────────────── */
|
|
43
43
|
export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf/index.js";
|
|
44
44
|
/* ─── Rate Limiting ──────────────────────────────────────────────────────── */
|
|
45
|
-
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
|
|
45
|
+
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, createIpKeyGenerator, ipRateLimitKey, parseClientIp, DEFAULT_IPV6_PREFIX_LENGTH, } from "./rateLimit/index.js";
|
|
46
46
|
export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
|
|
47
47
|
/* ─── Security Headers ───────────────────────────────────────────────────── */
|
|
48
48
|
export { SECURITY_HEADER_NAMES } from "./headers/index.js";
|
|
@@ -46,6 +46,8 @@ export declare function sanitizeString(input: string, config?: InputSanitization
|
|
|
46
46
|
* @param obj - The object to sanitize.
|
|
47
47
|
* @param config - Optional sanitization configuration.
|
|
48
48
|
* @returns The sanitized object.
|
|
49
|
+
* @throws {ConfigurationError} when `config.maxDepth` is present but is not
|
|
50
|
+
* an integer of 1 or more.
|
|
49
51
|
*/
|
|
50
52
|
export declare function sanitizeObject<T extends Record<string, unknown>>(obj: T, config?: InputSanitizationConfig): T;
|
|
51
53
|
/**
|
package/dist/input/input.core.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Sanitizes user input against common attack patterns.
|
|
5
5
|
*/
|
|
6
|
+
import { ConfigurationError } from "@zudojs/errors";
|
|
6
7
|
import { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "../types/security.type.js";
|
|
8
|
+
import { containsObfuscatedScheme, decodeHtmlEntities } from "./input.decode.js";
|
|
7
9
|
/**
|
|
8
10
|
* Null byte and control character patterns.
|
|
9
11
|
*
|
|
@@ -39,7 +41,15 @@ export function containsSqlInjection(input) {
|
|
|
39
41
|
* @returns True if XSS patterns are detected.
|
|
40
42
|
*/
|
|
41
43
|
export function containsXss(input) {
|
|
42
|
-
|
|
44
|
+
if (XSS_PATTERNS.some((pattern) => pattern.test(input)))
|
|
45
|
+
return true;
|
|
46
|
+
// Entity-encoded payloads (`javascript:`, `javascript:`,
|
|
47
|
+
// `java	script:`) are decoded by the browser but not by a regex.
|
|
48
|
+
const decoded = decodeHtmlEntities(input);
|
|
49
|
+
if (decoded !== input && XSS_PATTERNS.some((pattern) => pattern.test(decoded))) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return containsObfuscatedScheme(decoded);
|
|
43
53
|
}
|
|
44
54
|
/**
|
|
45
55
|
* Checks if a string contains prototype pollution keys.
|
|
@@ -155,11 +165,34 @@ function isPlainObject(value) {
|
|
|
155
165
|
* @param obj - The object to sanitize.
|
|
156
166
|
* @param config - Optional sanitization configuration.
|
|
157
167
|
* @returns The sanitized object.
|
|
168
|
+
* @throws {ConfigurationError} when `config.maxDepth` is present but is not
|
|
169
|
+
* an integer of 1 or more.
|
|
158
170
|
*/
|
|
159
171
|
export function sanitizeObject(obj, config) {
|
|
160
172
|
const maxDepth = config?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
173
|
+
assertUsableMaxDepth(config?.maxDepth);
|
|
161
174
|
return sanitizeValue(obj, config, new WeakSet(), 0, maxDepth);
|
|
162
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Rejects a `maxDepth` that cannot describe a depth the caller wants kept.
|
|
178
|
+
*
|
|
179
|
+
* The guard runs before the object is entered, so `maxDepth: 0` discarded the
|
|
180
|
+
* argument itself and returned `undefined` under a non-optional `T` — every
|
|
181
|
+
* field read off the result then threw at a call site TypeScript had told was
|
|
182
|
+
* safe. `Number(process.env.MAX_DEPTH)` with the variable unset is the same
|
|
183
|
+
* shape of mistake as the body limit's `NaN`.
|
|
184
|
+
*
|
|
185
|
+
* @throws {ConfigurationError} when `maxDepth` is present but is not an
|
|
186
|
+
* integer of 1 or more.
|
|
187
|
+
*/
|
|
188
|
+
function assertUsableMaxDepth(maxDepth) {
|
|
189
|
+
if (maxDepth === undefined)
|
|
190
|
+
return;
|
|
191
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) {
|
|
192
|
+
throw new ConfigurationError("Input sanitization maxDepth must be an integer of 1 or more, got: " +
|
|
193
|
+
String(maxDepth));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
163
196
|
/**
|
|
164
197
|
* Validates that a string contains only safe characters.
|
|
165
198
|
*
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — HTML entity decoding for the XSS heuristic.
|
|
3
|
+
*
|
|
4
|
+
* Browsers decode character references in attribute values before they
|
|
5
|
+
* interpret a URL, and they drop tabs and newlines inside a scheme. A regex
|
|
6
|
+
* run over the raw text therefore missed `javascript:`,
|
|
7
|
+
* `javascript:`, `javascript:` and `java	script:`.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Decodes numeric (`j`, `j`, with or without the trailing `;`)
|
|
11
|
+
* and the relevant named character references, repeatedly, up to three
|
|
12
|
+
* rounds.
|
|
13
|
+
*
|
|
14
|
+
* @param input - Raw text.
|
|
15
|
+
* @returns The decoded text (unchanged when it holds no references).
|
|
16
|
+
*/
|
|
17
|
+
export declare function decodeHtmlEntities(input: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* True when a script-capable URL scheme appears once whitespace and control
|
|
20
|
+
* characters are removed, as a browser removes them when parsing a URL.
|
|
21
|
+
*
|
|
22
|
+
* @param decoded - Text that has already been entity-decoded.
|
|
23
|
+
*/
|
|
24
|
+
export declare function containsObfuscatedScheme(decoded: string): boolean;
|
|
25
|
+
//# sourceMappingURL=input.decode.d.ts.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — HTML entity decoding for the XSS heuristic.
|
|
3
|
+
*
|
|
4
|
+
* Browsers decode character references in attribute values before they
|
|
5
|
+
* interpret a URL, and they drop tabs and newlines inside a scheme. A regex
|
|
6
|
+
* run over the raw text therefore missed `javascript:`,
|
|
7
|
+
* `javascript:`, `javascript:` and `java	script:`.
|
|
8
|
+
*/
|
|
9
|
+
/** Named references that matter for smuggling a scheme or a tag. */
|
|
10
|
+
const NAMED_ENTITIES = Object.freeze({
|
|
11
|
+
colon: ":",
|
|
12
|
+
tab: "\t",
|
|
13
|
+
newline: "\n",
|
|
14
|
+
lpar: "(",
|
|
15
|
+
rpar: ")",
|
|
16
|
+
lt: "<",
|
|
17
|
+
gt: ">",
|
|
18
|
+
quot: '"',
|
|
19
|
+
apos: "'",
|
|
20
|
+
amp: "&",
|
|
21
|
+
sol: "/",
|
|
22
|
+
bsol: "\\",
|
|
23
|
+
period: ".",
|
|
24
|
+
equals: "=",
|
|
25
|
+
nbsp: " ",
|
|
26
|
+
});
|
|
27
|
+
/** Rounds of decoding, so `&#106;` is unwrapped too. */
|
|
28
|
+
const MAX_ROUNDS = 3;
|
|
29
|
+
const ENTITY_PATTERN = /&(?:#(\d{1,8})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]{2,10}));?/g;
|
|
30
|
+
function decodeOnce(input) {
|
|
31
|
+
return input.replace(ENTITY_PATTERN, (match, dec, hex, name) => {
|
|
32
|
+
const code = dec !== undefined
|
|
33
|
+
? Number.parseInt(dec, 10)
|
|
34
|
+
: hex !== undefined
|
|
35
|
+
? Number.parseInt(hex, 16)
|
|
36
|
+
: undefined;
|
|
37
|
+
if (code !== undefined) {
|
|
38
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
39
|
+
}
|
|
40
|
+
const named = name === undefined ? undefined : NAMED_ENTITIES[name.toLowerCase()];
|
|
41
|
+
return named ?? match;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decodes numeric (`j`, `j`, with or without the trailing `;`)
|
|
46
|
+
* and the relevant named character references, repeatedly, up to three
|
|
47
|
+
* rounds.
|
|
48
|
+
*
|
|
49
|
+
* @param input - Raw text.
|
|
50
|
+
* @returns The decoded text (unchanged when it holds no references).
|
|
51
|
+
*/
|
|
52
|
+
export function decodeHtmlEntities(input) {
|
|
53
|
+
let current = input;
|
|
54
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
55
|
+
const next = decodeOnce(current);
|
|
56
|
+
if (next === current)
|
|
57
|
+
break;
|
|
58
|
+
current = next;
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* True when a script-capable URL scheme appears once whitespace and control
|
|
64
|
+
* characters are removed, as a browser removes them when parsing a URL.
|
|
65
|
+
*
|
|
66
|
+
* @param decoded - Text that has already been entity-decoded.
|
|
67
|
+
*/
|
|
68
|
+
export function containsObfuscatedScheme(decoded) {
|
|
69
|
+
const compact = decoded.replace(/[\u0000- \u007f- ]+/g, "");
|
|
70
|
+
return /(?:javascript|vbscript|livescript):|data:text\/html/i.test(compact);
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=input.decode.js.map
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — Rate Limiting Barrel
|
|
3
|
+
*
|
|
4
|
+
* Sliding-window limiter, client address extraction, and IP-derived
|
|
5
|
+
* rate-limit keys (port-stripped, IPv6 bucketed by prefix).
|
|
3
6
|
*/
|
|
4
|
-
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter,
|
|
5
|
-
export type { RateLimiterOptions
|
|
7
|
+
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, } from "./rateLimit.core.js";
|
|
8
|
+
export type { RateLimiterOptions } from "./rateLimit.core.js";
|
|
9
|
+
export { extractClientIp } from "./rateLimit.clientIp.js";
|
|
10
|
+
export type { ClientIpOptions } from "./rateLimit.clientIp.js";
|
|
11
|
+
export { createIpKeyGenerator, ipRateLimitKey, parseClientIp, DEFAULT_IPV6_PREFIX_LENGTH, } from "./rateLimit.clientKey.js";
|
|
12
|
+
export type { IpKeyOptions } from "./rateLimit.clientKey.js";
|
|
6
13
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/rateLimit/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — Rate Limiting Barrel
|
|
3
|
+
*
|
|
4
|
+
* Sliding-window limiter, client address extraction, and IP-derived
|
|
5
|
+
* rate-limit keys (port-stripped, IPv6 bucketed by prefix).
|
|
3
6
|
*/
|
|
4
|
-
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter,
|
|
7
|
+
export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, } from "./rateLimit.core.js";
|
|
8
|
+
export { extractClientIp } from "./rateLimit.clientIp.js";
|
|
9
|
+
export { createIpKeyGenerator, ipRateLimitKey, parseClientIp, DEFAULT_IPV6_PREFIX_LENGTH, } from "./rateLimit.clientKey.js";
|
|
5
10
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — Client address extraction from forwarding headers.
|
|
3
|
+
*/
|
|
4
|
+
/** Options controlling how far forwarding headers are trusted. */
|
|
5
|
+
export interface ClientIpOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Number of reverse proxies you operate in front of this service.
|
|
8
|
+
*
|
|
9
|
+
* `X-Forwarded-For` is appended to by every hop, so the entries closest to
|
|
10
|
+
* the right are the ones your own infrastructure added. With `trustProxy: 1`
|
|
11
|
+
* the last entry is used, with `2` the second-to-last, and so on. Entries to
|
|
12
|
+
* the left of your proxies were supplied by the client and are ignored.
|
|
13
|
+
*
|
|
14
|
+
* Defaults to `0`: no forwarding header is trusted at all.
|
|
15
|
+
*/
|
|
16
|
+
readonly trustProxy?: number;
|
|
17
|
+
/** The connection's remote address, used when no header is trusted. */
|
|
18
|
+
readonly remoteAddress?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Extracts the client IP from request headers.
|
|
22
|
+
*
|
|
23
|
+
* **Forwarding headers are not trusted by default.** Any client can send
|
|
24
|
+
* `X-Forwarded-For`, so taking its leftmost entry — the historical behaviour —
|
|
25
|
+
* hands the caller control of their own rate-limit bucket, and rotating it
|
|
26
|
+
* defeats the limiter entirely. Pass `trustProxy` set to the number of proxies
|
|
27
|
+
* you actually run, together with the socket's `remoteAddress`.
|
|
28
|
+
*
|
|
29
|
+
* The returned address has any port and IPv6 brackets removed: some proxies
|
|
30
|
+
* append `client-ip:port`, and returning that verbatim gave every new TCP
|
|
31
|
+
* source port its own rate-limit bucket. Values that are not IP addresses
|
|
32
|
+
* are ignored.
|
|
33
|
+
*
|
|
34
|
+
* @param headers - Request headers.
|
|
35
|
+
* @param options - Proxy trust configuration.
|
|
36
|
+
* @returns The client IP address, or "unknown". Note that the default
|
|
37
|
+
* rate-limit key generator refuses `"unknown"`; pass `remoteAddress`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function extractClientIp(headers: Record<string, string | string[] | undefined>, options?: ClientIpOptions): string;
|
|
40
|
+
//# sourceMappingURL=rateLimit.clientIp.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — Client address extraction from forwarding headers.
|
|
3
|
+
*/
|
|
4
|
+
import { parseClientIp } from "./rateLimit.clientKey.js";
|
|
5
|
+
/**
|
|
6
|
+
* Extracts the client IP from request headers.
|
|
7
|
+
*
|
|
8
|
+
* **Forwarding headers are not trusted by default.** Any client can send
|
|
9
|
+
* `X-Forwarded-For`, so taking its leftmost entry — the historical behaviour —
|
|
10
|
+
* hands the caller control of their own rate-limit bucket, and rotating it
|
|
11
|
+
* defeats the limiter entirely. Pass `trustProxy` set to the number of proxies
|
|
12
|
+
* you actually run, together with the socket's `remoteAddress`.
|
|
13
|
+
*
|
|
14
|
+
* The returned address has any port and IPv6 brackets removed: some proxies
|
|
15
|
+
* append `client-ip:port`, and returning that verbatim gave every new TCP
|
|
16
|
+
* source port its own rate-limit bucket. Values that are not IP addresses
|
|
17
|
+
* are ignored.
|
|
18
|
+
*
|
|
19
|
+
* @param headers - Request headers.
|
|
20
|
+
* @param options - Proxy trust configuration.
|
|
21
|
+
* @returns The client IP address, or "unknown". Note that the default
|
|
22
|
+
* rate-limit key generator refuses `"unknown"`; pass `remoteAddress`.
|
|
23
|
+
*/
|
|
24
|
+
export function extractClientIp(headers, options) {
|
|
25
|
+
const trustProxy = options?.trustProxy ?? 0;
|
|
26
|
+
const fallback = options?.remoteAddress ?? "unknown";
|
|
27
|
+
if (trustProxy <= 0) {
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
const raw = lookupHeader(headers, "x-forwarded-for");
|
|
31
|
+
if (raw !== undefined) {
|
|
32
|
+
const chain = raw
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((entry) => entry.trim())
|
|
35
|
+
.filter((entry) => entry.length > 0);
|
|
36
|
+
// Walk in from the right: index 0 from the end is the address our own
|
|
37
|
+
// outermost proxy observed, and each additional trusted hop steps left.
|
|
38
|
+
const index = chain.length - trustProxy;
|
|
39
|
+
// A chain shorter than the configured hop count did not pass through all
|
|
40
|
+
// of the proxies whose entries make it trustworthy — a request entering
|
|
41
|
+
// at an inner hop, or one a client shortened on purpose. Clamping the
|
|
42
|
+
// index to 0 landed on the entry the client wrote, handing them their own
|
|
43
|
+
// rate-limit bucket; there is nothing trustworthy to read here, so the
|
|
44
|
+
// header is skipped entirely.
|
|
45
|
+
if (index >= 0) {
|
|
46
|
+
const candidate = chain[index];
|
|
47
|
+
const ip = candidate === undefined ? undefined : parseClientIp(candidate);
|
|
48
|
+
if (ip !== undefined)
|
|
49
|
+
return ip;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const realIp = lookupHeader(headers, "x-real-ip");
|
|
53
|
+
const ip = realIp === undefined ? undefined : parseClientIp(realIp);
|
|
54
|
+
if (ip !== undefined)
|
|
55
|
+
return ip;
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
/** Case-insensitive header lookup that flattens repeated fields. */
|
|
59
|
+
function lookupHeader(headers, name) {
|
|
60
|
+
for (const key of Object.keys(headers)) {
|
|
61
|
+
if (key.toLowerCase() !== name)
|
|
62
|
+
continue;
|
|
63
|
+
const value = headers[key];
|
|
64
|
+
if (typeof value === "string")
|
|
65
|
+
return value;
|
|
66
|
+
if (Array.isArray(value) && value.length > 0)
|
|
67
|
+
return value.join(",");
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=rateLimit.clientIp.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — Rate-limit keys derived from client addresses.
|
|
3
|
+
*
|
|
4
|
+
* An address is only a useful bucket if one client cannot cheaply become
|
|
5
|
+
* many. Two things made that cheap: a `host:port` string (some proxies
|
|
6
|
+
* append the client's source port to `X-Forwarded-For`), which gave every
|
|
7
|
+
* new TCP connection its own bucket, and full /128 IPv6 keys, which let any
|
|
8
|
+
* host rotate through the 2^64 addresses of its own /64.
|
|
9
|
+
*/
|
|
10
|
+
import type { RateLimitRequest } from "../types/security.type.js";
|
|
11
|
+
/** Default IPv6 prefix a single client is bucketed by. */
|
|
12
|
+
export declare const DEFAULT_IPV6_PREFIX_LENGTH = 64;
|
|
13
|
+
/** Options for {@link createIpKeyGenerator} and {@link ipRateLimitKey}. */
|
|
14
|
+
export interface IpKeyOptions {
|
|
15
|
+
/**
|
|
16
|
+
* IPv6 prefix length, in bits, that counts as one client (default: 64,
|
|
17
|
+
* the smallest allocation an end site normally receives). 128 keys every
|
|
18
|
+
* address separately.
|
|
19
|
+
*/
|
|
20
|
+
readonly ipv6PrefixLength?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Parses an address as it appears in a forwarding header or socket, with
|
|
24
|
+
* any port and IPv6 brackets or zone id removed.
|
|
25
|
+
*
|
|
26
|
+
* Accepts `1.2.3.4`, `1.2.3.4:5678`, `2001:db8::1`, `[2001:db8::1]` and
|
|
27
|
+
* `[2001:db8::1]:443`.
|
|
28
|
+
*
|
|
29
|
+
* @returns The bare address, or `undefined` if the value is not an IP.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseClientIp(value: string): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The rate-limit key for a client address: IPv4 as-is (port stripped),
|
|
34
|
+
* IPv4-mapped IPv6 as its IPv4 address, and other IPv6 addresses truncated
|
|
35
|
+
* to their `ipv6PrefixLength` network, e.g. `2001:db8:0:1:0:0:0:0/64`.
|
|
36
|
+
*
|
|
37
|
+
* @returns The key, or `undefined` when `value` is not an IP address.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ipRateLimitKey(value: string, options?: IpKeyOptions): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Creates a key generator that buckets requests by `request.ip`.
|
|
42
|
+
*
|
|
43
|
+
* Throws a `ConfigurationError` when `request.ip` is missing or is not an IP
|
|
44
|
+
* address (including the `"unknown"` placeholder `extractClientIp` returns).
|
|
45
|
+
* Keying such requests under one shared `"unknown"` bucket let a single
|
|
46
|
+
* client starve every other client whose address was not available.
|
|
47
|
+
*
|
|
48
|
+
* @throws {RangeError} when `ipv6PrefixLength` is not an integer in 1–128.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createIpKeyGenerator(options?: IpKeyOptions): (request: RateLimitRequest) => string;
|
|
51
|
+
//# sourceMappingURL=rateLimit.clientKey.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — Rate-limit keys derived from client addresses.
|
|
3
|
+
*
|
|
4
|
+
* An address is only a useful bucket if one client cannot cheaply become
|
|
5
|
+
* many. Two things made that cheap: a `host:port` string (some proxies
|
|
6
|
+
* append the client's source port to `X-Forwarded-For`), which gave every
|
|
7
|
+
* new TCP connection its own bucket, and full /128 IPv6 keys, which let any
|
|
8
|
+
* host rotate through the 2^64 addresses of its own /64.
|
|
9
|
+
*/
|
|
10
|
+
import { isIPv4, isIPv6 } from "node:net";
|
|
11
|
+
import { ConfigurationError } from "@zudojs/errors";
|
|
12
|
+
import { expandIpv6 } from "../url/url.ipv6.js";
|
|
13
|
+
/** Default IPv6 prefix a single client is bucketed by. */
|
|
14
|
+
export const DEFAULT_IPV6_PREFIX_LENGTH = 64;
|
|
15
|
+
/**
|
|
16
|
+
* Parses an address as it appears in a forwarding header or socket, with
|
|
17
|
+
* any port and IPv6 brackets or zone id removed.
|
|
18
|
+
*
|
|
19
|
+
* Accepts `1.2.3.4`, `1.2.3.4:5678`, `2001:db8::1`, `[2001:db8::1]` and
|
|
20
|
+
* `[2001:db8::1]:443`.
|
|
21
|
+
*
|
|
22
|
+
* @returns The bare address, or `undefined` if the value is not an IP.
|
|
23
|
+
*/
|
|
24
|
+
export function parseClientIp(value) {
|
|
25
|
+
const raw = value.trim();
|
|
26
|
+
let host = raw;
|
|
27
|
+
if (raw.startsWith("[")) {
|
|
28
|
+
const close = raw.indexOf("]");
|
|
29
|
+
if (close === -1)
|
|
30
|
+
return undefined;
|
|
31
|
+
const rest = raw.slice(close + 1);
|
|
32
|
+
if (rest !== "" && !/^:\d{1,5}$/.test(rest))
|
|
33
|
+
return undefined;
|
|
34
|
+
host = raw.slice(1, close);
|
|
35
|
+
}
|
|
36
|
+
else if (raw.split(":").length === 2) {
|
|
37
|
+
const [address, port] = raw.split(":");
|
|
38
|
+
if (!/^\d{1,5}$/.test(port ?? ""))
|
|
39
|
+
return undefined;
|
|
40
|
+
host = address ?? "";
|
|
41
|
+
}
|
|
42
|
+
const zone = host.indexOf("%");
|
|
43
|
+
if (zone !== -1)
|
|
44
|
+
host = host.slice(0, zone);
|
|
45
|
+
if (isIPv4(host))
|
|
46
|
+
return host;
|
|
47
|
+
if (isIPv6(host))
|
|
48
|
+
return host.toLowerCase();
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The rate-limit key for a client address: IPv4 as-is (port stripped),
|
|
53
|
+
* IPv4-mapped IPv6 as its IPv4 address, and other IPv6 addresses truncated
|
|
54
|
+
* to their `ipv6PrefixLength` network, e.g. `2001:db8:0:1:0:0:0:0/64`.
|
|
55
|
+
*
|
|
56
|
+
* @returns The key, or `undefined` when `value` is not an IP address.
|
|
57
|
+
*/
|
|
58
|
+
export function ipRateLimitKey(value, options) {
|
|
59
|
+
const prefix = options?.ipv6PrefixLength ?? DEFAULT_IPV6_PREFIX_LENGTH;
|
|
60
|
+
const ip = parseClientIp(value);
|
|
61
|
+
if (ip === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (isIPv4(ip))
|
|
64
|
+
return ip;
|
|
65
|
+
const groups = expandIpv6(ip);
|
|
66
|
+
if (!groups)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) {
|
|
69
|
+
const [a = 0, b = 0] = groups.slice(6);
|
|
70
|
+
return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
|
|
71
|
+
}
|
|
72
|
+
const masked = groups.map((group, index) => {
|
|
73
|
+
const bits = Math.min(16, Math.max(0, prefix - index * 16));
|
|
74
|
+
return bits === 0 ? 0 : group & ((0xffff << (16 - bits)) & 0xffff);
|
|
75
|
+
});
|
|
76
|
+
return `${masked.map((g) => g.toString(16)).join(":")}/${prefix}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Creates a key generator that buckets requests by `request.ip`.
|
|
80
|
+
*
|
|
81
|
+
* Throws a `ConfigurationError` when `request.ip` is missing or is not an IP
|
|
82
|
+
* address (including the `"unknown"` placeholder `extractClientIp` returns).
|
|
83
|
+
* Keying such requests under one shared `"unknown"` bucket let a single
|
|
84
|
+
* client starve every other client whose address was not available.
|
|
85
|
+
*
|
|
86
|
+
* @throws {RangeError} when `ipv6PrefixLength` is not an integer in 1–128.
|
|
87
|
+
*/
|
|
88
|
+
export function createIpKeyGenerator(options) {
|
|
89
|
+
const prefix = options?.ipv6PrefixLength ?? DEFAULT_IPV6_PREFIX_LENGTH;
|
|
90
|
+
if (!Number.isInteger(prefix) || prefix < 1 || prefix > 128) {
|
|
91
|
+
throw new RangeError(`ipv6PrefixLength must be an integer in 1-128, got: ${prefix}`);
|
|
92
|
+
}
|
|
93
|
+
return (request) => {
|
|
94
|
+
const key = typeof request.ip === "string"
|
|
95
|
+
? ipRateLimitKey(request.ip, { ipv6PrefixLength: prefix })
|
|
96
|
+
: undefined;
|
|
97
|
+
if (key === undefined) {
|
|
98
|
+
throw new ConfigurationError("Rate limiting by IP needs RateLimitRequest.ip to be a client address; " +
|
|
99
|
+
"pass the socket's remoteAddress (or extractClientIp with it), or supply a keyGenerator.");
|
|
100
|
+
}
|
|
101
|
+
return key;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=rateLimit.clientKey.js.map
|