@zudojs/security 1.3.3 → 1.3.4

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 (66) hide show
  1. package/README.md +4 -1
  2. package/dist/body/body.core.d.ts +0 -1
  3. package/dist/body/body.core.js +0 -1
  4. package/dist/body/body.guard.d.ts +0 -1
  5. package/dist/body/body.guard.js +0 -1
  6. package/dist/body/index.d.ts +0 -1
  7. package/dist/body/index.js +0 -1
  8. package/dist/cookie/cookie.attribute.d.ts +0 -1
  9. package/dist/cookie/cookie.attribute.js +0 -1
  10. package/dist/cookie/cookie.core.d.ts +0 -1
  11. package/dist/cookie/cookie.core.js +0 -1
  12. package/dist/cookie/cookie.sensitive.d.ts +0 -1
  13. package/dist/cookie/cookie.sensitive.js +0 -1
  14. package/dist/cookie/index.d.ts +0 -1
  15. package/dist/cookie/index.js +0 -1
  16. package/dist/cors/cors.core.d.ts +0 -1
  17. package/dist/cors/cors.core.js +0 -1
  18. package/dist/cors/cors.namespace.d.ts +0 -1
  19. package/dist/cors/cors.namespace.js +0 -1
  20. package/dist/cors/index.d.ts +0 -1
  21. package/dist/cors/index.js +0 -1
  22. package/dist/csrf/csrf.core.d.ts +0 -1
  23. package/dist/csrf/csrf.core.js +0 -1
  24. package/dist/csrf/index.d.ts +0 -1
  25. package/dist/csrf/index.js +0 -1
  26. package/dist/header/header.core.d.ts +0 -1
  27. package/dist/header/header.core.js +0 -1
  28. package/dist/header/index.d.ts +0 -1
  29. package/dist/header/index.js +0 -1
  30. package/dist/headers/headers.core.d.ts +0 -1
  31. package/dist/headers/headers.core.js +0 -1
  32. package/dist/headers/index.d.ts +0 -1
  33. package/dist/headers/index.js +0 -1
  34. package/dist/index.d.ts +0 -1
  35. package/dist/index.js +0 -1
  36. package/dist/input/index.d.ts +0 -1
  37. package/dist/input/index.js +0 -1
  38. package/dist/input/input.core.d.ts +0 -1
  39. package/dist/input/input.core.js +0 -1
  40. package/dist/input/input.decode.d.ts +0 -1
  41. package/dist/input/input.decode.js +0 -1
  42. package/dist/input/input.unsafeKey.d.ts +0 -1
  43. package/dist/input/input.unsafeKey.js +0 -1
  44. package/dist/rateLimit/index.d.ts +0 -1
  45. package/dist/rateLimit/index.js +0 -1
  46. package/dist/rateLimit/rateLimit.clientIp.d.ts +0 -1
  47. package/dist/rateLimit/rateLimit.clientIp.js +0 -1
  48. package/dist/rateLimit/rateLimit.clientKey.d.ts +0 -1
  49. package/dist/rateLimit/rateLimit.clientKey.js +0 -1
  50. package/dist/rateLimit/rateLimit.core.d.ts +2 -1
  51. package/dist/rateLimit/rateLimit.core.js +11 -10
  52. package/dist/rateLimit/rateLimit.namespace.d.ts +0 -1
  53. package/dist/rateLimit/rateLimit.namespace.js +0 -1
  54. package/dist/rateLimit/rateLimit.window.d.ts +37 -0
  55. package/dist/rateLimit/rateLimit.window.js +60 -0
  56. package/dist/types/index.d.ts +0 -1
  57. package/dist/types/index.js +0 -1
  58. package/dist/types/security.type.d.ts +0 -1
  59. package/dist/types/security.type.js +0 -1
  60. package/dist/url/index.d.ts +0 -1
  61. package/dist/url/index.js +0 -1
  62. package/dist/url/url.core.d.ts +0 -1
  63. package/dist/url/url.core.js +0 -1
  64. package/dist/url/url.ipv6.d.ts +0 -1
  65. package/dist/url/url.ipv6.js +0 -1
  66. package/package.json +3 -3
