@arcis/node 1.4.0 → 1.4.2

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 (42) 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/index.js +125 -46
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +126 -47
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/logging/index.js.map +1 -1
  13. package/dist/logging/index.mjs.map +1 -1
  14. package/dist/middleware/csrf.d.ts.map +1 -1
  15. package/dist/middleware/index.js +62 -30
  16. package/dist/middleware/index.js.map +1 -1
  17. package/dist/middleware/index.mjs +63 -31
  18. package/dist/middleware/index.mjs.map +1 -1
  19. package/dist/middleware/rate-limit.d.ts.map +1 -1
  20. package/dist/sanitizers/encode.d.ts.map +1 -1
  21. package/dist/sanitizers/index.d.ts +1 -0
  22. package/dist/sanitizers/index.d.ts.map +1 -1
  23. package/dist/sanitizers/index.js +90 -32
  24. package/dist/sanitizers/index.js.map +1 -1
  25. package/dist/sanitizers/index.mjs +88 -33
  26. package/dist/sanitizers/index.mjs.map +1 -1
  27. package/dist/sanitizers/ldap.d.ts +42 -0
  28. package/dist/sanitizers/ldap.d.ts.map +1 -0
  29. package/dist/sanitizers/path.d.ts.map +1 -1
  30. package/dist/sanitizers/sanitize.d.ts.map +1 -1
  31. package/dist/sanitizers/ssti.d.ts.map +1 -1
  32. package/dist/stores/index.js +21 -1
  33. package/dist/stores/index.js.map +1 -1
  34. package/dist/stores/index.mjs +21 -1
  35. package/dist/stores/index.mjs.map +1 -1
  36. package/dist/stores/memory.d.ts +4 -10
  37. package/dist/stores/memory.d.ts.map +1 -1
  38. package/dist/validation/index.js +38 -21
  39. package/dist/validation/index.js.map +1 -1
  40. package/dist/validation/index.mjs +38 -21
  41. package/dist/validation/index.mjs.map +1 -1
  42. package/package.json +2 -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,7 +66,15 @@ 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) */
@@ -93,7 +101,15 @@ var XSS_REMOVE_PATTERNS = [
93
101
  /javascript\s*:/gi,
94
102
  /vbscript\s*:/gi,
95
103
  /** data: URIs with HTML/script content */
96
- /data\s*:\s*text\/html[^>\s]*/gi
104
+ /data\s*:\s*text\/html[^>\s]*/gi,
105
+ /** form tag injection — phishing via action= redirection */
106
+ /<form[\s>][^>]*/gi,
107
+ /** meta tag injection — http-equiv refresh or CSP bypass */
108
+ /<meta[\s>][^>]*/gi,
109
+ /** base href hijacking */
110
+ /<base[\s>][^>]*/gi,
111
+ /** link tag injection — stylesheet or preload attacks */
112
+ /<link[\s>][^>]*/gi
97
113
  ];
