@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.
Files changed (38) hide show
  1. package/README.md +56 -5
  2. package/dist/body/body.core.d.ts +6 -0
  3. package/dist/body/body.core.js +24 -4
  4. package/dist/body/body.guard.d.ts +19 -0
  5. package/dist/body/body.guard.js +26 -0
  6. package/dist/cookie/cookie.core.d.ts +10 -6
  7. package/dist/cookie/cookie.core.js +22 -20
  8. package/dist/cookie/cookie.sensitive.d.ts +27 -0
  9. package/dist/cookie/cookie.sensitive.js +61 -0
  10. package/dist/cookie/index.d.ts +1 -0
  11. package/dist/cookie/index.js +1 -0
  12. package/dist/cors/cors.core.js +2 -1
  13. package/dist/csrf/csrf.core.d.ts +3 -1
  14. package/dist/csrf/csrf.core.js +19 -5
  15. package/dist/headers/headers.core.js +2 -1
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +3 -3
  18. package/dist/input/input.core.js +10 -1
  19. package/dist/input/input.decode.d.ts +25 -0
  20. package/dist/input/input.decode.js +72 -0
  21. package/dist/rateLimit/index.d.ts +9 -2
  22. package/dist/rateLimit/index.js +6 -1
  23. package/dist/rateLimit/rateLimit.clientIp.d.ts +40 -0
  24. package/dist/rateLimit/rateLimit.clientIp.js +63 -0
  25. package/dist/rateLimit/rateLimit.clientKey.d.ts +51 -0
  26. package/dist/rateLimit/rateLimit.clientKey.js +104 -0
  27. package/dist/rateLimit/rateLimit.core.d.ts +5 -30
  28. package/dist/rateLimit/rateLimit.core.js +37 -84
  29. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  30. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  31. package/dist/types/security.type.d.ts +1 -1
  32. package/dist/types/security.type.js +3 -0
  33. package/dist/url/index.d.ts +1 -0
  34. package/dist/url/index.js +1 -0
  35. package/dist/url/url.core.js +12 -27
  36. package/dist/url/url.ipv6.d.ts +36 -0
  37. package/dist/url/url.ipv6.js +88 -0
  38. 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 Error(`Security header "${key}" contains CRLF or null bytes (injection risk)`);
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";
@@ -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
- return XSS_PATTERNS.some((pattern) => pattern.test(input));
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 `&amp;#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 (`&#106;`, `&#x6a;`, 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, extractClientIp, } from "./rateLimit.core.js";
5
- export type { RateLimiterOptions, ClientIpOptions } from "./rateLimit.core.js";
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
@@ -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, extractClientIp, } from "./rateLimit.core.js";
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