package/README.md CHANGED
@@ -61,7 +61,10 @@ function handle(request, response) {
61
61
  A genuine sliding window: each check prunes entries older than `windowMs` and
62
62
  decides against what remains, so a client cannot spend a full allowance either
63
63
  side of a fixed boundary. Only allowed requests consume an allowance slot, so a
64
- client being limited cannot grow its own bucket.
64
+ client being limited cannot grow its own bucket. Expiry is a binary search over
65
+ an ordered hit log with amortised compaction, so a check costs O(log `max`)
66
+ rather than O(`max`): a limiter with `max: 1_000_000` is as cheap per request
67
+ as one with `max: 300`.
65
68
 
66
69
  ```typescript
67
70
  const limiter = createRateLimiter({
@@ -102,4 +102,3 @@ export declare function createBodySizeChecker(maxSize: number, contentType?: str
102
102
  allowed: boolean;
103
103
  error?: string;
104
104
  };
105
- //# sourceMappingURL=body.core.d.ts.map
@@ -268,4 +268,3 @@ export function createBodySizeChecker(maxSize, contentType) {
268
268
  };
269
269
  };
270
270
  }
271
- //# sourceMappingURL=body.core.js.map
@@ -16,4 +16,3 @@ export declare function isUsableBodyLimit(value: unknown): value is number;
16
16
  * negative or not a number.
17
17
  */
18
18
  export declare function assertBodyLimit(value: unknown, name?: string): asserts value is number;
19
- //# sourceMappingURL=body.guard.d.ts.map
@@ -23,4 +23,3 @@ export function assertBodyLimit(value, name = "maxSize") {
23
23
  throw new ConfigurationError(`Body limit ${name} must be a finite number of bytes above 0, got: ${String(value)}`);
24
24
  }
25
25
  }
26
- //# sourceMappingURL=body.guard.js.map
@@ -2,4 +2,3 @@
2
2
  * @zudojs/security — Body Validation Barrel
3
3
  */
4
4
  export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body.core.js";
5
- //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,3 @@
2
2
  * @zudojs/security — Body Validation Barrel
3
3
  */
4
4
  export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body.core.js";
5
- //# sourceMappingURL=index.js.map
@@ -25,4 +25,3 @@ export declare function assertCookieDomain(domain: string): void;
25
25
  * `;` or `,`. Space is allowed, as RFC 6265 allows it.
26
26
  */
27
27
  export declare function assertCookiePath(path: string): void;
28
- //# sourceMappingURL=cookie.attribute.d.ts.map
@@ -60,4 +60,3 @@ export function assertCookiePath(path) {
60
60
  `and "," are not, got ${JSON.stringify(path)}`);
61
61
  }
62
62
  }
63
- //# sourceMappingURL=cookie.attribute.js.map
@@ -71,4 +71,3 @@ export declare function validateCookieValue(value: string): string | undefined;
71
71
  * @returns The cleaned cookie header.
72
72
  */
73
73
  export declare function stripSensitiveCookies(cookieHeader: string, sensitiveNames?: readonly string[]): string;
74
- //# sourceMappingURL=cookie.core.d.ts.map
@@ -245,4 +245,3 @@ export function stripSensitiveCookies(cookieHeader, sensitiveNames = DEFAULT_SEN
245
245
  })
246
246
  .join("; ");
247
247
  }
248
- //# sourceMappingURL=cookie.core.js.map
@@ -24,4 +24,3 @@ export declare function cookieNameWords(name: string): string[];
24
24
  * `__Host-session`. Word boundaries keep `theme` or `sidebar` from matching.
25
25
  */
26
26
  export declare function isSensitiveCookieName(name: string, sensitiveNames?: readonly string[]): boolean;
27
- //# sourceMappingURL=cookie.sensitive.d.ts.map
@@ -58,4 +58,3 @@ export function isSensitiveCookieName(name, sensitiveNames = DEFAULT_SENSITIVE_C
58
58
  return false;
59
59
  });
60
60
  }
61
- //# sourceMappingURL=cookie.sensitive.js.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie.core.js";
5
5
  export { DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie.sensitive.js";
6
- //# sourceMappingURL=index.d.ts.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie.core.js";
5
5
  export { DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie.sensitive.js";
6
- //# sourceMappingURL=index.js.map
@@ -62,4 +62,3 @@ export declare function isMethodAllowed(method: string, config: CorsConfig): boo
62
62
  * @returns An array of disallowed headers, or empty array if all allowed.
63
63
  */
64
64
  export declare function getDisallowedHeaders(headers: string[], config: CorsConfig): string[];
65
- //# sourceMappingURL=cors.core.d.ts.map
@@ -186,4 +186,3 @@ export function getDisallowedHeaders(headers, config) {
186
186
  const allowedHeaders = new Set((config.allowedHeaders ?? DEFAULT_CORS_CONFIG.allowedHeaders).map((h) => h.toLowerCase()));
187
187
  return headers.filter((h) => !allowedHeaders.has(h.trim().toLowerCase()));
188
188
  }
189
- //# sourceMappingURL=cors.core.js.map
@@ -12,4 +12,3 @@ export declare const cors: {
12
12
  isMethodAllowed: typeof isMethodAllowed;
13
13
  getDisallowedHeaders: typeof getDisallowedHeaders;
14
14
  };
15
- //# sourceMappingURL=cors.namespace.d.ts.map
@@ -11,4 +11,3 @@ export const cors = {
11
11
  isMethodAllowed,
12
12
  getDisallowedHeaders,
13
13
  };
14
- //# sourceMappingURL=cors.namespace.js.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export type { CorsHeaders } from "./cors.core.js";
5
5
  export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors.core.js";
6
- //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,3 @@
2
2
  * @zudojs/security — CORS Barrel
3
3
  */
4
4
  export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors.core.js";
5
- //# sourceMappingURL=index.js.map
@@ -230,4 +230,3 @@ export interface CsrfProtection {
230
230
  * @throws {ValidationError} when `path` breaks the cookie `Path` rules.
231
231
  */
232
232
  export declare function createCsrfProtection(config: CsrfProtectionOptions): CsrfProtection;
233
- //# sourceMappingURL=csrf.core.d.ts.map
@@ -405,4 +405,3 @@ export function createCsrfProtection(config) {
405
405
  },
406
406
  };
407
407
  }
408
- //# sourceMappingURL=csrf.core.js.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf.core.js";
5
5
  export type { CsrfTokenOptions, CsrfCookieOptions, CsrfProtection, CsrfProtectionOptions, IssuedCsrfToken, CsrfVerifiableRequest, } from "./csrf.core.js";
6
- //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,3 @@
2
2
  * @zudojs/security — CSRF Protection Barrel
3
3
  */
4
4
  export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf.core.js";
5
- //# sourceMappingURL=index.js.map
@@ -44,4 +44,3 @@ export declare function sanitizeHeaderValue(value: string): string | undefined;
44
44
  * @returns True if the header is hop-by-hop.
45
45
  */
46
46
  export declare function isHopByHopHeader(name: string): boolean;
47
- //# sourceMappingURL=header.core.d.ts.map
@@ -168,4 +168,3 @@ export function isHopByHopHeader(name) {
168
168
  ]);
169
169
  return hopByHop.has(name.toLowerCase());
170
170
  }
171
- //# sourceMappingURL=header.core.js.map
@@ -2,4 +2,3 @@
2
2
  * @zudojs/security — Header Security Barrel
3
3
  */
4
4
  export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header.core.js";
5
- //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,3 @@
2
2
  * @zudojs/security — Header Security Barrel
3
3
  */
4
4
  export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header.core.js";
5
- //# sourceMappingURL=index.js.map
@@ -45,4 +45,3 @@ export declare function generateCspNonce(nonceLength?: number): string;
45
45
  * @returns An error message if invalid, or undefined.
46
46
  */
47
47
  export declare function validateCspDirective(directive: string): string | undefined;
48
- //# sourceMappingURL=headers.core.d.ts.map
@@ -154,4 +154,3 @@ export function validateCspDirective(directive) {
154
154
  // silent while `unsafe-eval` and `unsafe-hashes` were still in place.
155
155
  return warnings.length > 0 ? warnings.join("; ") : undefined;
156
156
  }
157
- //# sourceMappingURL=headers.core.js.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { SECURITY_HEADER_NAMES } from "./headers.core.js";
5
5
  export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers.core.js";
6
- //# sourceMappingURL=index.d.ts.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { SECURITY_HEADER_NAMES } from "./headers.core.js";
5
5
  export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers.core.js";
6
- //# sourceMappingURL=index.js.map
package/dist/index.d.ts CHANGED
@@ -46,4 +46,3 @@ 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";
48
48
  export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, findUnsafeKey, } from "./input/index.js";
49
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -49,4 +49,3 @@ export { SECURITY_HEADER_NAMES } from "./headers/index.js";
49
49
  export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
50
50
  /* ─── Input Sanitization ─────────────────────────────────────────────────── */
51
51
  export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, findUnsafeKey, } from "./input/index.js";
52
- //# sourceMappingURL=index.js.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
5
5
  export { findUnsafeKey } from "./input.unsafeKey.js";
6
- //# sourceMappingURL=index.d.ts.map
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
5
5
  export { findUnsafeKey } from "./input.unsafeKey.js";
6
- //# sourceMappingURL=index.js.map
@@ -99,4 +99,3 @@ export declare function escapeHtml(input: string): string;
99
99
  * @returns The string with HTML tags removed.
100
100
  */
101
101
  export declare function stripHtml(input: string): string;
102
- //# sourceMappingURL=input.core.d.ts.map
@@ -300,4 +300,3 @@ export function stripHtml(input) {
300
300
  }
301
301
  return result;
302
302
  }
303
- //# sourceMappingURL=input.core.js.map
@@ -22,4 +22,3 @@ export declare function decodeHtmlEntities(input: string): string;
22
22
  * @param decoded - Text that has already been entity-decoded.
23
23
  */
24
24
  export declare function containsObfuscatedScheme(decoded: string): boolean;
25
- //# sourceMappingURL=input.decode.d.ts.map
@@ -69,4 +69,3 @@ export function containsObfuscatedScheme(decoded) {
69
69
  const compact = decoded.replace(/[\u0000- \u007f- ]+/g, "");
70
70
  return /(?:javascript|vbscript|livescript):|data:text\/html/i.test(compact);
71
71
  }
72
- //# sourceMappingURL=input.decode.js.map
@@ -16,4 +16,3 @@
16
16
  * and cycle-safe, so it is safe on any decoded or in-process value.
17
17
  */
18
18
  export declare function findUnsafeKey(value: unknown): string | undefined;
19
- //# sourceMappingURL=input.unsafeKey.d.ts.map
@@ -58,4 +58,3 @@ export function findUnsafeKey(value) {
58
58
  }
59
59
  return undefined;
60
60
  }
61
- //# sourceMappingURL=input.unsafeKey.js.map
@@ -10,4 +10,3 @@ export { extractClientIp } from "./rateLimit.clientIp.js";
10
10
  export type { ClientIpOptions } from "./rateLimit.clientIp.js";
11
11
  export { createIpKeyGenerator, ipRateLimitKey, parseClientIp, DEFAULT_IPV6_PREFIX_LENGTH, } from "./rateLimit.clientKey.js";
12
12
  export type { IpKeyOptions } from "./rateLimit.clientKey.js";
13
- //# sourceMappingURL=index.d.ts.map
@@ -7,4 +7,3 @@
7
7
  export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, } from "./rateLimit.core.js";
8
8
  export { extractClientIp } from "./rateLimit.clientIp.js";
9
9
  export { createIpKeyGenerator, ipRateLimitKey, parseClientIp, DEFAULT_IPV6_PREFIX_LENGTH, } from "./rateLimit.clientKey.js";
10
- //# sourceMappingURL=index.js.map
@@ -37,4 +37,3 @@ export interface ClientIpOptions {
37
37
  * rate-limit key generator refuses `"unknown"`; pass `remoteAddress`.
38
38
  */
39
39
  export declare function extractClientIp(headers: Record<string, string | string[] | undefined>, options?: ClientIpOptions): string;
40
- //# sourceMappingURL=rateLimit.clientIp.d.ts.map
@@ -68,4 +68,3 @@ function lookupHeader(headers, name) {
68
68
  }
69
69
  return undefined;
70
70
  }
71
- //# sourceMappingURL=rateLimit.clientIp.js.map
@@ -48,4 +48,3 @@ export declare function ipRateLimitKey(value: string, options?: IpKeyOptions): s
48
48
  * @throws {RangeError} when `ipv6PrefixLength` is not an integer in 1–128.
49
49
  */
50
50
  export declare function createIpKeyGenerator(options?: IpKeyOptions): (request: RateLimitRequest) => string;
51
- //# sourceMappingURL=rateLimit.clientKey.d.ts.map
@@ -101,4 +101,3 @@ export function createIpKeyGenerator(options) {
101
101
  return key;
102
102
  };
103
103
  }
104
- //# sourceMappingURL=rateLimit.clientKey.js.map
@@ -54,6 +54,8 @@ export interface RateLimiterOptions extends RateLimitConfig {
54
54
  * The window genuinely slides: each check prunes timestamps older than
55
55
  * `windowMs` and decides against what remains, so a client cannot spend a full
56
56
  * allowance either side of a fixed boundary and get `2 × max` back to back.
57
+ * Pruning is a binary search over an ordered log (see `rateLimit.window.ts`),
58
+ * so the cost of a check is O(log max), not O(max).
57
59
  *
58
60
  * @param config - Rate limit configuration.
59
61
  * @returns A function that checks rate limits.
@@ -68,4 +70,3 @@ export declare function createRateLimiter(config: RateLimiterOptions): {
68
70
  /** Number of keys currently tracked. */
69
71
  readonly size: number;
70
72
  };
71
- //# sourceMappingURL=rateLimit.core.d.ts.map
@@ -4,6 +4,7 @@
4
4
  * Implements sliding window rate limiting to prevent abuse.
5
5
  */
6
6
  import { createIpKeyGenerator, ipRateLimitKey } from "./rateLimit.clientKey.js";
7
+ import { createSlidingWindow, pruneWindow, recordHit, windowCount, windowOldest, } from "./rateLimit.window.js";
7
8
  /** Default window: 1 minute. */
8
9
  const DEFAULT_WINDOW_MS = 60_000;
9
10
  /** Default rate limit message. */
@@ -75,6 +76,8 @@ export function retryAfterSeconds(result, now = Date.now()) {
75
76
  * The window genuinely slides: each check prunes timestamps older than
76
77
  * `windowMs` and decides against what remains, so a client cannot spend a full
77
78
  * allowance either side of a fixed boundary and get `2 × max` back to back.
79
+ * Pruning is a binary search over an ordered log (see `rateLimit.window.ts`),
80
+ * so the cost of a check is O(log max), not O(max).
78
81
  *
79
82
  * @param config - Rate limit configuration.
80
83
  * @returns A function that checks rate limits.
@@ -155,7 +158,7 @@ export function createRateLimiter(config) {
155
158
  const windowStart = now - config.windowMs;
156
159
  let entry = store.get(key);
157
160
  if (!entry) {
158
- entry = { timestamps: [], lastSeen: now };
161
+ entry = { window: createSlidingWindow(), lastSeen: now };
159
162
  store.set(key, entry);
160
163
  evictIfNeeded();
161
164
  }
@@ -165,19 +168,18 @@ export function createRateLimiter(config) {
165
168
  store.set(key, entry);
166
169
  }
167
170
  // Prune everything that has slid out of the window.
168
- const timestamps = entry.timestamps.filter((t) => t > windowStart);
171
+ pruneWindow(entry.window, windowStart);
169
172
  entry.lastSeen = now;
170
- const allowed = timestamps.length < config.max;
173
+ const allowed = windowCount(entry.window) < config.max;
171
174
  // Only an allowed request consumes an allowance slot. Recording denied
172
175
  // requests too would let a client already over the limit keep growing its
173
176
  // own bucket, so the cost of an attack would scale with the attack.
174
177
  if (allowed) {
175
- timestamps.push(now);
178
+ recordHit(entry.window, now);
176
179
  }
177
- entry.timestamps = timestamps;
178
- const remaining = Math.max(0, config.max - timestamps.length);
180
+ const remaining = Math.max(0, config.max - windowCount(entry.window));
179
181
  // The window frees up when its oldest surviving request ages out.
180
- const oldest = timestamps[0];
182
+ const oldest = windowOldest(entry.window);
181
183
  const resetAt = new Date((oldest ?? now) + config.windowMs);
182
184
  return {
183
185
  allowed,
@@ -215,8 +217,8 @@ export function createRateLimiter(config) {
215
217
  const entry = store.get(storeKey(key));
216
218
  if (!entry)
217
219
  return 0;
218
- const windowStart = Date.now() - config.windowMs;
219
- return entry.timestamps.filter((t) => t > windowStart).length;
220
+ pruneWindow(entry.window, Date.now() - config.windowMs);
221
+ return windowCount(entry.window);
220
222
  }
221
223
  /**
222
224
  * Destroys the rate limiter and cleans up resources.
@@ -238,4 +240,3 @@ export function createRateLimiter(config) {
238
240
  },
239
241
  };
240
242
  }
241
- //# sourceMappingURL=rateLimit.core.js.map
@@ -12,4 +12,3 @@ export declare const rateLimit: {
12
12
  createRateLimiter: typeof createRateLimiter;
13
13
  extractClientIp: typeof extractClientIp;
14
14
  };
15
- //# sourceMappingURL=rateLimit.namespace.d.ts.map
@@ -12,4 +12,3 @@ export const rateLimit = {
12
12
  createRateLimiter,
13
13
  extractClientIp,
14
14
  };
15
- //# sourceMappingURL=rateLimit.namespace.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @zudojs/security — Sliding-window bookkeeping for the rate limiter.
3
+ *
4
+ * A key's allowed hits are kept in chronological order behind a head
5
+ * offset. Expiring old hits is a binary search that advances the head, and
6
+ * the array is compacted only once the dead prefix outgrows the live
7
+ * suffix, so every operation is O(log n) amortised. The previous
8
+ * `timestamps.filter(...)` copied the whole array on every check, which
9
+ * made the cost of one request proportional to `max`: a limiter with
10
+ * `max: 1e6` spent more than twenty percent of a core on a single busy
11
+ * client where `max: 300` spent three.
12
+ */
13
+ /** Allowed hits inside a key's window, oldest first from `head`. */
14
+ export interface SlidingWindow {
15
+ /** Monotonic hit timestamps; entries before `head` have expired. */
16
+ timestamps: number[];
17
+ /** Index of the oldest live hit. */
18
+ head: number;
19
+ }
20
+ /** Create an empty window. */
21
+ export declare function createSlidingWindow(): SlidingWindow;
22
+ /**
23
+ * Drop every hit at or before `windowStart`.
24
+ *
25
+ * Timestamps are appended in arrival order, so the live region is a suffix
26
+ * and its start can be found by binary search. Compaction copies the live
27
+ * suffix only when the dead prefix is at least as long, which bounds both
28
+ * the copy cost (amortised O(1) per hit) and the retained memory (at most
29
+ * twice the live hits).
30
+ */
31
+ export declare function pruneWindow(window: SlidingWindow, windowStart: number): void;
32
+ /** Number of live hits. */
33
+ export declare function windowCount(window: SlidingWindow): number;
34
+ /** Timestamp of the oldest live hit, if any. */
35
+ export declare function windowOldest(window: SlidingWindow): number | undefined;
36
+ /** Record an allowed hit at `now`. */
37
+ export declare function recordHit(window: SlidingWindow, now: number): void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @zudojs/security — Sliding-window bookkeeping for the rate limiter.
3
+ *
4
+ * A key's allowed hits are kept in chronological order behind a head
5
+ * offset. Expiring old hits is a binary search that advances the head, and
6
+ * the array is compacted only once the dead prefix outgrows the live
7
+ * suffix, so every operation is O(log n) amortised. The previous
8
+ * `timestamps.filter(...)` copied the whole array on every check, which
9
+ * made the cost of one request proportional to `max`: a limiter with
10
+ * `max: 1e6` spent more than twenty percent of a core on a single busy
11
+ * client where `max: 300` spent three.
12
+ */
13
+ /** Create an empty window. */
14
+ export function createSlidingWindow() {
15
+ return { timestamps: [], head: 0 };
16
+ }
17
+ /**
18
+ * Drop every hit at or before `windowStart`.
19
+ *
20
+ * Timestamps are appended in arrival order, so the live region is a suffix
21
+ * and its start can be found by binary search. Compaction copies the live
22
+ * suffix only when the dead prefix is at least as long, which bounds both
23
+ * the copy cost (amortised O(1) per hit) and the retained memory (at most
24
+ * twice the live hits).
25
+ */
26
+ export function pruneWindow(window, windowStart) {
27
+ const { timestamps } = window;
28
+ let low = window.head;
29
+ let high = timestamps.length;
30
+ while (low < high) {
31
+ const middle = (low + high) >>> 1;
32
+ if (timestamps[middle] > windowStart) {
33
+ high = middle;
34
+ }
35
+ else {
36
+ low = middle + 1;
37
+ }
38
+ }
39
+ window.head = low;
40
+ if (low === timestamps.length) {
41
+ window.timestamps = [];
42
+ window.head = 0;
43
+ }
44
+ else if (low > 0 && low * 2 >= timestamps.length) {
45
+ window.timestamps = timestamps.slice(low);
46
+ window.head = 0;
47
+ }
48
+ }
49
+ /** Number of live hits. */
50
+ export function windowCount(window) {
51
+ return window.timestamps.length - window.head;
52
+ }
53
+ /** Timestamp of the oldest live hit, if any. */
54
+ export function windowOldest(window) {
55
+ return window.timestamps[window.head];
56
+ }
57
+ /** Record an allowed hit at `now`. */
58
+ export function recordHit(window, now) {
59
+ window.timestamps.push(now);
60
+ }
@@ -5,4 +5,3 @@
5
5
  */
6
6
  export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig, InputSanitizationConfig, } from "./security.type.js";
7
7
  export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "./security.type.js";
8
- //# sourceMappingURL=index.d.ts.map
@@ -4,4 +4,3 @@
4
4
  * Re-exports all security configuration and result types.
5
5
  */
6
6
  export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "./security.type.js";
7
- //# sourceMappingURL=index.js.map
@@ -223,4 +223,3 @@ export declare const SQL_INJECTION_PATTERNS: readonly [RegExp, RegExp, RegExp, R
223
223
  * a payload it matched a moment earlier.
224
224
  */
225
225
  export declare const XSS_PATTERNS: readonly [RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp, RegExp];
226
- //# sourceMappingURL=security.type.d.ts.map
@@ -46,4 +46,3 @@ export const XSS_PATTERNS = [
46
46
  /<object\b[^>]*>/i,
47
47
  /<embed\b[^>]*>/i,
48
48
  ];
49
- //# sourceMappingURL=security.type.js.map
@@ -4,4 +4,3 @@
4
4
  export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url.core.js";
5
5
  export type { RequestTargetConfig } from "./url.core.js";
6
6
  export { expandIpv6, embeddedIpv4, isNonPublicIpv6Range, } from "./url.ipv6.js";
7
- //# sourceMappingURL=index.d.ts.map
package/dist/url/index.js CHANGED
@@ -3,4 +3,3 @@
3
3
  */
4
4
  export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url.core.js";
5
5
  export { expandIpv6, embeddedIpv4, isNonPublicIpv6Range, } from "./url.ipv6.js";
6
- //# sourceMappingURL=index.js.map
@@ -97,4 +97,3 @@ export declare function isPrivateHostname(hostname: string): boolean;
97
97
  * @returns True if the URL appears safe.
98
98
  */
99
99
  export declare function isSafeUrl(url: string, allowedProtocols?: readonly string[]): boolean;
100
- //# sourceMappingURL=url.core.d.ts.map
@@ -410,4 +410,3 @@ export function isSafeUrl(url, allowedProtocols = DEFAULT_ALLOWED_PROTOCOLS) {
410
410
  }
411
411
  return !isPrivateHostname(parsed.hostname);
412
412
  }
413
- //# sourceMappingURL=url.core.js.map
@@ -33,4 +33,3 @@ export declare function embeddedIpv4(groups: readonly number[]): number[] | unde
33
33
  * `64:ff9b:1::/48`, whose embedded address depends on local configuration.
34
34
  */
35
35
  export declare function isNonPublicIpv6Range(groups: readonly number[]): boolean;
36
- //# sourceMappingURL=url.ipv6.d.ts.map
@@ -85,4 +85,3 @@ export function isNonPublicIpv6Range(groups) {
85
85
  return true;
86
86
  return g0 === 0x64 && groups[1] === 0xff9b && groups[2] === 1;
87
87
  }
88
- //# sourceMappingURL=url.ipv6.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/security",
3
- "version": "1.3.3",
3
+ "version": "1.3.4",
4
4
  "description": "Security primitives for input validation, header security, CORS, CSRF protection, rate limiting, and security headers.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -24,8 +24,8 @@
24
24
  "!dist/.tsbuildinfo"
25
25
  ],
26
26
  "dependencies": {
27
- "@zudojs/errors": "1.3.2",
28
- "@zudojs/constants": "1.1.4"
27
+ "@zudojs/errors": "1.4.0",
28
+ "@zudojs/constants": "1.2.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "typescript": "7.0.2",