@zudojs/security 1.0.0 → 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 +24 -4
- 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 +37 -84
- 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 +7 -3
|
@@ -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";
|
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
|