@arcis/node 1.4.0 → 1.4.3

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 (65) hide show
  1. package/README.md +1 -1
  2. package/dist/core/constants.d.ts +2 -2
  3. package/dist/core/constants.d.ts.map +1 -1
  4. package/dist/core/index.js +11 -3
  5. package/dist/core/index.js.map +1 -1
  6. package/dist/core/index.mjs +11 -3
  7. package/dist/core/index.mjs.map +1 -1
  8. package/dist/core/types.d.ts +6 -0
  9. package/dist/core/types.d.ts.map +1 -1
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +527 -63
  13. package/dist/index.js.map +1 -1
  14. package/dist/index.mjs +525 -65
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/logging/index.js.map +1 -1
  17. package/dist/logging/index.mjs.map +1 -1
  18. package/dist/middleware/bot-detection.d.ts.map +1 -1
  19. package/dist/middleware/cookies.d.ts.map +1 -1
  20. package/dist/middleware/csrf.d.ts +10 -0
  21. package/dist/middleware/csrf.d.ts.map +1 -1
  22. package/dist/middleware/hpp.d.ts.map +1 -1
  23. package/dist/middleware/index.d.ts +2 -0
  24. package/dist/middleware/index.d.ts.map +1 -1
  25. package/dist/middleware/index.js +671 -39
  26. package/dist/middleware/index.js.map +1 -1
  27. package/dist/middleware/index.mjs +671 -41
  28. package/dist/middleware/index.mjs.map +1 -1
  29. package/dist/middleware/main.d.ts.map +1 -1
  30. package/dist/middleware/rate-limit.d.ts.map +1 -1
  31. package/dist/middleware/signup-protection.d.ts +65 -0
  32. package/dist/middleware/signup-protection.d.ts.map +1 -0
  33. package/dist/middleware/telemetry.d.ts +36 -0
  34. package/dist/middleware/telemetry.d.ts.map +1 -0
  35. package/dist/sanitizers/encode.d.ts.map +1 -1
  36. package/dist/sanitizers/index.d.ts +1 -0
  37. package/dist/sanitizers/index.d.ts.map +1 -1
  38. package/dist/sanitizers/index.js +113 -37
  39. package/dist/sanitizers/index.js.map +1 -1
  40. package/dist/sanitizers/index.mjs +111 -38
  41. package/dist/sanitizers/index.mjs.map +1 -1
  42. package/dist/sanitizers/ldap.d.ts +42 -0
  43. package/dist/sanitizers/ldap.d.ts.map +1 -0
  44. package/dist/sanitizers/path.d.ts.map +1 -1
  45. package/dist/sanitizers/pii.d.ts.map +1 -1
  46. package/dist/sanitizers/sanitize.d.ts.map +1 -1
  47. package/dist/sanitizers/ssti.d.ts.map +1 -1
  48. package/dist/sanitizers/xxe.d.ts.map +1 -1
  49. package/dist/stores/index.js +21 -1
  50. package/dist/stores/index.js.map +1 -1
  51. package/dist/stores/index.mjs +21 -1
  52. package/dist/stores/index.mjs.map +1 -1
  53. package/dist/stores/memory.d.ts +4 -10
  54. package/dist/stores/memory.d.ts.map +1 -1
  55. package/dist/telemetry/client.d.ts +60 -0
  56. package/dist/telemetry/client.d.ts.map +1 -0
  57. package/dist/telemetry/index.d.ts +3 -0
  58. package/dist/telemetry/index.d.ts.map +1 -0
  59. package/dist/telemetry/types.d.ts +59 -0
  60. package/dist/telemetry/types.d.ts.map +1 -0
  61. package/dist/validation/index.js +41 -21
  62. package/dist/validation/index.js.map +1 -1
  63. package/dist/validation/index.mjs +41 -21
  64. package/dist/validation/index.mjs.map +1 -1
  65. package/package.json +8 -2
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { promises } from 'dns';
2
- import { randomBytes, createHash } from 'crypto';
2
+ import { randomBytes, timingSafeEqual, createHash } from 'crypto';
3
3
 
4
4
  // src/core/constants.ts
