@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.js CHANGED
@@ -70,13 +70,24 @@ var XSS_PATTERNS = [
70
70
  /** URL-encoded script tags */
71
71
  /%3Cscript/gi,
72
72
  /** SVG with onload */
73
- /<svg[^>]*onload/gi
73
+ /<svg[^>]*onload/gi,
74
+ /** form tags — phishing/credential harvesting via action= redirection */
75
+ /<form[\s>]/gi,
76
+ /** meta tags — http-equiv refresh redirects or CSP bypass */
77
+ /<meta[\s>]/gi,
78
+ /** base href hijacking — redirects all relative URLs to attacker domain */
79
+ /<base[\s>]/gi,
80
+ /** link tag injection — stylesheet or preload CSRF attacks */
81
+ /<link[\s>]/gi
74
82
  ];
75
83
  var XSS_REMOVE_PATTERNS = [
76
84
  /** Full script blocks (content + tags) */
77
85
  /<script[^>]*>[\s\S]*?<\/script>/gi,
78
86
  /** Standalone/unclosed script tags */
79
87
  /<script[^>]*>/gi,
88
+ /** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
89
+ /<style[^>]*>[\s\S]*?<\/style>/gi,
90
+ /<style[^>]*/gi,
80
91
  /** iframe — full block and partial/unclosed */
81
92
  /<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
82
93
  /<iframe[^>]*/gi,
@@ -97,7 +108,15 @@ var XSS_REMOVE_PATTERNS = [
97
108
  /javascript\s*:/gi,
98
109
  /vbscript\s*:/gi,
99
110
  /** data: URIs with HTML/script content */
100
- /data\s*:\s*text\/html[^>\s]*/gi
111
+ /data\s*:\s*text\/html[^>\s]*/gi,
112
+ /** form tag injection — phishing via action= redirection */
113
+ /<form[\s>][^>]*/gi,
114
+ /** meta tag injection — http-equiv refresh or CSP bypass */
115
+ /<meta[\s>][^>]*/gi,
116
+ /** base href hijacking */
117
+ /<base[\s>][^>]*/gi,
118
+ /** link tag injection — stylesheet or preload attacks */
119
+ /<link[\s>][^>]*/gi
101
120
  ];
102
121
  var SQL_PATTERNS = [
103
122
  /** SQL keywords */
@@ -161,8 +180,8 @@ var COMMAND_PATTERNS = [
161
180
  /[;&|`]/g,
162
181
  /** Command substitution: $( ... ) — matched as a pair to reduce false positives */
163
182
  /\$\(/g,
164
- /** URL-encoded newline/carriage-return injection (%0a, %0d) */
165
- /%0[ad]/gi
183
+ /** URL-encoded control characters (%00-%0F): null, tab, vtab, formfeed, LF, CR */
184
+ /%0[0-9a-f]/gi
166
185
  ];
167
186
  var DANGEROUS_PROTO_KEYS = /* @__PURE__ */ new Set([
168
187
  "__proto__",
@@ -384,13 +403,13 @@ function createRateLimiter(options = {}) {
384
403
  statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
385
404
  keyGenerator = (req) => {
386
405
  const ip = req.ip ?? req.socket?.remoteAddress;
387
- if (!ip) {
388
- console.warn(
389
- "[arcis] Rate limiter: cannot resolve client IP. All unresolvable clients share one counter. Set Express trust proxy if behind a reverse proxy."
390
- );
391
- return "unknown";
392
- }
393
- return ip;
406
+ if (ip) return ip;
407
+ const ua = req.headers["user-agent"] ?? "";
408
+ const lang = req.headers["accept-language"] ?? "";
409
+ const fp = `${ua}|${lang}`;
410
+ let hash = 0;
411
+ for (let i = 0; i < fp.length; i++) hash = (hash << 5) - hash + fp.charCodeAt(i) | 0;
412
+ return `unknown:${hash.toString(36)}`;
394
413
  },
395
414
  skip,
396
415
  store: externalStore
@@ -453,7 +472,24 @@ function createRateLimiter(options = {}) {
453
472
  }
454
473
  next();
455
474
  } catch (error) {
456
- console.error("[arcis] Rate limiter error:", error);
475
+ console.error("[arcis] Rate limiter store error, using in-memory fallback:", error);
476
+ try {
477
+ const key = keyGenerator(req);
478
+ const now = Date.now();
479
+ if (!inMemoryStore[key] || inMemoryStore[key].resetTime < now) {
480
+ inMemoryStore[key] = { count: 1, resetTime: now + windowMs };
481
+ } else {
482
+ inMemoryStore[key].count++;
483
+ }
484
+ const count = inMemoryStore[key].count;
485
+ if (count > max) {
486
+ const resetSeconds = Math.ceil((inMemoryStore[key].resetTime - now) / 1e3);
487
+ res.setHeader("Retry-After", resetSeconds.toString());
488
+ res.status(statusCode).json({ error: message, retryAfter: resetSeconds });
489
+ return;
490
+ }
491
+ } catch {
492
+ }
457
493
  next();
458
494
  }
459
495
  };
@@ -585,6 +621,80 @@ var SanitizationError = class extends ArcisError {
585
621
  }
586
622
  };
587
623
 
624
+ // src/middleware/telemetry.ts
625
+ var THREAT_TO_VECTOR = {
626
+ xss: "xss",
627
+ sql_injection: "sql",
628
+ nosql_injection: "nosql",
629
+ path_traversal: "path",
630
+ command_injection: "command",
631
+ prototype_pollution: "prototype",
632
+ header_injection: "header",
633
+ ssti: "ssti",
634
+ xxe: "xxe"
635
+ };
636
+ function createTelemetryEmitter(client) {
637
+ return (req, res, next) => {
638
+ const start = performance.now();
639
+ res.on("finish", () => {
640
+ try {
641
+ const event = buildEvent(req, res.statusCode, performance.now() - start);
642
+ client.record(event);
643
+ } catch {
644
+ }
645
+ });
646
+ next();
647
+ };
648
+ }
649
+ function tapSanitizerThreats(handler) {
650
+ return (req, res, next) => {
651
+ handler(req, res, (err) => {
652
+ if (err instanceof SecurityThreatError) {
653
+ const vector = THREAT_TO_VECTOR[err.threatType] ?? err.threatType;
654
+ req.__arcis = {
655
+ vector,
656
+ rule: `${vector}/match`,
657
+ severity: "high",
658
+ matchedPattern: err.pattern,
659
+ reason: err.message,
660
+ decision: "deny"
661
+ };
662
+ }
663
+ next(err);
664
+ });
665
+ };
666
+ }
667
+ function buildEvent(req, status, latencyMs) {
668
+ const marker = req.__arcis;
669
+ const decision = marker?.decision ?? inferDecision(status);
670
+ return {
671
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
672
+ ip: extractIp(req),
673
+ method: (req.method ?? "GET").toUpperCase(),
674
+ path: req.path ?? req.url ?? "/",
675
+ decision,
676
+ vector: marker?.vector ?? (status === 429 ? "rate-limit" : void 0),
677
+ rule: marker?.rule ?? (status === 429 ? "rate-limit/exceeded" : void 0),
678
+ severity: marker?.severity ?? (status === 429 ? "medium" : void 0),
679
+ userAgent: typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "",
680
+ reason: marker?.reason,
681
+ status,
682
+ matchedPattern: marker?.matchedPattern,
683
+ latencyMs: Math.max(0, latencyMs)
684
+ };
685
+ }
686
+ function inferDecision(status) {
687
+ if (status === 429) return "deny";
688
+ if (status === 400) return "deny";
689
+ if (status === 403) return "deny";
690
+ return "allow";
691
+ }
692
+ function extractIp(req) {
693
+ if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
694
+ const remote = req.socket?.remoteAddress;
695
+ return typeof remote === "string" ? remote : "0.0.0.0";
696
+ }
697
+
588
698
  // src/sanitizers/utils.ts
589
699
  function encodeHtmlEntities(str) {
590
700
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
@@ -697,26 +807,31 @@ function sanitizePath(input, collectThreats = false) {
697
807
  const threats = [];
698
808
  let value = input;
699
809
  let wasSanitized = false;
700
- for (const pattern of PATH_PATTERNS) {
701
- pattern.lastIndex = 0;
702
- if (pattern.test(value)) {
810
+ value = value.normalize("NFKC");
811
+ let prev;
812
+ do {
813
+ prev = value;
814
+ for (const pattern of PATH_PATTERNS) {
703
815
  pattern.lastIndex = 0;
704
- if (collectThreats) {
705
- const matches = value.match(pattern);
706
- if (matches) {
707
- for (const match of matches) {
708
- threats.push({
709
- type: "path_traversal",
710
- pattern: pattern.source,
711
- original: match
712
- });
816
+ if (pattern.test(value)) {
817
+ pattern.lastIndex = 0;
818
+ if (collectThreats) {
819
+ const matches = value.match(pattern);
820
+ if (matches) {
821
+ for (const match of matches) {
822
+ threats.push({
823
+ type: "path_traversal",
824
+ pattern: pattern.source,
825
+ original: match
826
+ });
827
+ }
713
828
  }
714
829
  }
830
+ value = value.replace(pattern, "");
831
+ wasSanitized = true;
715
832
  }
716
- value = value.replace(pattern, "");
717
- wasSanitized = true;
718
833
  }
719
- }
834
+ } while (value !== prev);
720
835
  if (collectThreats) {
721
836
  return { value, wasSanitized, threats };
722
837
  }
@@ -724,9 +839,10 @@ function sanitizePath(input, collectThreats = false) {
724
839
  }
725
840
  function detectPathTraversal(input) {
726
841
  if (typeof input !== "string") return false;
842
+ const normalized = input.normalize("NFKC");
727
843
  for (const pattern of PATH_PATTERNS) {
728
844
  pattern.lastIndex = 0;
729
- if (pattern.test(input)) {
845
+ if (pattern.test(normalized)) {
730
846
  return true;
731
847
  }
732
848
  }
@@ -784,7 +900,7 @@ function sanitizeString(value, options = {}) {
784
900
  if (value.length > maxSize) {
785
901
  throw new InputTooLargeError(maxSize, value.length);
786
902
  }
787
- const reject = options.mode !== "sanitize";
903
+ const reject = options.mode === "reject";
788
904
  let result = value;
789
905
  if (options.sql !== false) {
790
906
  if (reject) {
@@ -933,10 +1049,24 @@ var SSTI_DETECT_PATTERNS = [
933
1049
  /\{\{\s*(?:self|request|lipsum|cycler|joiner|namespace|range)\b/gi
934
1050
  ];
935
1051
  var SSTI_REMOVE_PATTERNS = [
1052
+ /** Jinja2 / Twig: {{ ... }} — always strip (not valid in any JS context) */
936
1053
  /\{\{.*?\}\}/g,
937
- /\$\{.*?\}/g,
1054
+ /**
1055
+ * Freemarker / Spring EL: ${...} — strip when expression contains operators,
1056
+ * method calls, or Python dunder patterns (sandbox escape).
1057
+ * Bare ${name} and ${user.name} are left intact (JS template literal syntax).
1058
+ */
1059
+ /\$\{[^}]*__\w+__[^}]*\}/g,
1060
+ /\$\{[^}]*[?!()*+\-/][^}]*\}/g,
1061
+ /** ERB / EJS: <%= ... %> */
938
1062
  /<%[=\-]?.*?%>/gs,
939
- /#\{.*?\}/g,
1063
+ /**
1064
+ * Pug / Jade: #{...} — same narrowing as ${ above, plus dunder detection.
1065
+ * #{name} output expressions are left intact.
1066
+ */
1067
+ /#\{[^}]*__\w+__[^}]*\}/g,
1068
+ /#\{[^}]*[?!()*+\-/][^}]*\}/g,
1069
+ /** Python dunder sandbox escape — always strip */
940
1070
  /__(?:class|mro|subclasses|globals|builtins|import)__/gi
941
1071
  ];
942
1072
  function sanitizeSsti(input, collectThreats = false) {
@@ -983,6 +1113,8 @@ function detectSsti(input) {
983
1113
  }
984
1114
 
985
1115
  // src/sanitizers/xxe.ts
1116
+ var MAX_XXE_INPUT_BYTES = 1e6;
1117
+ var MAX_ENTITY_REFERENCES = 64;
986
1118
  var XXE_DETECT_PATTERNS = [
987
1119
  /** DOCTYPE declaration */
988
1120
  /<!DOCTYPE\b/gi,
@@ -1012,6 +1144,19 @@ function sanitizeXxe(input, collectThreats = false) {
1012
1144
  const threats = [];
1013
1145
  let value = input;
1014
1146
  let wasSanitized = false;
1147
+ if (value.length > MAX_XXE_INPUT_BYTES) {
1148
+ if (collectThreats) {
1149
+ threats.push({ type: "xxe", pattern: "oversize_input", original: `length=${value.length}` });
1150
+ }
1151
+ return collectThreats ? { value: "", wasSanitized: true, threats } : "";
1152
+ }
1153
+ const entityRefs = value.match(/&\w+;/g);
1154
+ if (entityRefs && entityRefs.length > MAX_ENTITY_REFERENCES) {
1155
+ if (collectThreats) {
1156
+ threats.push({ type: "xxe", pattern: "entity_expansion", original: `count=${entityRefs.length}` });
1157
+ }
1158
+ return collectThreats ? { value: "", wasSanitized: true, threats } : "";
1159
+ }
1015
1160
  for (const pattern of XXE_REMOVE_PATTERNS) {
1016
1161
  pattern.lastIndex = 0;
1017
1162
  if (pattern.test(value)) {
@@ -1049,12 +1194,10 @@ function detectXxe(input) {
1049
1194
  }
1050
1195
 
1051
1196
  // src/sanitizers/jsonp.ts
1052
- var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.[\]]*$/;
1197
+ var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.]*$/;
1053
1198
  var DANGEROUS_CALLBACK_PATTERNS = [
1054
- /\.\./,
1199
+ /\.\./
1055
1200
  // prototype chain traversal
1056
- /\[\s*\]/
1057
- // empty bracket access
1058
1201
  ];
1059
1202
  function sanitizeJsonpCallback(callback, maxLength = 128) {
1060
1203
  if (typeof callback !== "string" || callback.length === 0) {
@@ -1139,7 +1282,7 @@ function detectHeaderInjection(input) {
1139
1282
 
1140
1283
  // src/sanitizers/pii.ts
1141
1284
  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;
1142
- var PHONE_RE = /(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
1285
+ var PHONE_RE = /(?<!\d)(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\d)/g;
1143
1286
  var CREDIT_CARD_RE = /\b(?:\d[ -]*?){13,19}\b/g;
1144
1287
  var SSN_RE = /\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g;
1145
1288
  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;
@@ -1300,16 +1443,18 @@ function encodeForAttribute(value) {
1300
1443
  function encodeForJs(value) {
1301
1444
  if (!value) return "";
1302
1445
  let result = "";
1303
- for (let i = 0; i < value.length; i++) {
1304
- const ch = value.charCodeAt(i);
1305
- if (ch >= 48 && ch <= 57 || // 0-9
1306
- ch >= 65 && ch <= 90 || // A-Z
1307
- ch >= 97 && ch <= 122) {
1308
- result += value[i];
1309
- } else if (ch < 256) {
1310
- result += `\\x${ch.toString(16).toUpperCase().padStart(2, "0")}`;
1446
+ for (const char of value) {
1447
+ const cp = char.codePointAt(0);
1448
+ if (cp >= 48 && cp <= 57 || // 0-9
1449
+ cp >= 65 && cp <= 90 || // A-Z
1450
+ cp >= 97 && cp <= 122) {
1451
+ result += char;
1452
+ } else if (cp < 256) {
1453
+ result += `\\x${cp.toString(16).toUpperCase().padStart(2, "0")}`;
1454
+ } else if (cp <= 65535) {
1455
+ result += `\\u${cp.toString(16).toUpperCase().padStart(4, "0")}`;
1311
1456
  } else {
1312
- result += `\\u${ch.toString(16).toUpperCase().padStart(4, "0")}`;
1457
+ result += `\\u{${cp.toString(16).toUpperCase()}}`;
1313
1458
  }
1314
1459
  }
1315
1460
  return result;
@@ -1762,8 +1907,12 @@ function checkPrivateIp(hostname) {
1762
1907
  if (hostname === "metadata.google.internal" || hostname === "metadata.internal" || hostname === "metadata.azure.internal") {
1763
1908
  return "cloud metadata endpoint";
1764
1909
  }
1765
- const ipv6 = hostname.replace(/^\[|\]$/g, "");
1766
- if (ipv6 === "::1" || ipv6 === "::" || ipv6.startsWith("fc") || ipv6.startsWith("fd") || ipv6.startsWith("fe80")) {
1910
+ let ipv6 = hostname.replace(/^\[|\]$/g, "");
1911
+ const zoneIdx = ipv6.indexOf("%");
1912
+ if (zoneIdx !== -1) {
1913
+ ipv6 = ipv6.slice(0, zoneIdx);
1914
+ }
1915
+ 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)) {
1767
1916
  return "private IPv6 address";
1768
1917
  }
1769
1918
  const mappedDotted = ipv6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
@@ -2257,10 +2406,192 @@ function createRedactor(sensitiveKeys = []) {
2257
2406
  }
2258
2407
  var safeLog = createSafeLogger;
2259
2408
 
2409
+ // src/telemetry/client.ts
2410
+ var DEFAULT_BATCH_SIZE = 50;
2411
+ var MAX_BATCH_SIZE = 500;
2412
+ var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
2413
+ var MIN_FLUSH_INTERVAL_MS = 500;
2414
+ var FLUSH_TIMEOUT_MS = 1e4;
2415
+ var TelemetryClient = class {
2416
+ constructor(options) {
2417
+ this.queue = [];
2418
+ this.flushing = false;
2419
+ this.closed = false;
2420
+ if (!options.endpoint || typeof options.endpoint !== "string") {
2421
+ throw new TypeError("TelemetryClient: `endpoint` is required");
2422
+ }
2423
+ this.endpoint = options.endpoint;
2424
+ this.apiKey = options.apiKey;
2425
+ this.workspaceId = options.workspaceId;
2426
+ this.batchSize = clamp(
2427
+ options.batchSize ?? DEFAULT_BATCH_SIZE,
2428
+ 1,
2429
+ MAX_BATCH_SIZE
2430
+ );
2431
+ this.flushIntervalMs = Math.max(
2432
+ options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
2433
+ MIN_FLUSH_INTERVAL_MS
2434
+ );
2435
+ this.onError = options.onError ?? (() => {
2436
+ });
2437
+ this.startTimer();
2438
+ }
2439
+ /**
2440
+ * Enqueue an event. Fast, synchronous, cannot throw.
2441
+ * Triggers a flush if the queue has reached `batchSize`.
2442
+ */
2443
+ record(event) {
2444
+ if (this.closed) return;
2445
+ this.queue.push(event);
2446
+ if (this.queue.length >= this.batchSize) {
2447
+ void this.flush();
2448
+ }
2449
+ }
2450
+ /**
2451
+ * Manually flush the queue. Pulls up to `batchSize` events into a batch and
2452
+ * POSTs them. Returns a resolved promise on success OR on handled failure.
2453
+ * Never throws.
2454
+ */
2455
+ async flush() {
2456
+ if (this.flushing) return;
2457
+ if (this.queue.length === 0) return;
2458
+ this.flushing = true;
2459
+ try {
2460
+ const batch = this.queue.splice(0, this.batchSize);
2461
+ await this.send(batch);
2462
+ } catch (err) {
2463
+ this.safeNotify(err);
2464
+ } finally {
2465
+ this.flushing = false;
2466
+ }
2467
+ if (!this.closed && this.queue.length > 0) {
2468
+ void this.flush();
2469
+ }
2470
+ }
2471
+ /**
2472
+ * Shut down: stop the interval timer and attempt one final flush.
2473
+ * Safe to call multiple times.
2474
+ */
2475
+ async close() {
2476
+ if (this.closed) return;
2477
+ this.closed = true;
2478
+ if (this.timer !== void 0) {
2479
+ clearInterval(this.timer);
2480
+ this.timer = void 0;
2481
+ }
2482
+ if (this.signalHandler !== void 0) {
2483
+ process.off("SIGTERM", this.signalHandler);
2484
+ process.off("SIGINT", this.signalHandler);
2485
+ this.signalHandler = void 0;
2486
+ }
2487
+ try {
2488
+ await this.flush();
2489
+ } catch {
2490
+ }
2491
+ }
2492
+ /**
2493
+ * Register `SIGTERM` / `SIGINT` handlers that call `close()` to drain
2494
+ * the queue on graceful shutdown. Opt-in — libraries should not silently
2495
+ * attach global signal handlers. Safe to call multiple times.
2496
+ */
2497
+ installShutdownHooks() {
2498
+ if (this.signalHandler !== void 0 || this.closed) return;
2499
+ const handler = () => {
2500
+ void this.close();
2501
+ };
2502
+ this.signalHandler = handler;
2503
+ process.once("SIGTERM", handler);
2504
+ process.once("SIGINT", handler);
2505
+ }
2506
+ /** Count of events currently waiting to be sent. Useful for tests. */
2507
+ get pendingCount() {
2508
+ return this.queue.length;
2509
+ }
2510
+ // ── internals ─────────────────────────────────────────────────────────
2511
+ async send(batch) {
2512
+ const headers = {
2513
+ "content-type": "application/json"
2514
+ };
2515
+ if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
2516
+ if (this.workspaceId) headers["x-workspace-id"] = this.workspaceId;
2517
+ const controller = new AbortController();
2518
+ const abortTimer = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
2519
+ try {
2520
+ const res = await fetch(this.endpoint, {
2521
+ method: "POST",
2522
+ headers,
2523
+ body: JSON.stringify({ events: batch }),
2524
+ signal: controller.signal
2525
+ });
2526
+ if (!res.ok) {
2527
+ const text = await safeReadBody(res);
2528
+ throw new TelemetryHttpError(res.status, text);
2529
+ }
2530
+ } finally {
2531
+ clearTimeout(abortTimer);
2532
+ }
2533
+ }
2534
+ startTimer() {
2535
+ this.timer = setInterval(() => {
2536
+ void this.flush();
2537
+ }, this.flushIntervalMs);
2538
+ this.timer.unref?.();
2539
+ }
2540
+ safeNotify(err) {
2541
+ try {
2542
+ this.onError(err instanceof Error ? err : new Error(String(err)));
2543
+ } catch {
2544
+ }
2545
+ }
2546
+ };
2547
+ var TelemetryHttpError = class extends Error {
2548
+ constructor(status, responseBody) {
2549
+ super(`Telemetry ingest returned HTTP ${status}`);
2550
+ this.status = status;
2551
+ this.responseBody = responseBody;
2552
+ this.name = "TelemetryHttpError";
2553
+ }
2554
+ };
2555
+ function clamp(value, min, max) {
2556
+ if (!Number.isFinite(value)) return min;
2557
+ return Math.max(min, Math.min(max, Math.trunc(value)));
2558
+ }
2559
+ async function safeReadBody(res) {
2560
+ try {
2561
+ const text = await res.text();
2562
+ return text.slice(0, 500);
2563
+ } catch {
2564
+ return "";
2565
+ }
2566
+ }
2567
+
2260
2568
  // src/middleware/main.ts
2569
+ function buildTelemetryFromEnv() {
2570
+ const env = typeof process !== "undefined" ? process.env : void 0;
2571
+ const endpoint = env?.ARCIS_ENDPOINT;
2572
+ if (!endpoint) return void 0;
2573
+ const opts = { endpoint };
2574
+ if (env?.ARCIS_WORKSPACE_ID) opts.workspaceId = env.ARCIS_WORKSPACE_ID;
2575
+ if (env?.ARCIS_KEY) opts.apiKey = env.ARCIS_KEY;
2576
+ const batch = env?.ARCIS_BATCH_SIZE ? parseInt(env.ARCIS_BATCH_SIZE, 10) : NaN;
2577
+ if (!Number.isNaN(batch)) opts.batchSize = batch;
2578
+ const flush = env?.ARCIS_FLUSH_INTERVAL_MS ? parseInt(env.ARCIS_FLUSH_INTERVAL_MS, 10) : NaN;
2579
+ if (!Number.isNaN(flush)) opts.flushIntervalMs = flush;
2580
+ return opts;
2581
+ }
2261
2582
  function arcis(options = {}) {
2262
2583
  const middlewares = [];
2263
2584
  const cleanupFns = [];
2585
+ let telemetryClient;
2586
+ const telemetryOpts = options.telemetry?.endpoint ? options.telemetry : buildTelemetryFromEnv();
2587
+ if (telemetryOpts) {
2588
+ const client = new TelemetryClient(telemetryOpts);
2589
+ telemetryClient = client;
2590
+ middlewares.push(createTelemetryEmitter(client));
2591
+ cleanupFns.push(() => {
2592
+ void client.close();
2593
+ });
2594
+ }
2264
2595
  if (options.headers !== false) {
2265
2596
  const headerOpts = typeof options.headers === "object" ? options.headers : {};
2266
2597
  middlewares.push(createHeaders(headerOpts));
@@ -2273,7 +2604,8 @@ function arcis(options = {}) {
2273
2604
  }
2274
2605
  if (options.sanitize !== false) {
2275
2606
  const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
2276
- middlewares.push(createSanitizer(sanitizeOpts));
2607
+ const sanitizer = createSanitizer(sanitizeOpts);
2608
+ middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
2277
2609
  }
2278
2610
  const result = middlewares;
2279
2611
  result.close = () => {
@@ -2600,6 +2932,16 @@ function secureCookieDefaults(options = {}) {
2600
2932
  sameSite: options.sameSite ?? "Lax",
2601
2933
  path: options.path
2602
2934
  };
2935
+ if (resolved.sameSite === "None" && resolved.secure === false) {
2936
+ throw new Error(
2937
+ "[arcis] secureCookieDefaults: sameSite=None requires secure=true (modern browsers reject the cookie otherwise)"
2938
+ );
2939
+ }
2940
+ if (resolved.httpOnly === false && resolved.secure === false && isProduction) {
2941
+ console.warn(
2942
+ "[arcis] secureCookieDefaults: httpOnly and secure are both disabled in production \u2014 cookies will be readable by JS and sent over HTTP"
2943
+ );
2944
+ }
2603
2945
  return (_req, res, next) => {
2604
2946
  const originalSetHeader = res.setHeader.bind(res);
2605
2947
  res.setHeader = function patchedSetHeader(name, value) {
@@ -2732,7 +3074,16 @@ function detectBehavioralSignals(req) {
2732
3074
  }
2733
3075
  function detectBot(req) {
2734
3076
  const rawUa = req.headers["user-agent"] ?? "";
2735
- const ua = rawUa.length > 2048 ? rawUa.slice(0, 2048) : rawUa;
3077
+ if (rawUa.length > 2048) {
3078
+ return {
3079
+ isBot: true,
3080
+ category: "UNKNOWN",
3081
+ name: null,
3082
+ confidence: 0.9,
3083
+ signals: detectBehavioralSignals(req)
3084
+ };
3085
+ }
3086
+ const ua = rawUa;
2736
3087
  const signals = detectBehavioralSignals(req);
2737
3088
  if (!ua) {
2738
3089
  return {
@@ -2822,11 +3173,9 @@ function generateCsrfToken(length = 32) {
2822
3173
  function validateCsrfToken(cookieToken, requestToken) {
2823
3174
  if (!cookieToken || !requestToken) return false;
2824
3175
  if (cookieToken.length !== requestToken.length) return false;
2825
- let result = 0;
2826
- for (let i = 0; i < cookieToken.length; i++) {
2827
- result |= cookieToken.charCodeAt(i) ^ requestToken.charCodeAt(i);
2828
- }
2829
- return result === 0;
3176
+ const a = Buffer.from(cookieToken);
3177
+ const b = Buffer.from(requestToken);
3178
+ return crypto.timingSafeEqual(a, b);
2830
3179
  }
2831
3180
  function getRequestToken(req, headerName, fieldName) {
2832
3181
  const headerToken = req.headers[headerName.toLowerCase()];
@@ -2835,10 +3184,6 @@ function getRequestToken(req, headerName, fieldName) {
2835
3184
  const bodyToken = req.body[fieldName];
2836
3185
  if (typeof bodyToken === "string" && bodyToken) return bodyToken;
2837
3186
  }
2838
- if (req.query && fieldName in req.query) {
2839
- const queryToken = req.query[fieldName];
2840
- if (typeof queryToken === "string" && queryToken) return queryToken;
2841
- }
2842
3187
  return void 0;
2843
3188
  }
2844
3189
  function csrfProtection(options = {}) {
@@ -2902,6 +3247,10 @@ function csrfProtection(options = {}) {
2902
3247
  if (!validateCsrfToken(cookieToken, requestToken)) {
2903
3248
  return onError(req, res, next);
2904
3249
  }
3250
+ if (options.rotateOnUse) {
3251
+ const freshToken = generateCsrfToken(tokenLength);
3252
+ setCsrfCookie(res, cookieName, freshToken, cookieOpts);
3253
+ }
2905
3254
  next();
2906
3255
  };
2907
3256
  }
@@ -2921,7 +3270,15 @@ function setCsrfCookie(res, name, token, opts) {
2921
3270
  if (opts.secure) parts.push("Secure");
2922
3271
  parts.push(`SameSite=${opts.sameSite}`);
2923
3272
  if (opts.domain) parts.push(`Domain=${opts.domain}`);
2924
- res.setHeader("Set-Cookie", parts.join("; "));
3273
+ const newCookie = parts.join("; ");
3274
+ const existing = res.getHeader("Set-Cookie");
3275
+ if (existing === void 0) {
3276
+ res.setHeader("Set-Cookie", newCookie);
3277
+ } else if (Array.isArray(existing)) {
3278
+ res.setHeader("Set-Cookie", [...existing, newCookie]);
3279
+ } else {
3280
+ res.setHeader("Set-Cookie", [existing, newCookie]);
3281
+ }
2925
3282
  }
2926
3283
  function escapeRegex(str) {
2927
3284
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -2930,7 +3287,7 @@ var createCsrf = csrfProtection;
2930
3287
 
2931
3288
  // src/middleware/hpp.ts
2932
3289
  function hpp(options = {}) {
2933
- const whitelist = new Set(options.whitelist ?? []);
3290
+ const whitelist = new Set((options.whitelist ?? []).map((k) => k.toLowerCase()));
2934
3291
  const checkQuery = options.checkQuery ?? true;
2935
3292
  const checkBody = options.checkBody ?? true;
2936
3293
  return (req, _res, next) => {
@@ -2940,7 +3297,7 @@ function hpp(options = {}) {
2940
3297
  for (const [key, value] of Object.entries(req.query)) {
2941
3298
  if (Array.isArray(value)) {
2942
3299
  const strings = value.filter((v) => typeof v === "string");
2943
- if (whitelist.has(key)) {
3300
+ if (whitelist.has(key.toLowerCase())) {
2944
3301
  clean[key] = strings;
2945
3302
  } else {
2946
3303
  polluted[key] = strings;
@@ -2958,7 +3315,7 @@ function hpp(options = {}) {
2958
3315
  const clean = {};
2959
3316
  for (const [key, value] of Object.entries(req.body)) {
2960
3317
  if (Array.isArray(value)) {
2961
- if (whitelist.has(key)) {
3318
+ if (whitelist.has(key.toLowerCase())) {
2962
3319
  clean[key] = value;
2963
3320
  } else {
2964
3321
  polluted[key] = value;
@@ -2976,6 +3333,89 @@ function hpp(options = {}) {
2976
3333
  }
2977
3334
  var createHpp = hpp;
2978
3335
 
3336
+ // src/middleware/signup-protection.ts
3337
+ function checkSignup(req, options = {}) {
3338
+ const {
3339
+ emailField = "email",
3340
+ checkEmail = true,
3341
+ blockDisposable = true,
3342
+ checkBot = true,
3343
+ allowedBotCategories = [],
3344
+ allowedEmailDomains = [],
3345
+ blockedEmailDomains = []
3346
+ } = options;
3347
+ if (checkBot) {
3348
+ const bot = detectBot(req);
3349
+ if (bot.isBot && !allowedBotCategories.includes(bot.category)) {
3350
+ return {
3351
+ allowed: false,
3352
+ reason: "bot",
3353
+ details: { category: bot.category, name: bot.name, confidence: bot.confidence }
3354
+ };
3355
+ }
3356
+ }
3357
+ if (checkEmail) {
3358
+ const email = req.body?.[emailField];
3359
+ if (typeof email !== "string" || email.length === 0) {
3360
+ return { allowed: false, reason: "missing_email" };
3361
+ }
3362
+ const result = validateEmail(email, {
3363
+ checkDisposable: blockDisposable,
3364
+ allowedDomains: allowedEmailDomains,
3365
+ blockedDomains: blockedEmailDomains
3366
+ });
3367
+ if (!result.valid) {
3368
+ const reason = result.reason === "disposable" ? "disposable_email" : "invalid_email";
3369
+ return { allowed: false, reason, details: { emailReason: result.reason } };
3370
+ }
3371
+ }
3372
+ return { allowed: true, reason: "ok" };
3373
+ }
3374
+ function signupProtection(options = {}) {
3375
+ const rateLimitCfg = options.rateLimit;
3376
+ const limiter = rateLimitCfg === false ? null : createRateLimiter({
3377
+ max: rateLimitCfg?.max ?? 5,
3378
+ windowMs: rateLimitCfg?.windowMs ?? 6e4,
3379
+ message: "Too many signup attempts"
3380
+ });
3381
+ const handler = (req, res, next) => {
3382
+ const result = checkSignup(req, options);
3383
+ if (!result.allowed) {
3384
+ options.onBlocked?.(req, result);
3385
+ const status = result.reason === "bot" ? 403 : 400;
3386
+ res.status(status).json({ error: "signup_blocked", reason: result.reason });
3387
+ return;
3388
+ }
3389
+ if (limiter) {
3390
+ let rateLimited = false;
3391
+ const rateLimitNext = (err) => {
3392
+ if (err) return next(err);
3393
+ if (!rateLimited) next();
3394
+ };
3395
+ const patchedRes = new Proxy(res, {
3396
+ get(target, prop) {
3397
+ if (prop === "status") {
3398
+ return (code) => {
3399
+ if (code === 429) {
3400
+ rateLimited = true;
3401
+ options.onBlocked?.(req, { allowed: false, reason: "rate_limited" });
3402
+ }
3403
+ return target.status.call(target, code);
3404
+ };
3405
+ }
3406
+ return Reflect.get(target, prop);
3407
+ }
3408
+ });
3409
+ limiter(req, patchedRes, rateLimitNext);
3410
+ return;
3411
+ }
3412
+ next();
3413
+ };
3414
+ const middleware = handler;
3415
+ middleware.close = () => limiter?.close();
3416
+ return middleware;
3417
+ }
3418
+
2979
3419
  // src/utils/ip.ts
2980
3420
  var PLATFORM_HEADERS = {
2981
3421
  cloudflare: "cf-connecting-ip",
@@ -3105,8 +3545,9 @@ function fingerprint(req, options = {}) {
3105
3545
  }
3106
3546
 
3107
3547
  // src/stores/memory.ts
3548
+ var DEFAULT_MAX_SIZE2 = 1e4;
3108
3549
  var MemoryStore = class {
3109
- constructor(windowMs = RATE_LIMIT.DEFAULT_WINDOW_MS) {
3550
+ constructor(windowMs = RATE_LIMIT.DEFAULT_WINDOW_MS, maxSize = DEFAULT_MAX_SIZE2) {
3110
3551
  this.store = /* @__PURE__ */ new Map();
3111
3552
  this.cleanupInterval = null;
3112
3553
  if (!Number.isFinite(windowMs) || windowMs < RATE_LIMIT.MIN_WINDOW_MS) {
@@ -3114,7 +3555,11 @@ var MemoryStore = class {
3114
3555
  `MemoryStore: windowMs must be a finite number >= ${RATE_LIMIT.MIN_WINDOW_MS} (got ${windowMs})`
3115
3556
  );
3116
3557
  }
3558
+ if (!Number.isFinite(maxSize) || maxSize < 1) {
3559
+ throw new RangeError(`MemoryStore: maxSize must be >= 1 (got ${maxSize})`);
3560
+ }
3117
3561
  this.windowMs = windowMs;
3562
+ this.maxSize = maxSize;
3118
3563
  this.startCleanup();
3119
3564
  }
3120
3565
  /**
@@ -3146,18 +3591,33 @@ var MemoryStore = class {
3146
3591
  return entry;
3147
3592
  }
3148
3593
  async set(key, entry) {
3594
+ if (!this.store.has(key) && this.store.size >= this.maxSize) {
3595
+ this.evictExpired();
3596
+ if (this.store.size >= this.maxSize) return;
3597
+ }
3149
3598
  this.store.set(key, entry);
3150
3599
  }
3151
3600
  async increment(key) {
3152
3601
  const now = Date.now();
3153
3602
  const entry = this.store.get(key);
3154
3603
  if (!entry || entry.resetTime < now) {
3604
+ if (this.store.size >= this.maxSize) {
3605
+ this.evictExpired();
3606
+ if (this.store.size >= this.maxSize) return 1;
3607
+ }
3155
3608
  this.store.set(key, { count: 1, resetTime: now + this.windowMs });
3156
3609
  return 1;
3157
3610
  }
3158
3611
  entry.count++;
3159
3612
  return entry.count;
3160
3613
  }
3614
+ /** Eagerly remove expired entries to reclaim capacity. */
3615
+ evictExpired() {
3616
+ const now = Date.now();
3617
+ for (const [key, entry] of this.store.entries()) {
3618
+ if (entry.resetTime < now) this.store.delete(key);
3619
+ }
3620
+ }
3161
3621
  async decrement(key) {
3162
3622
  const entry = this.store.get(key);
3163
3623
  if (entry && entry.count > 0) {
@@ -3253,10 +3713,13 @@ exports.RateLimitError = RateLimitError;
3253
3713
  exports.RedisStore = RedisStore;
3254
3714
  exports.SanitizationError = SanitizationError;
3255
3715
  exports.SecurityThreatError = SecurityThreatError;
3716
+ exports.TelemetryClient = TelemetryClient;
3717
+ exports.TelemetryHttpError = TelemetryHttpError;
3256
3718
  exports.VALIDATION = VALIDATION;
3257
3719
  exports.arcis = arcis;
3258
3720
  exports.arcisFunction = arcisWithMethods;
3259
3721
  exports.botProtection = botProtection;
3722
+ exports.checkSignup = checkSignup;
3260
3723
  exports.createCors = createCors;
3261
3724
  exports.createCsrf = createCsrf;
3262
3725
  exports.createErrorHandler = createErrorHandler;
@@ -3326,6 +3789,7 @@ exports.scanObjectPii = scanObjectPii;
3326
3789
  exports.scanPii = scanPii;
3327
3790
  exports.secureCookieDefaults = secureCookieDefaults;
3328
3791
  exports.securityHeaders = securityHeaders;
3792
+ exports.signupProtection = signupProtection;
3329
3793
  exports.validate = validate;
3330
3794
  exports.validateCsrfToken = validateCsrfToken;
3331
3795
  exports.validateEmail = validateEmail;