@zudojs/security 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) 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 +15 -1
  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 +5 -2
  14. package/dist/csrf/csrf.core.js +50 -6
  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.d.ts +2 -0
  19. package/dist/input/input.core.js +34 -1
  20. package/dist/input/input.decode.d.ts +25 -0
  21. package/dist/input/input.decode.js +72 -0
  22. package/dist/rateLimit/index.d.ts +9 -2
  23. package/dist/rateLimit/index.js +6 -1
  24. package/dist/rateLimit/rateLimit.clientIp.d.ts +40 -0
  25. package/dist/rateLimit/rateLimit.clientIp.js +71 -0
  26. package/dist/rateLimit/rateLimit.clientKey.d.ts +51 -0
  27. package/dist/rateLimit/rateLimit.clientKey.js +104 -0
  28. package/dist/rateLimit/rateLimit.core.d.ts +5 -30
  29. package/dist/rateLimit/rateLimit.core.js +12 -73
  30. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  31. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  32. package/dist/types/security.type.d.ts +1 -1
  33. package/dist/types/security.type.js +3 -0
  34. package/dist/url/index.d.ts +1 -0
  35. package/dist/url/index.js +1 -0
  36. package/dist/url/url.core.d.ts +6 -0
  37. package/dist/url/url.core.js +29 -30
  38. package/dist/url/url.ipv6.d.ts +36 -0
  39. package/dist/url/url.ipv6.js +88 -0
  40. package/package.json +3 -3
@@ -19,6 +19,7 @@
19
19
  * token minted for one user validates for every other user.
20
20
  */
21
21
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
22
+ import { ConfigurationError } from "@zudojs/errors";
22
23
  /** Default token expiration (1 hour). */
23
24
  const DEFAULT_EXPIRATION = 3600;
24
25
  /** Default cookie name for CSRF token. */
@@ -37,14 +38,41 @@ const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
37
38
  * the only check, which accepted a one-character secret in silence.
38
39
  */
39
40
  export const MIN_CSRF_SECRET_LENGTH = 32;
41
+ /**
42
+ * Rejects a `methods` list that cannot protect anything.
43
+ *
44
+ * `methods.some(…)` over an empty list is always `false`, and `verify()`
45
+ * answers `true` for a method that needs no protection — so `methods: []`
46
+ * turned CSRF off for every request without an error or a warning. A list
47
+ * built from configuration (`process.env.CSRF_METHODS?.split(",") ?? []`) is
48
+ * empty exactly when the configuration is missing. An empty list is a
49
+ * configuration error, not a blanket grant; omit `methods` for the defaults.
50
+ */
51
+ function assertUsableMethods(methods) {
52
+ if (methods === undefined)
53
+ return;
54
+ if (!Array.isArray(methods)) {
55
+ throw new ConfigurationError("CSRF methods must be an array of HTTP method names, " +
56
+ "or omitted for the defaults (POST, PUT, PATCH, DELETE).");
57
+ }
58
+ if (methods.length === 0) {
59
+ throw new ConfigurationError("CSRF methods cannot be empty: an empty list protects no request at " +
60
+ "all. Omit `methods` for the defaults (POST, PUT, PATCH, DELETE).");
61
+ }
62
+ for (const method of methods) {
63
+ if (typeof method !== "string" || method.trim().length === 0) {
64
+ throw new ConfigurationError("CSRF methods must be non-empty HTTP method names.");
65
+ }
66
+ }
67
+ }
40
68
  /** Rejects a secret too short to be worth signing with. */