98
114
  var SQL_PATTERNS = [
99
115
  /** SQL keywords */
@@ -157,8 +173,8 @@ var COMMAND_PATTERNS = [
157
173
  /[;&|`]/g,
158
174
  /** Command substitution: $( ... ) — matched as a pair to reduce false positives */
159
175
  /\$\(/g,
160
- /** URL-encoded newline/carriage-return injection (%0a, %0d) */
161
- /%0[ad]/gi
176
+ /** URL-encoded control characters (%00-%0F): null, tab, vtab, formfeed, LF, CR */
177
+ /%0[0-9a-f]/gi
162
178
  ];
163
179
  var DANGEROUS_PROTO_KEYS = /* @__PURE__ */ new Set([
164
180
  "__proto__",
@@ -449,7 +465,24 @@ function createRateLimiter(options = {}) {
449
465
  }
450
466
  next();
451
467
  } catch (error) {
452
- console.error("[arcis] Rate limiter error:", error);
468
+ console.error("[arcis] Rate limiter store error, using in-memory fallback:", error);
469
+ try {
470
+ const key = keyGenerator(req);
471
+ const now = Date.now();
472
+ if (!inMemoryStore[key] || inMemoryStore[key].resetTime < now) {
473
+ inMemoryStore[key] = { count: 1, resetTime: now + windowMs };
474
+ } else {
475
+ inMemoryStore[key].count++;
476
+ }
477
+ const count = inMemoryStore[key].count;
478
+ if (count > max) {
479
+ const resetSeconds = Math.ceil((inMemoryStore[key].resetTime - now) / 1e3);
480
+ res.setHeader("Retry-After", resetSeconds.toString());
481
+ res.status(statusCode).json({ error: message, retryAfter: resetSeconds });
482
+ return;
483
+ }
484
+ } catch {
485
+ }
453
486
  next();
454
487
  }
455
488
  };
@@ -693,26 +726,31 @@ function sanitizePath(input, collectThreats = false) {
693
726
  const threats = [];
694
727
  let value = input;
695
728
  let wasSanitized = false;
696
- for (const pattern of PATH_PATTERNS) {
697
- pattern.lastIndex = 0;
698
- if (pattern.test(value)) {
729
+ value = value.normalize("NFKC");
730
+ let prev;
731
+ do {
732
+ prev = value;
733
+ for (const pattern of PATH_PATTERNS) {
699
734
  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
- });
735
+ if (pattern.test(value)) {
736
+ pattern.lastIndex = 0;
737
+ if (collectThreats) {
738
+ const matches = value.match(pattern);
739
+ if (matches) {
740
+ for (const match of matches) {
741
+ threats.push({
742
+ type: "path_traversal",
743
+ pattern: pattern.source,
744
+ original: match
745
+ });
746
+ }
709
747
  }
710
748
  }
749
+ value = value.replace(pattern, "");
750
+ wasSanitized = true;
711
751
  }
712
- value = value.replace(pattern, "");
713
- wasSanitized = true;
714
752
  }
715
- }
753
+ } while (value !== prev);
716
754
  if (collectThreats) {
717
755
  return { value, wasSanitized, threats };
718
756
  }
@@ -720,9 +758,10 @@ function sanitizePath(input, collectThreats = false) {
720
758
  }
721
759
  function detectPathTraversal(input) {
722
760
  if (typeof input !== "string") return false;
761
+ const normalized = input.normalize("NFKC");
723
762
  for (const pattern of PATH_PATTERNS) {
724
763
  pattern.lastIndex = 0;
725
- if (pattern.test(input)) {
764
+ if (pattern.test(normalized)) {
726
765
  return true;
727
766
  }
728
767
  }
@@ -780,7 +819,7 @@ function sanitizeString(value, options = {}) {
780
819
  if (value.length > maxSize) {
781
820
  throw new InputTooLargeError(maxSize, value.length);
782
821
  }
783
- const reject = options.mode !== "sanitize";
822
+ const reject = options.mode === "reject";
784
823
  let result = value;
785
824
  if (options.sql !== false) {
786
825
  if (reject) {
@@ -929,10 +968,22 @@ var SSTI_DETECT_PATTERNS = [
929
968
  /\{\{\s*(?:self|request|lipsum|cycler|joiner|namespace|range)\b/gi
930
969
  ];
931
970
  var SSTI_REMOVE_PATTERNS = [
971
+ /** Jinja2 / Twig: {{ ... }} — always strip (not valid in any JS context) */
932
972
  /\{\{.*?\}\}/g,
933
- /\$\{.*?\}/g,
973
+ /**
974
+ * Freemarker / Spring EL: ${...} — only strip when the expression contains
975
+ * operators (?!*+-/), method calls (), or known-dangerous prefixes.
976
+ * Bare ${name} and ${user.name} are left intact (JS template literal syntax).
977
+ */
978
+ /\$\{[^}]*[?!()*+\-/][^}]*\}/g,
979
+ /** ERB / EJS: <%= ... %> */
934
980
  /<%[=\-]?.*?%>/gs,
935
- /#\{.*?\}/g,
981
+ /**
982
+ * Pug / Jade: #{...} — same narrowing as ${ above.
983
+ * #{name} output expressions are left intact.
984
+ */
985
+ /#\{[^}]*[?!()*+\-/][^}]*\}/g,
986
+ /** Python dunder sandbox escape — always strip */
936
987
  /__(?:class|mro|subclasses|globals|builtins|import)__/gi
937
988
  ];
938
989
  function sanitizeSsti(input, collectThreats = false) {
@@ -1296,16 +1347,18 @@ function encodeForAttribute(value) {
1296
1347
  function encodeForJs(value) {
1297
1348
  if (!value) return "";
1298
1349
  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")}`;
1350
+ for (const char of value) {
1351
+ const cp = char.codePointAt(0);
1352
+ if (cp >= 48 && cp <= 57 || // 0-9
1353
+ cp >= 65 && cp <= 90 || // A-Z
1354
+ cp >= 97 && cp <= 122) {
1355
+ result += char;
1356
+ } else if (cp < 256) {
1357
+ result += `\\x${cp.toString(16).toUpperCase().padStart(2, "0")}`;
1358
+ } else if (cp <= 65535) {
1359
+ result += `\\u${cp.toString(16).toUpperCase().padStart(4, "0")}`;
1307
1360
  } else {
1308
- result += `\\u${ch.toString(16).toUpperCase().padStart(4, "0")}`;
1361
+ result += `\\u{${cp.toString(16).toUpperCase()}}`;
1309
1362
  }
1310
1363
  }
1311
1364
  return result;
@@ -1758,8 +1811,12 @@ function checkPrivateIp(hostname) {
1758
1811
  if (hostname === "metadata.google.internal" || hostname === "metadata.internal" || hostname === "metadata.azure.internal") {
1759
1812
  return "cloud metadata endpoint";
1760
1813
  }
1761
- const ipv6 = hostname.replace(/^\[|\]$/g, "");
1762
- if (ipv6 === "::1" || ipv6 === "::" || ipv6.startsWith("fc") || ipv6.startsWith("fd") || ipv6.startsWith("fe80")) {
1814
+ let ipv6 = hostname.replace(/^\[|\]$/g, "");
1815
+ const zoneIdx = ipv6.indexOf("%");
1816
+ if (zoneIdx !== -1) {
1817
+ ipv6 = ipv6.slice(0, zoneIdx);
1818
+ }
1819
+ 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
1820
  return "private IPv6 address";
1764
1821
  }
1765
1822
  const mappedDotted = ipv6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
@@ -2818,11 +2875,9 @@ function generateCsrfToken(length = 32) {
2818
2875
  function validateCsrfToken(cookieToken, requestToken) {
2819
2876
  if (!cookieToken || !requestToken) return false;
2820
2877
  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;
2878
+ const a = Buffer.from(cookieToken);
2879
+ const b = Buffer.from(requestToken);
2880
+ return timingSafeEqual(a, b);
2826
2881
  }
2827
2882
  function getRequestToken(req, headerName, fieldName) {
2828
2883
  const headerToken = req.headers[headerName.toLowerCase()];
@@ -2831,10 +2886,6 @@ function getRequestToken(req, headerName, fieldName) {
2831
2886
  const bodyToken = req.body[fieldName];
2832
2887
  if (typeof bodyToken === "string" && bodyToken) return bodyToken;
2833
2888
  }
2834
- if (req.query && fieldName in req.query) {
2835
- const queryToken = req.query[fieldName];
2836
- if (typeof queryToken === "string" && queryToken) return queryToken;
2837
- }
2838
2889
  return void 0;
2839
2890
  }
2840
2891
  function csrfProtection(options = {}) {
@@ -2917,7 +2968,15 @@ function setCsrfCookie(res, name, token, opts) {
2917
2968
  if (opts.secure) parts.push("Secure");
2918
2969
  parts.push(`SameSite=${opts.sameSite}`);
2919
2970
  if (opts.domain) parts.push(`Domain=${opts.domain}`);
2920
- res.setHeader("Set-Cookie", parts.join("; "));
2971
+ const newCookie = parts.join("; ");
2972
+ const existing = res.getHeader("Set-Cookie");
2973
+ if (existing === void 0) {
2974
+ res.setHeader("Set-Cookie", newCookie);
2975
+ } else if (Array.isArray(existing)) {
2976
+ res.setHeader("Set-Cookie", [...existing, newCookie]);
2977
+ } else {
2978
+ res.setHeader("Set-Cookie", [existing, newCookie]);
2979
+ }
2921
2980
  }
2922
2981
  function escapeRegex(str) {
2923
2982
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -3101,8 +3160,9 @@ function fingerprint(req, options = {}) {
3101
3160
  }
3102
3161
 
3103
3162
  // src/stores/memory.ts
3163
+ var DEFAULT_MAX_SIZE2 = 1e4;
3104
3164
  var MemoryStore = class {
3105
- constructor(windowMs = RATE_LIMIT.DEFAULT_WINDOW_MS) {
3165
+ constructor(windowMs = RATE_LIMIT.DEFAULT_WINDOW_MS, maxSize = DEFAULT_MAX_SIZE2) {
3106
3166
  this.store = /* @__PURE__ */ new Map();
3107
3167
  this.cleanupInterval = null;
3108
3168
  if (!Number.isFinite(windowMs) || windowMs < RATE_LIMIT.MIN_WINDOW_MS) {
@@ -3110,7 +3170,11 @@ var MemoryStore = class {
3110
3170
  `MemoryStore: windowMs must be a finite number >= ${RATE_LIMIT.MIN_WINDOW_MS} (got ${windowMs})`
3111
3171
  );
3112
3172
  }
3173
+ if (!Number.isFinite(maxSize) || maxSize < 1) {
3174
+ throw new RangeError(`MemoryStore: maxSize must be >= 1 (got ${maxSize})`);
3175
+ }
3113
3176
  this.windowMs = windowMs;
3177
+ this.maxSize = maxSize;
3114
3178
  this.startCleanup();
3115
3179
  }
3116
3180
  /**
@@ -3142,18 +3206,33 @@ var MemoryStore = class {
3142
3206
  return entry;
3143
3207
  }
3144
3208
  async set(key, entry) {
3209
+ if (!this.store.has(key) && this.store.size >= this.maxSize) {
3210
+ this.evictExpired();
3211
+ if (this.store.size >= this.maxSize) return;
3212
+ }
3145
3213
  this.store.set(key, entry);
3146
3214
  }
3147
3215
  async increment(key) {
3148
3216
  const now = Date.now();
3149
3217
  const entry = this.store.get(key);
3150
3218
  if (!entry || entry.resetTime < now) {
3219
+ if (this.store.size >= this.maxSize) {
3220
+ this.evictExpired();
3221
+ if (this.store.size >= this.maxSize) return 1;
3222
+ }
3151
3223
  this.store.set(key, { count: 1, resetTime: now + this.windowMs });
3152
3224
  return 1;
3153
3225
  }
3154
3226
  entry.count++;
3155
3227
  return entry.count;
3156
3228
  }
3229
+ /** Eagerly remove expired entries to reclaim capacity. */
3230
+ evictExpired() {
3231
+ const now = Date.now();
3232
+ for (const [key, entry] of this.store.entries()) {
3233
+ if (entry.resetTime < now) this.store.delete(key);
3234
+ }
3235
+ }
3157
3236
  async decrement(key) {
3158
3237
  const entry = this.store.get(key);
3159
3238
  if (entry && entry.count > 0) {