@zudojs/security 1.0.1 → 1.1.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 +3 -1
- package/dist/csrf/csrf.core.js +19 -5
- 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.js +10 -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 +63 -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.js +12 -27
- package/dist/url/url.ipv6.d.ts +36 -0
- package/dist/url/url.ipv6.js +88 -0
- package/package.json +3 -3
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";
|
package/dist/input/input.core.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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
|
+
import { containsObfuscatedScheme, decodeHtmlEntities } from "./input.decode.js";
|
|
7
8
|
/**
|
|
8
9
|
* Null byte and control character patterns.
|
|
9
10
|
*
|
|
@@ -39,7 +40,15 @@ export function containsSqlInjection(input) {
|
|
|
39
40
|
* @returns True if XSS patterns are detected.
|
|
40
41
|
*/
|
|
41
42
|
export function containsXss(input) {
|
|
42
|
-
|
|
43
|
+
if (XSS_PATTERNS.some((pattern) => pattern.test(input)))
|
|
44
|
+
return true;
|
|
45
|
+
// Entity-encoded payloads (`javascript:`, `javascript:`,
|
|
46
|
+
// `java	script:`) are decoded by the browser but not by a regex.
|
|
47
|
+
const decoded = decodeHtmlEntities(input);
|
|
48
|
+
if (decoded !== input && XSS_PATTERNS.some((pattern) => pattern.test(decoded))) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return containsObfuscatedScheme(decoded);
|
|
43
52
|
}
|
|
44
53
|
/**
|
|
45
54
|
* Checks if a string contains prototype pollution keys.
|
|
@@ -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,63 @@
|
|
|
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
|
+
const candidate = chain[Math.max(0, index)];
|
|
40
|
+
const ip = candidate === undefined ? undefined : parseClientIp(candidate);
|
|
41
|
+
if (ip !== undefined)
|
|
42
|
+
return ip;
|
|
43
|
+
}
|
|
44
|
+
const realIp = lookupHeader(headers, "x-real-ip");
|
|
45
|
+
const ip = realIp === undefined ? undefined : parseClientIp(realIp);
|
|
46
|
+
if (ip !== undefined)
|
|
47
|
+
return ip;
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
/** Case-insensitive header lookup that flattens repeated fields. */
|
|
51
|
+
function lookupHeader(headers, name) {
|
|
52
|
+
for (const key of Object.keys(headers)) {
|
|
53
|
+
if (key.toLowerCase() !== name)
|
|
54
|
+
continue;
|
|
55
|
+
const value = headers[key];
|
|
56
|
+
if (typeof value === "string")
|
|
57
|
+
return value;
|
|
58
|
+
if (Array.isArray(value) && value.length > 0)
|
|
59
|
+
return value.join(",");
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
//# 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
|
|
@@ -7,8 +7,13 @@ import type { RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitRes
|
|
|
7
7
|
/**
|
|
8
8
|
* Default key generator using IP address.
|
|
9
9
|
*
|
|
10
|
+
* The port is stripped, IPv4-mapped IPv6 is keyed as IPv4, and IPv6 is
|
|
11
|
+
* bucketed by /64 (see {@link createIpKeyGenerator} for another prefix).
|
|
12
|
+
*
|
|
10
13
|
* @param request - The rate limit request.
|
|
11
14
|
* @returns The rate limit key.
|
|
15
|
+
* @throws {ConfigurationError} when `request.ip` is missing or not an IP
|
|
16
|
+
* address; requests used to share a single `"unknown"` bucket.
|
|
12
17
|
*/
|
|
13
18
|
export declare function defaultKeyGenerator(request: RateLimitRequest): string;
|
|
14
19
|
/**
|
|
@@ -63,34 +68,4 @@ export declare function createRateLimiter(config: RateLimiterOptions): {
|
|
|
63
68
|
/** Number of keys currently tracked. */
|
|
64
69
|
readonly size: number;
|
|
65
70
|
};
|
|
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
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Extracts the client IP from request headers.
|
|
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`.
|
|
90
|
-
*
|
|
91
|
-
* @param headers - Request headers.
|
|
92
|
-
* @param options - Proxy trust configuration.
|
|
93
|
-
* @returns The client IP address, or "unknown".
|
|
94
|
-
*/
|
|
95
|
-
export declare function extractClientIp(headers: Record<string, string | string[] | undefined>, options?: ClientIpOptions): string;
|
|
96
71
|
//# sourceMappingURL=rateLimit.core.d.ts.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Implements sliding window rate limiting to prevent abuse.
|
|
5
5
|
*/
|
|
6
|
+
import { createIpKeyGenerator, ipRateLimitKey } from "./rateLimit.clientKey.js";
|
|
6
7
|
/** Default window: 1 minute. */
|
|
7
8
|
const DEFAULT_WINDOW_MS = 60_000;
|
|
8
9
|
/** Default rate limit message. */
|
|
@@ -14,12 +15,18 @@ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
|
14
15
|
/**
|
|
15
16
|
* Default key generator using IP address.
|
|
16
17
|
*
|
|
18
|
+
* The port is stripped, IPv4-mapped IPv6 is keyed as IPv4, and IPv6 is
|
|
19
|
+
* bucketed by /64 (see {@link createIpKeyGenerator} for another prefix).
|
|
20
|
+
*
|
|
17
21
|
* @param request - The rate limit request.
|
|
18
22
|
* @returns The rate limit key.
|
|
23
|
+
* @throws {ConfigurationError} when `request.ip` is missing or not an IP
|
|
24
|
+
* address; requests used to share a single `"unknown"` bucket.
|
|
19
25
|
*/
|
|
20
26
|
export function defaultKeyGenerator(request) {
|
|
21
|
-
return request
|
|
27
|
+
return ipKeyGenerator(request);
|
|
22
28
|
}
|
|
29
|
+
const ipKeyGenerator = createIpKeyGenerator();
|
|
23
30
|
/**
|
|
24
31
|
* Default handler when rate limit is exceeded.
|
|
25
32
|
*
|
|
@@ -81,6 +88,8 @@ export function createRateLimiter(config) {
|
|
|
81
88
|
}
|
|
82
89
|
const store = new Map();
|
|
83
90
|
const keyGenerator = config.keyGenerator ?? defaultKeyGenerator;
|
|
91
|
+
/** Lets `reset`/`getCount` take a raw address under the default keys. */
|
|
92
|
+
const storeKey = (key) => config.keyGenerator || store.has(key) ? key : (ipRateLimitKey(key) ?? key);
|
|
84
93
|
// `config.message` was declared and documented but never read: the default
|
|
85
94
|
// handler always emitted the built-in string.
|
|
86
95
|
const message = config.message ?? DEFAULT_MESSAGE;
|
|
@@ -191,7 +200,7 @@ export function createRateLimiter(config) {
|
|
|
191
200
|
* Resets the rate limit for a specific key.
|
|
192
201
|
*/
|
|
193
202
|
function reset(key) {
|
|
194
|
-
store.delete(key);
|
|
203
|
+
store.delete(storeKey(key));
|
|
195
204
|
}
|
|
196
205
|
/**
|
|
197
206
|
* Clears all rate limit data.
|
|
@@ -203,7 +212,7 @@ export function createRateLimiter(config) {
|
|
|
203
212
|
* Gets the current count for a key.
|
|
204
213
|
*/
|
|
205
214
|
function getCount(key) {
|
|
206
|
-
const entry = store.get(key);
|
|
215
|
+
const entry = store.get(storeKey(key));
|
|
207
216
|
if (!entry)
|
|
208
217
|
return 0;
|
|
209
218
|
const windowStart = Date.now() - config.windowMs;
|
|
@@ -229,74 +238,4 @@ export function createRateLimiter(config) {
|
|
|
229
238
|
},
|
|
230
239
|
};
|
|
231
240
|
}
|
|
232
|
-
/**
|
|
233
|
-
* Extracts the client IP from request headers.
|
|
234
|
-
*
|
|
235
|
-
* **Forwarding headers are not trusted by default.** Any client can send
|
|
236
|
-
* `X-Forwarded-For`, so taking its leftmost entry — the historical behaviour —
|
|
237
|
-
* hands the caller control of their own rate-limit bucket, and rotating it
|
|
238
|
-
* defeats the limiter entirely. Pass `trustProxy` set to the number of proxies
|
|
239
|
-
* you actually run, together with the socket's `remoteAddress`.
|
|
240
|
-
*
|
|
241
|
-
* @param headers - Request headers.
|
|
242
|
-
* @param options - Proxy trust configuration.
|
|
243
|
-
* @returns The client IP address, or "unknown".
|
|
244
|
-
*/
|
|
245
|
-
export function extractClientIp(headers, options) {
|
|
246
|
-
const trustProxy = options?.trustProxy ?? 0;
|
|
247
|
-
const fallback = options?.remoteAddress ?? "unknown";
|
|
248
|
-
if (trustProxy <= 0) {
|
|
249
|
-
return fallback;
|
|
250
|
-
}
|
|
251
|
-
const raw = lookupHeader(headers, "x-forwarded-for");
|
|
252
|
-
if (raw !== undefined) {
|
|
253
|
-
const chain = raw
|
|
254
|
-
.split(",")
|
|
255
|
-
.map((entry) => entry.trim())
|
|
256
|
-
.filter((entry) => entry.length > 0);
|
|
257
|
-
// Walk in from the right: index 0 from the end is the address our own
|
|
258
|
-
// outermost proxy observed, and each additional trusted hop steps left.
|
|
259
|
-
const index = chain.length - trustProxy;
|
|
260
|
-
const candidate = chain[Math.max(0, index)];
|
|
261
|
-
if (candidate && isPlausibleIp(candidate)) {
|
|
262
|
-
return candidate;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
const realIp = lookupHeader(headers, "x-real-ip");
|
|
266
|
-
if (realIp !== undefined && isPlausibleIp(realIp.trim())) {
|
|
267
|
-
return realIp.trim();
|
|
268
|
-
}
|
|
269
|
-
return fallback;
|
|
270
|
-
}
|
|
271
|
-
/** Case-insensitive header lookup that flattens repeated fields. */
|
|
272
|
-
function lookupHeader(headers, name) {
|
|
273
|
-
for (const key of Object.keys(headers)) {
|
|
274
|
-
if (key.toLowerCase() !== name)
|
|
275
|
-
continue;
|
|
276
|
-
const value = headers[key];
|
|
277
|
-
if (typeof value === "string")
|
|
278
|
-
return value;
|
|
279
|
-
if (Array.isArray(value) && value.length > 0)
|
|
280
|
-
return value.join(",");
|
|
281
|
-
}
|
|
282
|
-
return undefined;
|
|
283
|
-
}
|
|
284
|
-
/**
|
|
285
|
-
* Rejects values that are not addresses at all.
|
|
286
|
-
*
|
|
287
|
-
* A forwarding header is text, and a hostname or arbitrary string in it would
|
|
288
|
-
* otherwise become a rate-limit key of the attacker's choosing.
|
|
289
|
-
*/
|
|
290
|
-
function isPlausibleIp(value) {
|
|
291
|
-
const host = value.startsWith("[")
|
|
292
|
-
? value.slice(1, value.indexOf("]") === -1 ? undefined : value.indexOf("]"))
|
|
293
|
-
: value.split(":").length > 2
|
|
294
|
-
? value
|
|
295
|
-
: (value.split(":")[0] ?? value);
|
|
296
|
-
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
297
|
-
return host.split(".").every((octet) => Number(octet) <= 255);
|
|
298
|
-
}
|
|
299
|
-
// Any hex-and-colon string is accepted as an IPv6 candidate.
|
|
300
|
-
return /^[0-9a-fA-F:]+$/.test(host) && host.includes(":");
|
|
301
|
-
}
|
|
302
241
|
//# sourceMappingURL=rateLimit.core.js.map
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Convenience namespace for rate limiting utilities.
|
|
5
5
|
*/
|
|
6
|
-
import { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter
|
|
6
|
+
import { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter } from "./rateLimit.core.js";
|
|
7
|
+
import { extractClientIp } from "./rateLimit.clientIp.js";
|
|
7
8
|
export declare const rateLimit: {
|
|
8
9
|
defaultKeyGenerator: typeof defaultKeyGenerator;
|
|
9
10
|
defaultHandler: typeof defaultHandler;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Convenience namespace for rate limiting utilities.
|
|
5
5
|
*/
|
|
6
|
-
import { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter,
|
|
6
|
+
import { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, } from "./rateLimit.core.js";
|
|
7
|
+
import { extractClientIp } from "./rateLimit.clientIp.js";
|
|
7
8
|
export const rateLimit = {
|
|
8
9
|
defaultKeyGenerator,
|
|
9
10
|
defaultHandler,
|