41
69
  function assertUsableSecret(secret) {
42
- if (secret.length === 0) {
43
- throw new Error("CSRF secret cannot be empty: pass a random string of at least " +
70
+ if (typeof secret !== "string" || secret.length === 0) {
71
+ throw new ConfigurationError("CSRF secret cannot be empty: pass a random string of at least " +
44
72
  `${MIN_CSRF_SECRET_LENGTH} characters, e.g. randomBytes(32).toString("hex")`);
45
73
  }
46
74
  if (secret.length < MIN_CSRF_SECRET_LENGTH) {
47
- throw new Error(`CSRF secret is too short: got ${secret.length} characters, expected at least ` +
75
+ throw new ConfigurationError(`CSRF secret is too short: got ${secret.length} characters, expected at least ` +
48
76
  `${MIN_CSRF_SECRET_LENGTH}. The signature is HMAC-SHA256, so a shorter ` +
49
77
  'secret adds no strength. Generate one with randomBytes(32).toString("hex").');
50
78
  }
@@ -101,10 +129,18 @@ export function generateCsrfToken(secret, options) {
101
129
  * @param options - Maximum lifetime and session binding, or a bare expiration
102
130
  * in seconds for backwards compatibility.
103
131
  * @returns True if the token is valid, unexpired, and bound to this session.
132
+ * @throws {ConfigurationError} when the secret is empty or shorter than
133
+ * {@link MIN_CSRF_SECRET_LENGTH}, exactly as `generateCsrfToken` does.
104
134
  */
105
135
  export function validateCsrfToken(token, secret, options) {
136
+ // Same bar as `generateCsrfToken`: a verifier configured with
137
+ // `process.env.CSRF_SECRET ?? ""` otherwise accepted tokens anyone can sign
138
+ // with an empty HMAC key.
139
+ assertUsableSecret(secret);
106
140
  const opts = typeof options === "number" ? { expiration: options } : (options ?? {});
107
141
  const maxTtl = opts.expiration ?? DEFAULT_EXPIRATION;
142
+ if (typeof token !== "string")
143
+ return false;
108
144
  const parts = token.split(":");
109
145
  if (parts.length !== 3) {
110
146
  return false;
@@ -150,6 +186,7 @@ export function validateCsrfToken(token, secret, options) {
150
186
  * @returns True when the request carries a matching, valid token.
151
187
  */
152
188
  export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
189
+ assertUsableSecret(secret);
153
190
  if (!cookieToken || !requestToken) {
154
191
  return false;
155
192
  }
@@ -166,11 +203,16 @@ export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
166
203
  * @returns True if CSRF protection is required.
167
204
  */
168
205
  export function requiresCsrfProtection(method, config) {
206
+ assertUsableMethods(config?.methods);
169
207
  if (SAFE_METHODS.includes(method.toUpperCase())) {
170
208
  return false;
171
209
  }
210
+ // HTTP methods are case-sensitive on the wire but configured by hand;
211
+ // comparing a lower-case `methods: ["post"]` against the upper-cased
212
+ // request method used to protect nothing, silently.
213
+ const upper = method.toUpperCase();
172
214
  const methods = config?.methods ?? DEFAULT_METHODS;
173
- return methods.includes(method.toUpperCase());
215
+ return methods.some((m) => String(m).toUpperCase() === upper);
174
216
  }
175
217
  /**
176
218
  * Extracts the CSRF token from request headers.
@@ -257,11 +299,13 @@ export function generateCsrfCookie(token, config) {
257
299
  *
258
300
  * @param config - Secret, lifetime, cookie/header names, protected methods.
259
301
  * @returns Protection bound to that configuration.
260
- * @throws {Error} when the secret is missing or shorter than
261
- * {@link MIN_CSRF_SECRET_LENGTH}.
302
+ * @throws {ConfigurationError} when the secret is missing or shorter than
303
+ * {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
304
+ * not an array, or contains a non-method entry.
262
305
  */
263
306
  export function createCsrfProtection(config) {
264
307
  assertUsableSecret(config.secret);
308
+ assertUsableMethods(config.methods);
265
309
  const expiration = config.expiration ?? DEFAULT_EXPIRATION;
266
310
  const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
267
311
  const headerName = config.headerName ?? DEFAULT_HEADER_NAME;
@@ -4,6 +4,7 @@
4
4
  * Generates security-related HTTP response headers with secure defaults.
5
5
  */
6
6
  import { randomBytes } from "node:crypto";
7
+ import { ConfigurationError } from "@zudojs/errors";
7
8
  /** Security header names. */
8
9
  export const SECURITY_HEADER_NAMES = {
9
10
  CONTENT_SECURITY_POLICY: "Content-Security-Policy",
@@ -58,7 +59,7 @@ export function generateSecurityHeaders(config) {
58
59
  if (config) {
59
60
  for (const [key, value] of Object.entries(config)) {
60
61
  if (typeof value === "string" && /[\r\n\x00]/.test(value)) {
61
- throw new 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";
@@ -46,6 +46,8 @@ export declare function sanitizeString(input: string, config?: InputSanitization
46
46
  * @param obj - The object to sanitize.
47
47
  * @param config - Optional sanitization configuration.
48
48
  * @returns The sanitized object.
49
+ * @throws {ConfigurationError} when `config.maxDepth` is present but is not
50
+ * an integer of 1 or more.
49
51
  */
50
52
  export declare function sanitizeObject<T extends Record<string, unknown>>(obj: T, config?: InputSanitizationConfig): T;
51
53
  /**
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Sanitizes user input against common attack patterns.
5
5
  */
6
+ import { ConfigurationError } from "@zudojs/errors";
6
7
  import { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "../types/security.type.js";
8
+ import { containsObfuscatedScheme, decodeHtmlEntities } from "./input.decode.js";
7
9
  /**
8
10
  * Null byte and control character patterns.
9
11
  *
@@ -39,7 +41,15 @@ export function containsSqlInjection(input) {
39
41
  * @returns True if XSS patterns are detected.
40
42
  */
41
43
  export function containsXss(input) {
42
- return XSS_PATTERNS.some((pattern) => pattern.test(input));
44
+ if (XSS_PATTERNS.some((pattern) => pattern.test(input)))
45
+ return true;
46
+ // Entity-encoded payloads (`jav&#x61;script:`, `javascript&colon;`,
47
+ // `java&#x09;script:`) are decoded by the browser but not by a regex.
48
+ const decoded = decodeHtmlEntities(input);
49
+ if (decoded !== input && XSS_PATTERNS.some((pattern) => pattern.test(decoded))) {
50
+ return true;
51
+ }
52
+ return containsObfuscatedScheme(decoded);
43
53
  }
44
54
  /**
45
55
  * Checks if a string contains prototype pollution keys.
@@ -155,11 +165,34 @@ function isPlainObject(value) {
155
165
  * @param obj - The object to sanitize.
156
166
  * @param config - Optional sanitization configuration.
157
167
  * @returns The sanitized object.
168
+ * @throws {ConfigurationError} when `config.maxDepth` is present but is not
169
+ * an integer of 1 or more.
158
170
  */
159
171
  export function sanitizeObject(obj, config) {
160
172
  const maxDepth = config?.maxDepth ?? DEFAULT_MAX_DEPTH;
173
+ assertUsableMaxDepth(config?.maxDepth);
161
174
  return sanitizeValue(obj, config, new WeakSet(), 0, maxDepth);
162
175
  }
176
+ /**
177
+ * Rejects a `maxDepth` that cannot describe a depth the caller wants kept.
178
+ *
179
+ * The guard runs before the object is entered, so `maxDepth: 0` discarded the
180
+ * argument itself and returned `undefined` under a non-optional `T` — every
181
+ * field read off the result then threw at a call site TypeScript had told was
182
+ * safe. `Number(process.env.MAX_DEPTH)` with the variable unset is the same
183
+ * shape of mistake as the body limit's `NaN`.
184
+ *
185
+ * @throws {ConfigurationError} when `maxDepth` is present but is not an
186
+ * integer of 1 or more.
187
+ */
188
+ function assertUsableMaxDepth(maxDepth) {
189
+ if (maxDepth === undefined)
190
+ return;
191
+ if (!Number.isInteger(maxDepth) || maxDepth < 1) {
192
+ throw new ConfigurationError("Input sanitization maxDepth must be an integer of 1 or more, got: " +
193
+ String(maxDepth));
194
+ }
195
+ }
163
196
  /**
164
197
  * Validates that a string contains only safe characters.
165
198
  *
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @zudojs/security — HTML entity decoding for the XSS heuristic.
3
+ *
4
+ * Browsers decode character references in attribute values before they
5
+ * interpret a URL, and they drop tabs and newlines inside a scheme. A regex
6
+ * run over the raw text therefore missed `jav&#x61;script:`,
7
+ * `&#106;avascript:`, `javascript&colon;` and `java&#x09;script:`.
8
+ */
9
+ /**
10
+ * Decodes numeric (`&#106;`, `&#x6a;`, 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 `jav&#x61;script:`,
7
+ * `&#106;avascript:`, `javascript&colon;` and `java&#x09;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,71 @@
1
+ /**
2
+ * @zudojs/security — Client address extraction from forwarding headers.
3
+ */
4
+ import { parseClientIp } from "./rateLimit.clientKey.js";
5
+ /**
6
+ * Extracts the client IP from request headers.
7
+ *
8
+ * **Forwarding headers are not trusted by default.** Any client can send
9
+ * `X-Forwarded-For`, so taking its leftmost entry — the historical behaviour —
10
+ * hands the caller control of their own rate-limit bucket, and rotating it
11
+ * defeats the limiter entirely. Pass `trustProxy` set to the number of proxies
12
+ * you actually run, together with the socket's `remoteAddress`.
13
+ *
14
+ * The returned address has any port and IPv6 brackets removed: some proxies
15
+ * append `client-ip:port`, and returning that verbatim gave every new TCP
16
+ * source port its own rate-limit bucket. Values that are not IP addresses
17
+ * are ignored.
18
+ *
19
+ * @param headers - Request headers.
20
+ * @param options - Proxy trust configuration.
21
+ * @returns The client IP address, or "unknown". Note that the default
22
+ * rate-limit key generator refuses `"unknown"`; pass `remoteAddress`.
23
+ */
24
+ export function extractClientIp(headers, options) {
25
+ const trustProxy = options?.trustProxy ?? 0;
26
+ const fallback = options?.remoteAddress ?? "unknown";
27
+ if (trustProxy <= 0) {
28
+ return fallback;
29
+ }
30
+ const raw = lookupHeader(headers, "x-forwarded-for");
31
+ if (raw !== undefined) {
32
+ const chain = raw
33
+ .split(",")
34
+ .map((entry) => entry.trim())
35
+ .filter((entry) => entry.length > 0);
36
+ // Walk in from the right: index 0 from the end is the address our own
37
+ // outermost proxy observed, and each additional trusted hop steps left.
38
+ const index = chain.length - trustProxy;
39
+ // A chain shorter than the configured hop count did not pass through all
40
+ // of the proxies whose entries make it trustworthy — a request entering
41
+ // at an inner hop, or one a client shortened on purpose. Clamping the
42
+ // index to 0 landed on the entry the client wrote, handing them their own
43
+ // rate-limit bucket; there is nothing trustworthy to read here, so the
44
+ // header is skipped entirely.
45
+ if (index >= 0) {
46
+ const candidate = chain[index];
47
+ const ip = candidate === undefined ? undefined : parseClientIp(candidate);
48
+ if (ip !== undefined)
49
+ return ip;
50
+ }
51
+ }
52
+ const realIp = lookupHeader(headers, "x-real-ip");
53
+ const ip = realIp === undefined ? undefined : parseClientIp(realIp);
54
+ if (ip !== undefined)
55
+ return ip;
56
+ return fallback;
57
+ }
58
+ /** Case-insensitive header lookup that flattens repeated fields. */
59
+ function lookupHeader(headers, name) {
60
+ for (const key of Object.keys(headers)) {
61
+ if (key.toLowerCase() !== name)
62
+ continue;
63
+ const value = headers[key];
64
+ if (typeof value === "string")
65
+ return value;
66
+ if (Array.isArray(value) && value.length > 0)
67
+ return value.join(",");
68
+ }
69
+ return undefined;
70
+ }
71
+ //# sourceMappingURL=rateLimit.clientIp.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @zudojs/security — Rate-limit keys derived from client addresses.
3
+ *
4
+ * An address is only a useful bucket if one client cannot cheaply become
5
+ * many. Two things made that cheap: a `host:port` string (some proxies
6
+ * append the client's source port to `X-Forwarded-For`), which gave every
7
+ * new TCP connection its own bucket, and full /128 IPv6 keys, which let any
8
+ * host rotate through the 2^64 addresses of its own /64.
9
+ */
10
+ import type { RateLimitRequest } from "../types/security.type.js";
11
+ /** Default IPv6 prefix a single client is bucketed by. */
12
+ export declare const DEFAULT_IPV6_PREFIX_LENGTH = 64;
13
+ /** Options for {@link createIpKeyGenerator} and {@link ipRateLimitKey}. */
14
+ export interface IpKeyOptions {
15
+ /**
16
+ * IPv6 prefix length, in bits, that counts as one client (default: 64,
17
+ * the smallest allocation an end site normally receives). 128 keys every
18
+ * address separately.
19
+ */
20
+ readonly ipv6PrefixLength?: number;
21
+ }
22
+ /**
23
+ * Parses an address as it appears in a forwarding header or socket, with
24
+ * any port and IPv6 brackets or zone id removed.
25
+ *
26
+ * Accepts `1.2.3.4`, `1.2.3.4:5678`, `2001:db8::1`, `[2001:db8::1]` and
27
+ * `[2001:db8::1]:443`.
28
+ *
29
+ * @returns The bare address, or `undefined` if the value is not an IP.
30
+ */
31
+ export declare function parseClientIp(value: string): string | undefined;
32
+ /**
33
+ * The rate-limit key for a client address: IPv4 as-is (port stripped),
34
+ * IPv4-mapped IPv6 as its IPv4 address, and other IPv6 addresses truncated
35
+ * to their `ipv6PrefixLength` network, e.g. `2001:db8:0:1:0:0:0:0/64`.
36
+ *
37
+ * @returns The key, or `undefined` when `value` is not an IP address.
38
+ */
39
+ export declare function ipRateLimitKey(value: string, options?: IpKeyOptions): string | undefined;
40
+ /**
41
+ * Creates a key generator that buckets requests by `request.ip`.
42
+ *
43
+ * Throws a `ConfigurationError` when `request.ip` is missing or is not an IP
44
+ * address (including the `"unknown"` placeholder `extractClientIp` returns).
45
+ * Keying such requests under one shared `"unknown"` bucket let a single
46
+ * client starve every other client whose address was not available.
47
+ *
48
+ * @throws {RangeError} when `ipv6PrefixLength` is not an integer in 1–128.
49
+ */
50
+ export declare function createIpKeyGenerator(options?: IpKeyOptions): (request: RateLimitRequest) => string;
51
+ //# sourceMappingURL=rateLimit.clientKey.d.ts.map
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @zudojs/security — Rate-limit keys derived from client addresses.
3
+ *
4
+ * An address is only a useful bucket if one client cannot cheaply become
5
+ * many. Two things made that cheap: a `host:port` string (some proxies
6
+ * append the client's source port to `X-Forwarded-For`), which gave every
7
+ * new TCP connection its own bucket, and full /128 IPv6 keys, which let any
8
+ * host rotate through the 2^64 addresses of its own /64.
9
+ */
10
+ import { isIPv4, isIPv6 } from "node:net";
11
+ import { ConfigurationError } from "@zudojs/errors";
12
+ import { expandIpv6 } from "../url/url.ipv6.js";
13
+ /** Default IPv6 prefix a single client is bucketed by. */
14
+ export const DEFAULT_IPV6_PREFIX_LENGTH = 64;
15
+ /**
16
+ * Parses an address as it appears in a forwarding header or socket, with
17
+ * any port and IPv6 brackets or zone id removed.
18
+ *
19
+ * Accepts `1.2.3.4`, `1.2.3.4:5678`, `2001:db8::1`, `[2001:db8::1]` and
20
+ * `[2001:db8::1]:443`.
21
+ *
22
+ * @returns The bare address, or `undefined` if the value is not an IP.
23
+ */
24
+ export function parseClientIp(value) {
25
+ const raw = value.trim();
26
+ let host = raw;
27
+ if (raw.startsWith("[")) {
28
+ const close = raw.indexOf("]");
29
+ if (close === -1)
30
+ return undefined;
31
+ const rest = raw.slice(close + 1);
32
+ if (rest !== "" && !/^:\d{1,5}$/.test(rest))
33
+ return undefined;
34
+ host = raw.slice(1, close);
35
+ }
36
+ else if (raw.split(":").length === 2) {
37
+ const [address, port] = raw.split(":");
38
+ if (!/^\d{1,5}$/.test(port ?? ""))
39
+ return undefined;
40
+ host = address ?? "";
41
+ }
42
+ const zone = host.indexOf("%");
43
+ if (zone !== -1)
44
+ host = host.slice(0, zone);
45
+ if (isIPv4(host))
46
+ return host;
47
+ if (isIPv6(host))
48
+ return host.toLowerCase();
49
+ return undefined;
50
+ }
51
+ /**
52
+ * The rate-limit key for a client address: IPv4 as-is (port stripped),
53
+ * IPv4-mapped IPv6 as its IPv4 address, and other IPv6 addresses truncated
54
+ * to their `ipv6PrefixLength` network, e.g. `2001:db8:0:1:0:0:0:0/64`.
55
+ *
56
+ * @returns The key, or `undefined` when `value` is not an IP address.
57
+ */
58
+ export function ipRateLimitKey(value, options) {
59
+ const prefix = options?.ipv6PrefixLength ?? DEFAULT_IPV6_PREFIX_LENGTH;
60
+ const ip = parseClientIp(value);
61
+ if (ip === undefined)
62
+ return undefined;
63
+ if (isIPv4(ip))
64
+ return ip;
65
+ const groups = expandIpv6(ip);
66
+ if (!groups)
67
+ return undefined;
68
+ if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) {
69
+ const [a = 0, b = 0] = groups.slice(6);
70
+ return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
71
+ }
72
+ const masked = groups.map((group, index) => {
73
+ const bits = Math.min(16, Math.max(0, prefix - index * 16));
74
+ return bits === 0 ? 0 : group & ((0xffff << (16 - bits)) & 0xffff);
75
+ });
76
+ return `${masked.map((g) => g.toString(16)).join(":")}/${prefix}`;
77
+ }
78
+ /**
79
+ * Creates a key generator that buckets requests by `request.ip`.
80
+ *
81
+ * Throws a `ConfigurationError` when `request.ip` is missing or is not an IP
82
+ * address (including the `"unknown"` placeholder `extractClientIp` returns).
83
+ * Keying such requests under one shared `"unknown"` bucket let a single
84
+ * client starve every other client whose address was not available.
85
+ *
86
+ * @throws {RangeError} when `ipv6PrefixLength` is not an integer in 1–128.
87
+ */
88
+ export function createIpKeyGenerator(options) {
89
+ const prefix = options?.ipv6PrefixLength ?? DEFAULT_IPV6_PREFIX_LENGTH;
90
+ if (!Number.isInteger(prefix) || prefix < 1 || prefix > 128) {
91
+ throw new RangeError(`ipv6PrefixLength must be an integer in 1-128, got: ${prefix}`);
92
+ }
93
+ return (request) => {
94
+ const key = typeof request.ip === "string"
95
+ ? ipRateLimitKey(request.ip, { ipv6PrefixLength: prefix })
96
+ : undefined;
97
+ if (key === undefined) {
98
+ throw new ConfigurationError("Rate limiting by IP needs RateLimitRequest.ip to be a client address; " +
99
+ "pass the socket's remoteAddress (or extractClientIp with it), or supply a keyGenerator.");
100
+ }
101
+ return key;
102
+ };
103
+ }
104
+ //# sourceMappingURL=rateLimit.clientKey.js.map