@arcis/node 1.4.2 → 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 (51) hide show
  1. package/dist/core/constants.d.ts +1 -1
  2. package/dist/core/constants.d.ts.map +1 -1
  3. package/dist/core/index.js.map +1 -1
  4. package/dist/core/index.mjs.map +1 -1
  5. package/dist/core/types.d.ts +6 -0
  6. package/dist/core/types.d.ts.map +1 -1
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +405 -20
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +402 -21
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/logging/index.js.map +1 -1
  14. package/dist/logging/index.mjs.map +1 -1
  15. package/dist/middleware/bot-detection.d.ts.map +1 -1
  16. package/dist/middleware/cookies.d.ts.map +1 -1
  17. package/dist/middleware/csrf.d.ts +10 -0
  18. package/dist/middleware/csrf.d.ts.map +1 -1
  19. package/dist/middleware/hpp.d.ts.map +1 -1
  20. package/dist/middleware/index.d.ts +2 -0
  21. package/dist/middleware/index.d.ts.map +1 -1
  22. package/dist/middleware/index.js +609 -9
  23. package/dist/middleware/index.js.map +1 -1
  24. package/dist/middleware/index.mjs +608 -10
  25. package/dist/middleware/index.mjs.map +1 -1
  26. package/dist/middleware/main.d.ts.map +1 -1
  27. package/dist/middleware/rate-limit.d.ts.map +1 -1
  28. package/dist/middleware/signup-protection.d.ts +65 -0
  29. package/dist/middleware/signup-protection.d.ts.map +1 -0
  30. package/dist/middleware/telemetry.d.ts +36 -0
  31. package/dist/middleware/telemetry.d.ts.map +1 -0
  32. package/dist/sanitizers/index.js +26 -8
  33. package/dist/sanitizers/index.js.map +1 -1
  34. package/dist/sanitizers/index.mjs +26 -8
  35. package/dist/sanitizers/index.mjs.map +1 -1
  36. package/dist/sanitizers/pii.d.ts.map +1 -1
  37. package/dist/sanitizers/ssti.d.ts.map +1 -1
  38. package/dist/sanitizers/xxe.d.ts.map +1 -1
  39. package/dist/stores/index.js.map +1 -1
  40. package/dist/stores/index.mjs.map +1 -1
  41. package/dist/telemetry/client.d.ts +60 -0
  42. package/dist/telemetry/client.d.ts.map +1 -0
  43. package/dist/telemetry/index.d.ts +3 -0
  44. package/dist/telemetry/index.d.ts.map +1 -0
  45. package/dist/telemetry/types.d.ts +59 -0
  46. package/dist/telemetry/types.d.ts.map +1 -0
  47. package/dist/validation/index.js +3 -0
  48. package/dist/validation/index.js.map +1 -1
  49. package/dist/validation/index.mjs +3 -0
  50. package/dist/validation/index.mjs.map +1 -1
  51. package/package.json +7 -1