5
5
  var INPUT = {
@@ -66,13 +66,24 @@ var XSS_PATTERNS = [
66
66
  /** URL-encoded script tags */
67
67
  /%3Cscript/gi,
68
68
  /** SVG with onload */
69
- /<svg[^>]*onload/gi
69
+ /<svg[^>]*onload/gi,
70
+ /** form tags — phishing/credential harvesting via action= redirection */
71
+ /<form[\s>]/gi,
72
+ /** meta tags — http-equiv refresh redirects or CSP bypass */
73
+ /<meta[\s>]/gi,
74
+ /** base href hijacking — redirects all relative URLs to attacker domain */
75
+ /<base[\s>]/gi,
76
+ /** link tag injection — stylesheet or preload CSRF attacks */
77
+ /<link[\s>]/gi
70
78
  ];
71
79
  var XSS_REMOVE_PATTERNS = [
72
80
  /** Full script blocks (content + tags) */
73
81
  /<script[^>]*>[\s\S]*?<\/script>/gi,
74
82
  /** Standalone/unclosed script tags */
75
83
  /<script[^>]*>/gi,
84
+ /** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
85
+ /<style[^>]*>[\s\S]*?<\/style>/gi,
86
+ /<style[^>]*/gi,
76
87
  /** iframe — full block and partial/unclosed */
77
88
  /<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
78
89
  /<iframe[^>]*/gi,
@@ -93,7 +104,15 @@ var XSS_REMOVE_PATTERNS = [
93
104
  /javascript\s*:/gi,
94
105
  /vbscript\s*:/gi,
95
106
  /** data: URIs with HTML/script content */
96
- /data\s*:\s*text\/html[^>\s]*/gi
107
+ /data\s*:\s*text\/html[^>\s]*/gi,
108
+ /** form tag injection — phishing via action= redirection */
109
+ /<form[\s>][^>]*/gi,
110
+ /** meta tag injection — http-equiv refresh or CSP bypass */
111
+ /<meta[\s>][^>]*/gi,
112
+ /** base href hijacking */
113
+ /<base[\s>][^>]*/gi,
114
+ /** link tag injection — stylesheet or preload attacks */
115
+ /<link[\s>][^>]*/gi
97
116
  ];
98
117
  var SQL_PATTERNS = [
99
118
  /** SQL keywords */
@@ -157,8 +176,8 @@ var COMMAND_PATTERNS = [
157
176
  /[;&|`]/g,
158
177
  /** Command substitution: $( ... ) — matched as a pair to reduce false positives */
159
178
  /\$\(/g,
160
- /** URL-encoded newline/carriage-return injection (%0a, %0d) */
161
- /%0[ad]/gi
179
+ /** URL-encoded control characters (%00-%0F): null, tab, vtab, formfeed, LF, CR */
180
+ /%0[0-9a-f]/gi
162
181
  ];
163
182
  var DANGEROUS_PROTO_KEYS = /* @__PURE__ */ new Set([
164
183
  "__proto__",
@@ -380,13 +399,13 @@ function createRateLimiter(options = {}) {
380
399
  statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
381
400
  keyGenerator = (req) => {
382
401
  const ip = req.ip ?? req.socket?.remoteAddress;
383
- if (!ip) {
384
- console.warn(
385
- "[arcis] Rate limiter: cannot resolve client IP. All unresolvable clients share one counter. Set Express trust proxy if behind a reverse proxy."
386
- );
387
- return "unknown";
388
- }
389
- return ip;
402
+ if (ip) return ip;
403
+ const ua = req.headers["user-agent"] ?? "";
404
+ const lang = req.headers["accept-language"] ?? "";
405
+ const fp = `${ua}|${lang}`;
406
+ let hash = 0;
407
+ for (let i = 0; i < fp.length; i++) hash = (hash << 5) - hash + fp.charCodeAt(i) | 0;
408
+ return `unknown:${hash.toString(36)}`;
390
409
  },
391
410
  skip,
392
411
  store: externalStore
@@ -449,7 +468,24 @@ function createRateLimiter(options = {}) {
449
468
  }
450
469
  next();
451
470
  } catch (error) {
452
- console.error("[arcis] Rate limiter error:", error);
471
+ console.error("[arcis] Rate limiter store error, using in-memory fallback:", error);
472
+ try {
473
+ const key = keyGenerator(req);
474
+ const now = Date.now();
475
+ if (!inMemoryStore[key] || inMemoryStore[key].resetTime < now) {
476
+ inMemoryStore[key] = { count: 1, resetTime: now + windowMs };
477
+ } else {
478
+ inMemoryStore[key].count++;
479
+ }
480
+ const count = inMemoryStore[key].count;
481
+ if (count > max) {
482
+ const resetSeconds = Math.ceil((inMemoryStore[key].resetTime - now) / 1e3);
483
+ res.setHeader("Retry-After", resetSeconds.toString());
484
+ res.status(statusCode).json({ error: message, retryAfter: resetSeconds });
485
+ return;
486
+ }
487
+ } catch {
488
+ }
453
489
  next();
454
490
  }
455
491
  };
@@ -581,6 +617,80 @@ var SanitizationError = class extends ArcisError {
581
617
  }
582
618
  };
583
619
 
620
+ // src/middleware/telemetry.ts
621
+ var THREAT_TO_VECTOR = {
622
+ xss: "xss",
623
+ sql_injection: "sql",
624
+ nosql_injection: "nosql",
625
+ path_traversal: "path",
626
+ command_injection: "command",
627
+ prototype_pollution: "prototype",
628
+ header_injection: "header",
629
+ ssti: "ssti",
630
+ xxe: "xxe"
631
+ };
632
+ function createTelemetryEmitter(client) {
633
+ return (req, res, next) => {
634
+ const start = performance.now();
635
+ res.on("finish", () => {
636
+ try {
637
+ const event = buildEvent(req, res.statusCode, performance.now() - start);
638
+ client.record(event);
639
+ } catch {
640
+ }
641
+ });
642
+ next();
643
+ };
644
+ }
645
+ function tapSanitizerThreats(handler) {
646
+ return (req, res, next) => {
647
+ handler(req, res, (err) => {
648
+ if (err instanceof SecurityThreatError) {
649
+ const vector = THREAT_TO_VECTOR[err.threatType] ?? err.threatType;
650
+ req.__arcis = {
651
+ vector,
652
+ rule: `${vector}/match`,
653
+ severity: "high",
654
+ matchedPattern: err.pattern,
655
+ reason: err.message,
656
+ decision: "deny"
657
+ };
658
+ }
659
+ next(err);
660
+ });
661
+ };
662
+ }
663
+ function buildEvent(req, status, latencyMs) {
664
+ const marker = req.__arcis;
665
+ const decision = marker?.decision ?? inferDecision(status);
666
+ return {
667
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
668
+ ip: extractIp(req),
669
+ method: (req.method ?? "GET").toUpperCase(),
670
+ path: req.path ?? req.url ?? "/",
671
+ decision,
672
+ vector: marker?.vector ?? (status === 429 ? "rate-limit" : void 0),
673
+ rule: marker?.rule ?? (status === 429 ? "rate-limit/exceeded" : void 0),
674
+ severity: marker?.severity ?? (status === 429 ? "medium" : void 0),
675
+ userAgent: typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "",
676
+ reason: marker?.reason,
677
+ status,
678
+ matchedPattern: marker?.matchedPattern,
679
+ latencyMs: Math.max(0, latencyMs)
680
+ };
681
+ }
682
+ function inferDecision(status) {
683
+ if (status === 429) return "deny";
684
+ if (status === 400) return "deny";
685
+ if (status === 403) return "deny";
686
+ return "allow";
687
+ }
688
+ function extractIp(req) {
689
+ if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
690
+ const remote = req.socket?.remoteAddress;
691
+ return typeof remote === "string" ? remote : "0.0.0.0";
692
+ }
693
+
584
694
  // src/sanitizers/utils.ts
585
695
  function encodeHtmlEntities(str) {
586
696
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
@@ -693,26 +803,31 @@ function sanitizePath(input, collectThreats = false) {
693
803
  const threats = [];
694
804
  let value = input;
695
805
  let wasSanitized = false;
696
- for (const pattern of PATH_PATTERNS) {
697
- pattern.lastIndex = 0;
698
- if (pattern.test(value)) {
806
+ value = value.normalize("NFKC");
807
+ let prev;
808
+ do {
809
+ prev = value;
810
+ for (const pattern of PATH_PATTERNS) {
699
811
  pattern.lastIndex = 0;
700
- if (collectThreats) {
701
- const matches = value.match(pattern);
702
- if (matches) {
703
- for (const match of matches) {
704
- threats.push({
705
- type: "path_traversal",
706
- pattern: pattern.source,
707
- original: match
708
- });
812
+ if (pattern.test(value)) {
813
+ pattern.lastIndex = 0;
814
+ if (collectThreats) {
815
+ const matches = value.match(pattern);
816
+ if (matches) {
817
+ for (const match of matches) {
818
+ threats.push({
819
+ type: "path_traversal",
820
+ pattern: pattern.source,
821
+ original: match
822
+ });
823
+ }
709
824
  }
710
825
  }
826
+ value = value.replace(pattern, "");
827
+ wasSanitized = true;
711
828
  }
712
- value = value.replace(pattern, "");
713
- wasSanitized = true;
714
829
  }
715
- }
830
+ } while (value !== prev);
716
831
  if (collectThreats) {
717
832
  return { value, wasSanitized, threats };
718
833
  }
@@ -720,9 +835,10 @@ function sanitizePath(input, collectThreats = false) {
720
835
  }
721
836
  function detectPathTraversal(input) {
722
837
  if (typeof input !== "string") return false;
838
+ const normalized = input.normalize("NFKC");
723
839
  for (const pattern of PATH_PATTERNS) {
724
840
  pattern.lastIndex = 0;
725
- if (pattern.test(input)) {
841
+ if (pattern.test(normalized)) {
726
842
  return true;
727
843
  }
728
844
  }
@@ -780,7 +896,7 @@ function sanitizeString(value, options = {}) {
780
896
  if (value.length > maxSize) {
781
897
  throw new InputTooLargeError(maxSize, value.length);
782
898
  }
783
- const reject = options.mode !== "sanitize";
899
+ const reject = options.mode === "reject";
784
900
  let result = value;
785
901
  if (options.sql !== false) {
786
902
  if (reject) {
@@ -929,10 +1045,24 @@ var SSTI_DETECT_PATTERNS = [
929
1045
  /\{\{\s*(?:self|request|lipsum|cycler|joiner|namespace|range)\b/gi
930
1046
  ];
931
1047
  var SSTI_REMOVE_PATTERNS = [
1048
+ /** Jinja2 / Twig: {{ ... }} — always strip (not valid in any JS context) */
932
1049
  /\{\{.*?\}\}/g,
933
- /\$\{.*?\}/g,
1050
+ /**
1051
+ * Freemarker / Spring EL: ${...} — strip when expression contains operators,
1052
+ * method calls, or Python dunder patterns (sandbox escape).
1053
+ * Bare ${name} and ${user.name} are left intact (JS template literal syntax).
1054
+ */
1055
+ /\$\{[^}]*__\w+__[^}]*\}/g,
1056
+ /\$\{[^}]*[?!()*+\-/][^}]*\}/g,
1057
+ /** ERB / EJS: <%= ... %> */
934
1058
  /<%[=\-]?.*?%>/gs,
935
- /#\{.*?\}/g,
1059
+ /**
1060
+ * Pug / Jade: #{...} — same narrowing as ${ above, plus dunder detection.
1061
+ * #{name} output expressions are left intact.
1062
+ */
1063
+ /#\{[^}]*__\w+__[^}]*\}/g,
1064
+ /#\{[^}]*[?!()*+\-/][^}]*\}/g,
1065
+ /** Python dunder sandbox escape — always strip */
936
1066
  /__(?:class|mro|subclasses|globals|builtins|import)__/gi
937
1067
  ];
938
1068
  function sanitizeSsti(input, collectThreats = false) {
@@ -979,6 +1109,8 @@ function detectSsti(input) {
979
1109
  }
980
1110
 
981
1111
  // src/sanitizers/xxe.ts
1112
+ var MAX_XXE_INPUT_BYTES = 1e6;
1113
+ var MAX_ENTITY_REFERENCES = 64;
982
1114
  var XXE_DETECT_PATTERNS = [
983
1115
  /** DOCTYPE declaration */
984
1116
  /<!DOCTYPE\b/gi,
@@ -1008,6 +1140,19 @@ function sanitizeXxe(input, collectThreats = false) {
1008
1140
  const threats = [];
1009
1141
  let value = input;
1010
1142
  let wasSanitized = false;
1143
+ if (value.length > MAX_XXE_INPUT_BYTES) {
1144
+ if (collectThreats) {
1145
+ threats.push({ type: "xxe", pattern: "oversize_input", original: `length=${value.length}` });
1146
+ }
1147
+ return collectThreats ? { value: "", wasSanitized: true, threats } : "";
1148
+ }
1149
+ const entityRefs = value.match(/&\w+;/g);
1150
+ if (entityRefs && entityRefs.length > MAX_ENTITY_REFERENCES) {
1151
+ if (collectThreats) {
1152
+ threats.push({ type: "xxe", pattern: "entity_expansion", original: `count=${entityRefs.length}` });
1153
+ }
1154
+ return collectThreats ? { value: "", wasSanitized: true, threats } : "";
1155
+ }
1011
1156
  for (const pattern of XXE_REMOVE_PATTERNS) {
1012
1157
  pattern.lastIndex = 0;
1013
1158
  if (pattern.test(value)) {
@@ -1045,12 +1190,10 @@ function detectXxe(input) {
1045
1190
  }
1046
1191
 
1047
1192
  // src/sanitizers/jsonp.ts
1048
- var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.[\]]*$/;
1193
+ var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.]*$/;
1049
1194
  var DANGEROUS_CALLBACK_PATTERNS = [
1050
- /\.\./,
1195
+ /\.\./
1051
1196
  // prototype chain traversal
1052
- /\[\s*\]/
1053
- // empty bracket access
1054
1197
  ];
1055
1198
  function sanitizeJsonpCallback(callback, maxLength = 128) {
1056
1199
  if (typeof callback !== "string" || callback.length === 0) {
@@ -1135,7 +1278,7 @@ function detectHeaderInjection(input) {
1135
1278
 
1136
1279
  // src/sanitizers/pii.ts
1137
1280
  var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z]{2,})+/g;
1138
- var PHONE_RE = /(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
1281
+ var PHONE_RE = /(?<!\d)(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\d)/g;
1139
1282
  var CREDIT_CARD_RE = /\b(?:\d[ -]*?){13,19}\b/g;
1140
1283
  var SSN_RE = /\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g;
1141
1284
  var IPV4_RE = /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g;
@@ -1296,16 +1439,18 @@ function encodeForAttribute(value) {
1296
1439
  function encodeForJs(value) {
1297
1440
  if (!value) return "";
1298
1441
  let result = "";
1299
- for (let i = 0; i < value.length; i++) {
1300
- const ch = value.charCodeAt(i);
1301
- if (ch >= 48 && ch <= 57 || // 0-9
1302
- ch >= 65 && ch <= 90 || // A-Z
1303
- ch >= 97 && ch <= 122) {
1304
- result += value[i];
1305
- } else if (ch < 256) {
1306
- result += `\\x${ch.toString(16).toUpperCase().padStart(2, "0")}`;
1442
+ for (const char of value) {
1443
+ const cp = char.codePointAt(0);
1444
+ if (cp >= 48 && cp <= 57 || // 0-9
1445
+ cp >= 65 && cp <= 90 || // A-Z
1446
+ cp >= 97 && cp <= 122) {
1447
+ result += char;
1448
+ } else if (cp < 256) {
1449
+ result += `\\x${cp.toString(16).toUpperCase().padStart(2, "0")}`;
1450
+ } else if (cp <= 65535) {
1451
+ result += `\\u${cp.toString(16).toUpperCase().padStart(4, "0")}`;
1307
1452
  } else {
1308
- result += `\\u${ch.toString(16).toUpperCase().padStart(4, "0")}`;
1453
+ result += `\\u{${cp.toString(16).toUpperCase()}}`;
1309
1454
  }
1310
1455
  }
1311
1456
  return result;
@@ -1758,8 +1903,12 @@ function checkPrivateIp(hostname) {
1758
1903
  if (hostname === "metadata.google.internal" || hostname === "metadata.internal" || hostname === "metadata.azure.internal") {
1759
1904
  return "cloud metadata endpoint";
1760
1905
  }
1761
- const ipv6 = hostname.replace(/^\[|\]$/g, "");
1762
- if (ipv6 === "::1" || ipv6 === "::" || ipv6.startsWith("fc") || ipv6.startsWith("fd") || ipv6.startsWith("fe80")) {
1906
+ let ipv6 = hostname.replace(/^\[|\]$/g, "");
1907
+ const zoneIdx = ipv6.indexOf("%");
1908
+ if (zoneIdx !== -1) {
1909
+ ipv6 = ipv6.slice(0, zoneIdx);
1910
+ }
1911
+ if (ipv6 === "::1" || ipv6 === "::" || /^fc[0-9a-f]{2}:/i.test(ipv6) || /^fd[0-9a-f]{2}:/i.test(ipv6) || /^fe80:/i.test(ipv6) || /^ff[0-9a-f]{2}:/i.test(ipv6)) {
1763
1912
  return "private IPv6 address";
1764
1913
  }
1765
1914
  const mappedDotted = ipv6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
@@ -2253,10 +2402,192 @@ function createRedactor(sensitiveKeys = []) {
2253
2402
  }
2254
2403
  var safeLog = createSafeLogger;
2255
2404
 
2405
+ // src/telemetry/client.ts
2406
+ var DEFAULT_BATCH_SIZE = 50;
2407
+ var MAX_BATCH_SIZE = 500;
2408
+ var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
2409
+ var MIN_FLUSH_INTERVAL_MS = 500;
2410
+ var FLUSH_TIMEOUT_MS = 1e4;
2411
+ var TelemetryClient = class {
2412
+ constructor(options) {
2413
+ this.queue = [];
2414
+ this.flushing = false;
2415
+ this.closed = false;
2416
+ if (!options.endpoint || typeof options.endpoint !== "string") {
2417
+ throw new TypeError("TelemetryClient: `endpoint` is required");
2418
+ }
2419
+ this.endpoint = options.endpoint;
2420
+ this.apiKey = options.apiKey;
2421
+ this.workspaceId = options.workspaceId;
2422
+ this.batchSize = clamp(
2423
+ options.batchSize ?? DEFAULT_BATCH_SIZE,
2424
+ 1,
2425
+ MAX_BATCH_SIZE
2426
+ );
2427
+ this.flushIntervalMs = Math.max(
2428
+ options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
2429
+ MIN_FLUSH_INTERVAL_MS
2430
+ );
2431
+ this.onError = options.onError ?? (() => {
2432
+ });
2433
+ this.startTimer();
2434
+ }
2435
+ /**
2436
+ * Enqueue an event. Fast, synchronous, cannot throw.
2437
+ * Triggers a flush if the queue has reached `batchSize`.
2438
+ */
2439
+ record(event) {
2440
+ if (this.closed) return;
2441
+ this.queue.push(event);
2442
+ if (this.queue.length >= this.batchSize) {
2443
+ void this.flush();
2444
+ }
2445
+ }
2446
+ /**
2447
+ * Manually flush the queue. Pulls up to `batchSize` events into a batch and
2448
+ * POSTs them. Returns a resolved promise on success OR on handled failure.
2449
+ * Never throws.
2450
+ */
2451
+ async flush() {
2452
+ if (this.flushing) return;
2453
+ if (this.queue.length === 0) return;
2454
+ this.flushing = true;
2455
+ try {
2456
+ const batch = this.queue.splice(0, this.batchSize);
2457
+ await this.send(batch);
2458
+ } catch (err) {
2459
+ this.safeNotify(err);
2460
+ } finally {
2461
+ this.flushing = false;
2462
+ }
2463
+ if (!this.closed && this.queue.length > 0) {
2464
+ void this.flush();
2465
+ }
2466
+ }
2467
+ /**
2468
+ * Shut down: stop the interval timer and attempt one final flush.
2469
+ * Safe to call multiple times.
2470
+ */
2471
+ async close() {
2472
+ if (this.closed) return;
2473
+ this.closed = true;
2474
+ if (this.timer !== void 0) {
2475
+ clearInterval(this.timer);
2476
+ this.timer = void 0;
2477
+ }
2478
+ if (this.signalHandler !== void 0) {
2479
+ process.off("SIGTERM", this.signalHandler);
2480
+ process.off("SIGINT", this.signalHandler);
2481
+ this.signalHandler = void 0;
2482
+ }
2483
+ try {
2484
+ await this.flush();
2485
+ } catch {
2486
+ }
2487
+ }
2488
+ /**
2489
+ * Register `SIGTERM` / `SIGINT` handlers that call `close()` to drain
2490
+ * the queue on graceful shutdown. Opt-in — libraries should not silently
2491
+ * attach global signal handlers. Safe to call multiple times.
2492
+ */
2493
+ installShutdownHooks() {
2494
+ if (this.signalHandler !== void 0 || this.closed) return;
2495
+ const handler = () => {
2496
+ void this.close();
2497
+ };
2498
+ this.signalHandler = handler;
2499
+ process.once("SIGTERM", handler);
2500
+ process.once("SIGINT", handler);
2501
+ }
2502
+ /** Count of events currently waiting to be sent. Useful for tests. */
2503
+ get pendingCount() {
2504
+ return this.queue.length;
2505
+ }
2506
+ // ── internals ─────────────────────────────────────────────────────────
2507
+ async send(batch) {
2508
+ const headers = {
2509
+ "content-type": "application/json"
2510
+ };
2511
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
2512
+ if (this.workspaceId) headers["x-workspace-id"] = this.workspaceId;
2513
+ const controller = new AbortController();
2514
+ const abortTimer = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
2515
+ try {
2516
+ const res = await fetch(this.endpoint, {
2517
+ method: "POST",
2518
+ headers,
2519
+ body: JSON.stringify({ events: batch }),
2520
+ signal: controller.signal
2521
+ });
2522
+ if (!res.ok) {
2523
+ const text = await safeReadBody(res);
2524
+ throw new TelemetryHttpError(res.status, text);
2525
+ }
2526
+ } finally {
2527
+ clearTimeout(abortTimer);
2528
+ }
2529
+ }
2530
+ startTimer() {
2531
+ this.timer = setInterval(() => {
2532
+ void this.flush();
2533
+ }, this.flushIntervalMs);
2534
+ this.timer.unref?.();
2535
+ }
2536
+ safeNotify(err) {
2537
+ try {
2538
+ this.onError(err instanceof Error ? err : new Error(String(err)));
2539
+ } catch {
2540
+ }
2541
+ }
2542
+ };
2543
+ var TelemetryHttpError = class extends Error {
2544
+ constructor(status, responseBody) {
2545
+ super(`Telemetry ingest returned HTTP ${status}`);
2546
+ this.status = status;
2547
+ this.responseBody = responseBody;
2548
+ this.name = "TelemetryHttpError";
2549
+ }
2550
+ };
2551
+ function clamp(value, min, max) {
2552
+ if (!Number.isFinite(value)) return min;
2553
+ return Math.max(min, Math.min(max, Math.trunc(value)));
2554
+ }
2555
+ async function safeReadBody(res) {
2556
+ try {
2557
+ const text = await res.text();
2558
+ return text.slice(0, 500);
2559
+ } catch {
2560
+ return "";
2561
+ }
2562
+ }
2563
+
2256
2564
  // src/middleware/main.ts
2565
+ function buildTelemetryFromEnv() {
2566
+ const env = typeof process !== "undefined" ? process.env : void 0;
2567
+ const endpoint = env?.ARCIS_ENDPOINT;
2568
+ if (!endpoint) return void 0;
2569
+ const opts = { endpoint };
2570
+ if (env?.ARCIS_WORKSPACE_ID) opts.workspaceId = env.ARCIS_WORKSPACE_ID;
2571
+ if (env?.ARCIS_KEY) opts.apiKey = env.ARCIS_KEY;
2572
+ const batch = env?.ARCIS_BATCH_SIZE ? parseInt(env.ARCIS_BATCH_SIZE, 10) : NaN;
2573
+ if (!Number.isNaN(batch)) opts.batchSize = batch;
2574
+ const flush = env?.ARCIS_FLUSH_INTERVAL_MS ? parseInt(env.ARCIS_FLUSH_INTERVAL_MS, 10) : NaN;
2575
+ if (!Number.isNaN(flush)) opts.flushIntervalMs = flush;
2576
+ return opts;
2577
+ }
2257
2578
  function arcis(options = {}) {
2258
2579
  const middlewares = [];
2259
2580
  const cleanupFns = [];
2581
+ let telemetryClient;
2582
+ const telemetryOpts = options.telemetry?.endpoint ? options.telemetry : buildTelemetryFromEnv();
2583
+ if (telemetryOpts) {
2584
+ const client = new TelemetryClient(telemetryOpts);
2585
+ telemetryClient = client;
2586
+ middlewares.push(createTelemetryEmitter(client));
2587
+ cleanupFns.push(() => {
2588
+ void client.close();
2589
+ });
2590
+ }
2260
2591
  if (options.headers !== false) {
2261
2592
  const headerOpts = typeof options.headers === "object" ? options.headers : {};
2262
2593
  middlewares.push(createHeaders(headerOpts));
@@ -2269,7 +2600,8 @@ function arcis(options = {}) {
2269
2600
  }
2270
2601
  if (options.sanitize !== false) {
2271
2602
  const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
2272
- middlewares.push(createSanitizer(sanitizeOpts));
2603
+ const sanitizer = createSanitizer(sanitizeOpts);
2604
+ middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
2273
2605
  }
2274
2606
  const result = middlewares;
2275
2607
  result.close = () => {
@@ -2596,6 +2928,16 @@ function secureCookieDefaults(options = {}) {
2596
2928
  sameSite: options.sameSite ?? "Lax",
2597
2929
  path: options.path
2598
2930
  };
2931
+ if (resolved.sameSite === "None" && resolved.secure === false) {
2932
+ throw new Error(
2933
+ "[arcis] secureCookieDefaults: sameSite=None requires secure=true (modern browsers reject the cookie otherwise)"
2934
+ );
2935
+ }
2936
+ if (resolved.httpOnly === false && resolved.secure === false && isProduction) {
2937
+ console.warn(
2938
+ "[arcis] secureCookieDefaults: httpOnly and secure are both disabled in production \u2014 cookies will be readable by JS and sent over HTTP"
2939
+ );
2940
+ }
2599
2941
  return (_req, res, next) => {
2600
2942
  const originalSetHeader = res.setHeader.bind(res);
2601
2943
  res.setHeader = function patchedSetHeader(name, value) {
@@ -2728,7 +3070,16 @@ function detectBehavioralSignals(req) {
2728
3070
  }
2729
3071
  function detectBot(req) {
2730
3072
  const rawUa = req.headers["user-agent"] ?? "";
2731
- const ua = rawUa.length > 2048 ? rawUa.slice(0, 2048) : rawUa;
3073
+ if (rawUa.length > 2048) {
3074
+ return {
3075
+ isBot: true,
3076
+ category: "UNKNOWN",
3077
+ name: null,
3078
+ confidence: 0.9,
3079
+ signals: detectBehavioralSignals(req)
3080
+ };
3081
+ }
3082
+ const ua = rawUa;
2732
3083
  const signals = detectBehavioralSignals(req);
2733
3084
  if (!ua) {
2734
3085
  return {
@@ -2818,11 +3169,9 @@ function generateCsrfToken(length = 32) {
2818
3169
  function validateCsrfToken(cookieToken, requestToken) {
2819
3170
  if (!cookieToken || !requestToken) return false;
2820
3171
  if (cookieToken.length !== requestToken.length) return false;
2821
- let result = 0;
2822
- for (let i = 0; i < cookieToken.length; i++) {
2823
- result |= cookieToken.charCodeAt(i) ^ requestToken.charCodeAt(i);
2824
- }
2825
- return result === 0;
3172
+ const a = Buffer.from(cookieToken);
3173
+ const b = Buffer.from(requestToken);
3174
+ return timingSafeEqual(a, b);
2826
3175
  }
2827
3176
  function getRequestToken(req, headerName, fieldName) {
2828
3177
  const headerToken = req.headers[headerName.toLowerCase()];
@@ -2831,10 +3180,6 @@ function getRequestToken(req, headerName, fieldName) {
2831
3180
  const bodyToken = req.body[fieldName];
2832
3181
  if (typeof bodyToken === "string" && bodyToken) return bodyToken;
2833
3182
  }
2834
- if (req.query && fieldName in req.query) {
2835
- const queryToken = req.query[fieldName];
2836
- if (typeof queryToken === "string" && queryToken) return queryToken;
2837
- }
2838
3183
  return void 0;
2839
3184
  }
2840
3185
  function csrfProtection(options = {}) {
@@ -2898,6 +3243,10 @@ function csrfProtection(options = {}) {
2898
3243
  if (!validateCsrfToken(cookieToken, requestToken)) {
2899
3244
  return onError(req, res, next);
2900
3245
  }
3246
+ if (options.rotateOnUse) {
3247
+ const freshToken = generateCsrfToken(tokenLength);
3248
+ setCsrfCookie(res, cookieName, freshToken, cookieOpts);
3249
+ }
2901
3250
  next();
2902
3251
  };
2903
3252
  }
@@ -2917,7 +3266,15 @@ function setCsrfCookie(res, name, token, opts) {
2917
3266
  if (opts.secure) parts.push("Secure");
2918
3267
  parts.push(`SameSite=${opts.sameSite}`);
2919
3268
  if (opts.domain) parts.push(`Domain=${opts.domain}`);
2920
- res.setHeader("Set-Cookie", parts.join("; "));
3269
+ const newCookie = parts.join("; ");
3270
+ const existing = res.getHeader("Set-Cookie");
3271
+ if (existing === void 0) {
3272
+ res.setHeader("Set-Cookie", newCookie);
3273
+ } else if (Array.isArray(existing)) {
3274
+ res.setHeader("Set-Cookie", [...existing, newCookie]);
3275
+ } else {
3276
+ res.setHeader("Set-Cookie", [existing, newCookie]);
3277
+ }
2921
3278
  }
2922
3279
  function escapeRegex(str) {
2923
3280
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -2926,7 +3283,7 @@ var createCsrf = csrfProtection;
2926
3283
 
2927
3284
  // src/middleware/hpp.ts
2928
3285
  function hpp(options = {}) {
2929
- const whitelist = new Set(options.whitelist ?? []);
3286
+ const whitelist = new Set((options.whitelist ?? []).map((k) => k.toLowerCase()));
2930
3287
  const checkQuery = options.checkQuery ?? true;
2931
3288
  const checkBody = options.checkBody ?? true;
2932
3289
  return (req, _res, next) => {
@@ -2936,7 +3293,7 @@ function hpp(options = {}) {
2936
3293
  for (const [key, value] of Object.entries(req.query)) {
2937
3294
  if (Array.isArray(value)) {
2938
3295
  const strings = value.filter((v) => typeof v === "string");
2939
- if (whitelist.has(key)) {
3296
+ if (whitelist.has(key.toLowerCase())) {
2940
3297
  clean[key] = strings;
2941
3298
  } else {
2942
3299
  polluted[key] = strings;
@@ -2954,7 +3311,7 @@ function hpp(options = {}) {
2954
3311
  const clean = {};
2955
3312
  for (const [key, value] of Object.entries(req.body)) {
2956
3313
  if (Array.isArray(value)) {
2957
- if (whitelist.has(key)) {
3314
+ if (whitelist.has(key.toLowerCase())) {
2958
3315
  clean[key] = value;
2959
3316
  } else {
2960
3317
  polluted[key] = value;
@@ -2972,6 +3329,89 @@ function hpp(options = {}) {
2972
3329
  }
2973
3330
  var createHpp = hpp;
2974
3331
 
3332
+ // src/middleware/signup-protection.ts
3333
+ function checkSignup(req, options = {}) {
3334
+ const {
3335
+ emailField = "email",
3336
+ checkEmail = true,
3337
+ blockDisposable = true,
3338
+ checkBot = true,
3339
+ allowedBotCategories = [],
3340
+ allowedEmailDomains = [],
3341
+ blockedEmailDomains = []
3342
+ } = options;
3343
+ if (checkBot) {
3344
+ const bot = detectBot(req);
3345
+ if (bot.isBot && !allowedBotCategories.includes(bot.category)) {
3346
+ return {
3347
+ allowed: false,
3348
+ reason: "bot",
3349
+ details: { category: bot.category, name: bot.name, confidence: bot.confidence }
3350
+ };
3351
+ }
3352
+ }
3353
+ if (checkEmail) {
3354
+ const email = req.body?.[emailField];
3355
+ if (typeof email !== "string" || email.length === 0) {
3356
+ return { allowed: false, reason: "missing_email" };
3357
+ }
3358
+ const result = validateEmail(email, {
3359
+ checkDisposable: blockDisposable,
3360
+ allowedDomains: allowedEmailDomains,
3361
+ blockedDomains: blockedEmailDomains
3362
+ });
3363
+ if (!result.valid) {
3364
+ const reason = result.reason === "disposable" ? "disposable_email" : "invalid_email";
3365
+ return { allowed: false, reason, details: { emailReason: result.reason } };
3366
+ }
3367
+ }
3368
+ return { allowed: true, reason: "ok" };
3369
+ }
3370
+ function signupProtection(options = {}) {
3371
+ const rateLimitCfg = options.rateLimit;
3372
+ const limiter = rateLimitCfg === false ? null : createRateLimiter({
3373
+ max: rateLimitCfg?.max ?? 5,
3374
+ windowMs: rateLimitCfg?.windowMs ?? 6e4,
3375
+ message: "Too many signup attempts"
3376
+ });
3377
+ const handler = (req, res, next) => {
3378
+ const result = checkSignup(req, options);
3379
+ if (!result.allowed) {
3380
+ options.onBlocked?.(req, result);
3381
+ const status = result.reason === "bot" ? 403 : 400;
3382
+ res.status(status).json({ error: "signup_blocked", reason: result.reason });
3383
+ return;
3384
+ }
3385
+ if (limiter) {
3386
+ let rateLimited = false;
3387
+ const rateLimitNext = (err) => {
3388
+ if (err) return next(err);
3389
+ if (!rateLimited) next();
3390
+ };
3391
+ const patchedRes = new Proxy(res, {
3392
+ get(target, prop) {
3393
+ if (prop === "status") {
3394
+ return (code) => {
3395
+ if (code === 429) {
3396
+ rateLimited = true;
3397
+ options.onBlocked?.(req, { allowed: false, reason: "rate_limited" });
3398
+ }
3399
+ return target.status.call(target, code);
3400
+ };
3401
+ }
3402
+ return Reflect.get(target, prop);
3403
+ }
3404
+ });
3405
+ limiter(req, patchedRes, rateLimitNext);
3406
+ return;
3407
+ }
3408
+ next();
3409
+ };
3410
+ const middleware = handler;
3411
+ middleware.close = () => limiter?.close();
3412
+ return middleware;
3413
+ }
3414
+
2975
3415
  // src/utils/ip.ts
2976
3416
  var PLATFORM_HEADERS = {
2977
3417
  cloudflare: "cf-connecting-ip",
@@ -3101,8 +3541,9 @@ function fingerprint(req, options = {}) {
3101
3541
  }
3102
3542
 
3103
3543
  // src/stores/memory.ts
3544
+ var DEFAULT_MAX_SIZE2 = 1e4;
3104
3545
  var MemoryStore = class {
3105
- constructor(windowMs = RATE_LIMIT.DEFAULT_WINDOW_MS) {
3546
+ constructor(windowMs = RATE_LIMIT.DEFAULT_WINDOW_MS, maxSize = DEFAULT_MAX_SIZE2) {
3106
3547
  this.store = /* @__PURE__ */ new Map();
3107
3548
  this.cleanupInterval = null;
3108
3549
  if (!Number.isFinite(windowMs) || windowMs < RATE_LIMIT.MIN_WINDOW_MS) {
@@ -3110,7 +3551,11 @@ var MemoryStore = class {
3110
3551
  `MemoryStore: windowMs must be a finite number >= ${RATE_LIMIT.MIN_WINDOW_MS} (got ${windowMs})`
3111
3552
  );
3112
3553
  }
3554
+ if (!Number.isFinite(maxSize) || maxSize < 1) {
3555
+ throw new RangeError(`MemoryStore: maxSize must be >= 1 (got ${maxSize})`);
3556
+ }
3113
3557
  this.windowMs = windowMs;
3558
+ this.maxSize = maxSize;
3114
3559
  this.startCleanup();
3115
3560
  }
3116
3561
  /**
@@ -3142,18 +3587,33 @@ var MemoryStore = class {
3142
3587
  return entry;
3143
3588
  }
3144
3589
  async set(key, entry) {
3590
+ if (!this.store.has(key) && this.store.size >= this.maxSize) {
3591
+ this.evictExpired();
3592
+ if (this.store.size >= this.maxSize) return;
3593
+ }
3145
3594
  this.store.set(key, entry);
3146
3595
  }
3147
3596
  async increment(key) {
3148
3597
  const now = Date.now();
3149
3598
  const entry = this.store.get(key);
3150
3599
  if (!entry || entry.resetTime < now) {
3600
+ if (this.store.size >= this.maxSize) {
3601
+ this.evictExpired();
3602
+ if (this.store.size >= this.maxSize) return 1;
3603
+ }
3151
3604
  this.store.set(key, { count: 1, resetTime: now + this.windowMs });
3152
3605
  return 1;
3153
3606
  }
3154
3607
  entry.count++;
3155
3608
  return entry.count;
3156
3609
  }
3610
+ /** Eagerly remove expired entries to reclaim capacity. */
3611
+ evictExpired() {
3612
+ const now = Date.now();
3613
+ for (const [key, entry] of this.store.entries()) {
3614
+ if (entry.resetTime < now) this.store.delete(key);
3615
+ }
3616
+ }
3157
3617
  async decrement(key) {
3158
3618
  const entry = this.store.get(key);
3159
3619
  if (entry && entry.count > 0) {
@@ -3235,6 +3695,6 @@ function createRedisStore(options) {
3235
3695
  return new RedisStore(options);
3236
3696
  }
3237
3697
 
3238
- export { ArcisError, ValidationError as ArcisValidationError, BLOCKED, ERRORS, HEADERS, INPUT, InputTooLargeError, MemoryStore, RATE_LIMIT, REDACTION, RateLimitError, RedisStore, SanitizationError, SecurityThreatError, VALIDATION, arcis, arcisWithMethods as arcisFunction, botProtection, createCors, createCsrf, createErrorHandler, createHeaders, createHpp, createRateLimiter, createRedactor, createRedisStore, createSafeLogger, createSanitizer, createSecureCookies, createSlidingWindowLimiter, createTokenBucketLimiter, createValidator, csrfProtection, main_default as default, detectBot, detectClientIp, detectCommandInjection, detectHeaderInjection, detectJsonpInjection, detectNoSqlInjection, detectPathTraversal, detectPii, detectPrototypePollution, detectSql, detectSsti, detectXss, detectXxe, encodeForAttribute, encodeForCss, encodeForHtml, encodeForJs, encodeForUrl, enforceSecureCookie, errorHandler, fingerprint, formatDuration, generateCsrfToken, hpp, isDangerousExtension, isDangerousNoSqlKey, isDangerousProtoKey, isPrivateIp, isRedirectSafe, isUrlSafe, isValidEmailSyntax, parseDuration, rateLimit, redactObjectPii, redactPii, safeCors, safeLog, sanitizeCommand, sanitizeFilename, sanitizeHeaderValue, sanitizeHeaders, sanitizeJsonpCallback, sanitizeObject, sanitizePath, sanitizeSql, sanitizeSsti, sanitizeString, sanitizeXss, sanitizeXxe, scanObjectPii, scanPii, secureCookieDefaults, securityHeaders, validate, validateCsrfToken, validateEmail, validateFile, validateRedirect, validateUrl, verifyEmailMx };
3698
+ export { ArcisError, ValidationError as ArcisValidationError, BLOCKED, ERRORS, HEADERS, INPUT, InputTooLargeError, MemoryStore, RATE_LIMIT, REDACTION, RateLimitError, RedisStore, SanitizationError, SecurityThreatError, TelemetryClient, TelemetryHttpError, VALIDATION, arcis, arcisWithMethods as arcisFunction, botProtection, checkSignup, createCors, createCsrf, createErrorHandler, createHeaders, createHpp, createRateLimiter, createRedactor, createRedisStore, createSafeLogger, createSanitizer, createSecureCookies, createSlidingWindowLimiter, createTokenBucketLimiter, createValidator, csrfProtection, main_default as default, detectBot, detectClientIp, detectCommandInjection, detectHeaderInjection, detectJsonpInjection, detectNoSqlInjection, detectPathTraversal, detectPii, detectPrototypePollution, detectSql, detectSsti, detectXss, detectXxe, encodeForAttribute, encodeForCss, encodeForHtml, encodeForJs, encodeForUrl, enforceSecureCookie, errorHandler, fingerprint, formatDuration, generateCsrfToken, hpp, isDangerousExtension, isDangerousNoSqlKey, isDangerousProtoKey, isPrivateIp, isRedirectSafe, isUrlSafe, isValidEmailSyntax, parseDuration, rateLimit, redactObjectPii, redactPii, safeCors, safeLog, sanitizeCommand, sanitizeFilename, sanitizeHeaderValue, sanitizeHeaders, sanitizeJsonpCallback, sanitizeObject, sanitizePath, sanitizeSql, sanitizeSsti, sanitizeString, sanitizeXss, sanitizeXxe, scanObjectPii, scanPii, secureCookieDefaults, securityHeaders, signupProtection, validate, validateCsrfToken, validateEmail, validateFile, validateRedirect, validateUrl, verifyEmailMx };
3239
3699
  //# sourceMappingURL=index.mjs.map
3240
3700
  //# sourceMappingURL=index.mjs.map