package/dist/index.mjs CHANGED
@@ -81,6 +81,9 @@ var XSS_REMOVE_PATTERNS = [
81
81
  /<script[^>]*>[\s\S]*?<\/script>/gi,
82
82
  /** Standalone/unclosed script tags */
83
83
  /<script[^>]*>/gi,
84
+ /** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
85
+ /<style[^>]*>[\s\S]*?<\/style>/gi,
86
+ /<style[^>]*/gi,
84
87
  /** iframe — full block and partial/unclosed */
85
88
  /<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
86
89
  /<iframe[^>]*/gi,
@@ -396,13 +399,13 @@ function createRateLimiter(options = {}) {
396
399
  statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
397
400
  keyGenerator = (req) => {
398
401
  const ip = req.ip ?? req.socket?.remoteAddress;
399
- if (!ip) {
400
- console.warn(
401
- "[arcis] Rate limiter: cannot resolve client IP. All unresolvable clients share one counter. Set Express trust proxy if behind a reverse proxy."
402
- );
403
- return "unknown";
404
- }
405
- 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)}`;
406
409
  },
407
410
  skip,
408
411
  store: externalStore
@@ -614,6 +617,80 @@ var SanitizationError = class extends ArcisError {
614
617
  }
615
618
  };
616
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
+
617
694
  // src/sanitizers/utils.ts
618
695
  function encodeHtmlEntities(str) {
619
696
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#x27;");
@@ -971,17 +1048,19 @@ var SSTI_REMOVE_PATTERNS = [
971
1048
  /** Jinja2 / Twig: {{ ... }} — always strip (not valid in any JS context) */
972
1049
  /\{\{.*?\}\}/g,
973
1050
  /**
974
- * Freemarker / Spring EL: ${...} — only strip when the expression contains
975
- * operators (?!*+-/), method calls (), or known-dangerous prefixes.
1051
+ * Freemarker / Spring EL: ${...} — strip when expression contains operators,
1052
+ * method calls, or Python dunder patterns (sandbox escape).
976
1053
  * Bare ${name} and ${user.name} are left intact (JS template literal syntax).
977
1054
  */
1055
+ /\$\{[^}]*__\w+__[^}]*\}/g,
978
1056
  /\$\{[^}]*[?!()*+\-/][^}]*\}/g,
979
1057
  /** ERB / EJS: <%= ... %> */
980
1058
  /<%[=\-]?.*?%>/gs,
981
1059
  /**
982
- * Pug / Jade: #{...} — same narrowing as ${ above.
1060
+ * Pug / Jade: #{...} — same narrowing as ${ above, plus dunder detection.
983
1061
  * #{name} output expressions are left intact.
984
1062
  */
1063
+ /#\{[^}]*__\w+__[^}]*\}/g,
985
1064
  /#\{[^}]*[?!()*+\-/][^}]*\}/g,
986
1065
  /** Python dunder sandbox escape — always strip */
987
1066
  /__(?:class|mro|subclasses|globals|builtins|import)__/gi
@@ -1030,6 +1109,8 @@ function detectSsti(input) {
1030
1109
  }
1031
1110
 
1032
1111
  // src/sanitizers/xxe.ts
1112
+ var MAX_XXE_INPUT_BYTES = 1e6;
1113
+ var MAX_ENTITY_REFERENCES = 64;
1033
1114
  var XXE_DETECT_PATTERNS = [
1034
1115
  /** DOCTYPE declaration */
1035
1116
  /<!DOCTYPE\b/gi,
@@ -1059,6 +1140,19 @@ function sanitizeXxe(input, collectThreats = false) {
1059
1140
  const threats = [];
1060
1141
  let value = input;
1061
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
+ }
1062
1156
  for (const pattern of XXE_REMOVE_PATTERNS) {
1063
1157
  pattern.lastIndex = 0;
1064
1158
  if (pattern.test(value)) {
@@ -1096,12 +1190,10 @@ function detectXxe(input) {
1096
1190
  }
1097
1191
 
1098
1192
  // src/sanitizers/jsonp.ts
1099
- var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.[\]]*$/;
1193
+ var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.]*$/;
1100
1194
  var DANGEROUS_CALLBACK_PATTERNS = [
1101
- /\.\./,
1195
+ /\.\./
1102
1196
  // prototype chain traversal
1103
- /\[\s*\]/
1104
- // empty bracket access
1105
1197
  ];
1106
1198
  function sanitizeJsonpCallback(callback, maxLength = 128) {
1107
1199
  if (typeof callback !== "string" || callback.length === 0) {
@@ -1186,7 +1278,7 @@ function detectHeaderInjection(input) {
1186
1278
 
1187
1279
  // src/sanitizers/pii.ts
1188
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;
1189
- 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;
1190
1282
  var CREDIT_CARD_RE = /\b(?:\d[ -]*?){13,19}\b/g;
1191
1283
  var SSN_RE = /\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g;
1192
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;
@@ -2310,10 +2402,192 @@ function createRedactor(sensitiveKeys = []) {
2310
2402
  }
2311
2403
  var safeLog = createSafeLogger;
2312
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
+
2313
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
+ }
2314
2578
  function arcis(options = {}) {
2315
2579
  const middlewares = [];
2316
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
+ }
2317
2591
  if (options.headers !== false) {
2318
2592
  const headerOpts = typeof options.headers === "object" ? options.headers : {};
2319
2593
  middlewares.push(createHeaders(headerOpts));
@@ -2326,7 +2600,8 @@ function arcis(options = {}) {
2326
2600
  }
2327
2601
  if (options.sanitize !== false) {
2328
2602
  const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
2329
- middlewares.push(createSanitizer(sanitizeOpts));
2603
+ const sanitizer = createSanitizer(sanitizeOpts);
2604
+ middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
2330
2605
  }
2331
2606
  const result = middlewares;
2332
2607
  result.close = () => {
@@ -2653,6 +2928,16 @@ function secureCookieDefaults(options = {}) {
2653
2928
  sameSite: options.sameSite ?? "Lax",
2654
2929
  path: options.path
2655
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
+ }
2656
2941
  return (_req, res, next) => {
2657
2942
  const originalSetHeader = res.setHeader.bind(res);
2658
2943
  res.setHeader = function patchedSetHeader(name, value) {
@@ -2785,7 +3070,16 @@ function detectBehavioralSignals(req) {
2785
3070
  }
2786
3071
  function detectBot(req) {
2787
3072
  const rawUa = req.headers["user-agent"] ?? "";
2788
- 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;
2789
3083
  const signals = detectBehavioralSignals(req);
2790
3084
  if (!ua) {
2791
3085
  return {
@@ -2949,6 +3243,10 @@ function csrfProtection(options = {}) {
2949
3243
  if (!validateCsrfToken(cookieToken, requestToken)) {
2950
3244
  return onError(req, res, next);
2951
3245
  }
3246
+ if (options.rotateOnUse) {
3247
+ const freshToken = generateCsrfToken(tokenLength);
3248
+ setCsrfCookie(res, cookieName, freshToken, cookieOpts);
3249
+ }
2952
3250
  next();
2953
3251
  };
2954
3252
  }
@@ -2985,7 +3283,7 @@ var createCsrf = csrfProtection;
2985
3283
 
2986
3284
  // src/middleware/hpp.ts
2987
3285
  function hpp(options = {}) {
2988
- const whitelist = new Set(options.whitelist ?? []);
3286
+ const whitelist = new Set((options.whitelist ?? []).map((k) => k.toLowerCase()));
2989
3287
  const checkQuery = options.checkQuery ?? true;
2990
3288
  const checkBody = options.checkBody ?? true;
2991
3289
  return (req, _res, next) => {
@@ -2995,7 +3293,7 @@ function hpp(options = {}) {
2995
3293
  for (const [key, value] of Object.entries(req.query)) {
2996
3294
  if (Array.isArray(value)) {
2997
3295
  const strings = value.filter((v) => typeof v === "string");
2998
- if (whitelist.has(key)) {
3296
+ if (whitelist.has(key.toLowerCase())) {
2999
3297
  clean[key] = strings;
3000
3298
  } else {
3001
3299
  polluted[key] = strings;
@@ -3013,7 +3311,7 @@ function hpp(options = {}) {
3013
3311
  const clean = {};
3014
3312
  for (const [key, value] of Object.entries(req.body)) {
3015
3313
  if (Array.isArray(value)) {
3016
- if (whitelist.has(key)) {
3314
+ if (whitelist.has(key.toLowerCase())) {
3017
3315
  clean[key] = value;
3018
3316
  } else {
3019
3317
  polluted[key] = value;
@@ -3031,6 +3329,89 @@ function hpp(options = {}) {
3031
3329
  }
3032
3330
  var createHpp = hpp;
3033
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
+
3034
3415
  // src/utils/ip.ts
3035
3416
  var PLATFORM_HEADERS = {
3036
3417
  cloudflare: "cf-connecting-ip",
@@ -3314,6 +3695,6 @@ function createRedisStore(options) {
3314
3695
  return new RedisStore(options);
3315
3696
  }
3316
3697
 
3317
- 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 };
3318
3699
  //# sourceMappingURL=index.mjs.map
3319
3700
  //# sourceMappingURL=index.mjs.map