@arcis/node 1.0.0 → 1.2.0

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 (54) hide show
  1. package/README.md +156 -222
  2. package/dist/core/index.d.mts +4 -4
  3. package/dist/core/index.d.ts +4 -4
  4. package/dist/core/index.js +13 -2
  5. package/dist/core/index.js.map +1 -1
  6. package/dist/core/index.mjs +13 -2
  7. package/dist/core/index.mjs.map +1 -1
  8. package/dist/index-A-m-pPeW.d.mts +340 -0
  9. package/dist/index-CgK94hY_.d.mts +532 -0
  10. package/dist/index-Co5kPRZz.d.ts +340 -0
  11. package/dist/index-D_bdJcF0.d.ts +532 -0
  12. package/dist/index.d.mts +144 -108
  13. package/dist/index.d.ts +144 -108
  14. package/dist/index.js +1541 -211
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +1515 -212
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/logging/index.d.mts +1 -1
  19. package/dist/logging/index.d.ts +1 -1
  20. package/dist/logging/index.js +12 -1
  21. package/dist/logging/index.js.map +1 -1
  22. package/dist/logging/index.mjs +12 -1
  23. package/dist/logging/index.mjs.map +1 -1
  24. package/dist/middleware/index.d.mts +2 -2
  25. package/dist/middleware/index.d.ts +2 -2
  26. package/dist/middleware/index.js +524 -4
  27. package/dist/middleware/index.js.map +1 -1
  28. package/dist/middleware/index.mjs +517 -5
  29. package/dist/middleware/index.mjs.map +1 -1
  30. package/dist/{headers-DBQedhrb.d.mts → pii-CXcHMlnX.d.mts} +156 -2
  31. package/dist/{headers-BJq2OA0i.d.ts → pii-DhNpl7M3.d.ts} +156 -2
  32. package/dist/sanitizers/index.d.mts +2 -2
  33. package/dist/sanitizers/index.d.ts +2 -2
  34. package/dist/sanitizers/index.js +331 -3
  35. package/dist/sanitizers/index.js.map +1 -1
  36. package/dist/sanitizers/index.mjs +321 -4
  37. package/dist/sanitizers/index.mjs.map +1 -1
  38. package/dist/stores/index.d.mts +1 -1
  39. package/dist/stores/index.d.ts +1 -1
  40. package/dist/stores/index.js.map +1 -1
  41. package/dist/stores/index.mjs.map +1 -1
  42. package/dist/{types-BOdL3ZWo.d.mts → types-CsOFHoD9.d.mts} +6 -1
  43. package/dist/{types-BOdL3ZWo.d.ts → types-CsOFHoD9.d.ts} +6 -1
  44. package/dist/validation/index.d.mts +2 -2
  45. package/dist/validation/index.d.ts +2 -2
  46. package/dist/validation/index.js +504 -2
  47. package/dist/validation/index.js.map +1 -1
  48. package/dist/validation/index.mjs +498 -3
  49. package/dist/validation/index.mjs.map +1 -1
  50. package/package.json +114 -109
  51. package/dist/index-BgHPM7LC.d.ts +0 -129
  52. package/dist/index-BpT7flAQ.d.ts +0 -255
  53. package/dist/index-JaFOUKyK.d.mts +0 -255
  54. package/dist/index-nAgXexwD.d.mts +0 -129
package/dist/index.js CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
+ var dns = require('dns');
6
+ var crypto = require('crypto');
7
+
5
8
  // src/core/constants.ts
6
9
  var INPUT = {
7
10
  /** Default maximum input size (1MB) */
@@ -116,7 +119,11 @@ var SQL_PATTERNS = [
116
119
  /** Time-based blind: SLEEP() */
117
120
  /\bSLEEP\s*\(\s*\d+\s*\)/gi,
118
121
  /** Time-based blind: BENCHMARK() */
119
- /\bBENCHMARK\s*\(/gi
122
+ /\bBENCHMARK\s*\(/gi,
123
+ /** Time-based blind: PostgreSQL pg_sleep() */
124
+ /\bpg_sleep\s*\(/gi,
125
+ /** Time-based blind: MSSQL WAITFOR DELAY */
126
+ /\bWAITFOR\s+DELAY\b/gi
120
127
  ];
121
128
  var PATH_PATTERNS = [
122
129
  /** Unix path traversal */
@@ -134,6 +141,10 @@ var PATH_PATTERNS = [
134
141
  /\.%2e[\\/]/gi,
135
142
  /** Fully URL-encoded: %2e%2e%2f */
136
143
  /%2e%2e%2f/gi,
144
+ /** Double URL-encoded forward slash: %252f */
145
+ /%252f/gi,
146
+ /** Dotdotslash bypass: ....// or ....\\ */
147
+ /\.{2,}[/\\]{2,}/g,
137
148
  /** Null byte injection in paths */
138
149
  /\0/g
139
150
  ];
@@ -149,7 +160,9 @@ var COMMAND_PATTERNS = [
149
160
  */
150
161
  /[;&|`]/g,
151
162
  /** Command substitution: $( ... ) — matched as a pair to reduce false positives */
152
- /\$\(/g
163
+ /\$\(/g,
164
+ /** URL-encoded newline/carriage-return injection (%0a, %0d) */
165
+ /%0[ad]/gi
153
166
  ];
154
167
  var DANGEROUS_PROTO_KEYS = /* @__PURE__ */ new Set([
155
168
  "__proto__",
@@ -183,6 +196,7 @@ var NOSQL_DANGEROUS_KEYS = /* @__PURE__ */ new Set([
183
196
  "$expr",
184
197
  "$mod",
185
198
  "$text",
199
+ "$jsonSchema",
186
200
  // Array
187
201
  "$elemMatch",
188
202
  "$all",
@@ -783,7 +797,8 @@ function sanitizeObject(obj, options = {}) {
783
797
  if (typeof obj === "string") return sanitizeString(obj, options);
784
798
  if (typeof obj !== "object") return obj;
785
799
  if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item, options));
786
- return sanitizeObjectDepth(obj, options, 0);
800
+ const result = sanitizeObjectDepth(obj, options, 0);
801
+ return options.freeze ? Object.freeze(result) : result;
787
802
  }
788
803
  function sanitizeObjectDepth(obj, options, depth) {
789
804
  if (depth >= INPUT.MAX_RECURSION_DEPTH) return obj;
@@ -880,6 +895,179 @@ function detectPrototypePollution(obj, maxDepth = 10) {
880
895
  return false;
881
896
  }
882
897
 
898
+ // src/sanitizers/ssti.ts
899
+ var SSTI_DETECT_PATTERNS = [
900
+ /** Jinja2 / Twig / Nunjucks: {{ ... }} */
901
+ /\{\{.*?\}\}/g,
902
+ /** Freemarker / Thymeleaf / Spring EL: ${ ... } */
903
+ /\$\{.*?\}/g,
904
+ /** ERB / EJS: <%= ... %> or <% ... %> */
905
+ /<%[=\-]?.*?%>/gs,
906
+ /** Pug / Jade / Slim: #{ ... } */
907
+ /#\{.*?\}/g,
908
+ /** Python dunder sandbox escape */
909
+ /__(?:class|mro|subclasses|globals|builtins|import)__/gi,
910
+ /** Jinja2 config leak: {{config.X}} or {{config['X']}} */
911
+ /\{\{\s*config[.\[]/gi,
912
+ /** Jinja2 built-in objects */
913
+ /\{\{\s*(?:self|request|lipsum|cycler|joiner|namespace|range)\b/gi
914
+ ];
915
+ var SSTI_REMOVE_PATTERNS = [
916
+ /\{\{.*?\}\}/g,
917
+ /\$\{.*?\}/g,
918
+ /<%[=\-]?.*?%>/gs,
919
+ /#\{.*?\}/g,
920
+ /__(?:class|mro|subclasses|globals|builtins|import)__/gi
921
+ ];
922
+ function sanitizeSsti(input, collectThreats = false) {
923
+ if (typeof input !== "string") {
924
+ return collectThreats ? { value: String(input), wasSanitized: false, threats: [] } : String(input);
925
+ }
926
+ const threats = [];
927
+ let value = input;
928
+ let wasSanitized = false;
929
+ for (const pattern of SSTI_REMOVE_PATTERNS) {
930
+ pattern.lastIndex = 0;
931
+ if (pattern.test(value)) {
932
+ pattern.lastIndex = 0;
933
+ if (collectThreats) {
934
+ const matches = value.match(pattern);
935
+ if (matches) {
936
+ for (const match of matches) {
937
+ threats.push({
938
+ type: "ssti",
939
+ pattern: pattern.source,
940
+ original: match
941
+ });
942
+ }
943
+ }
944
+ }
945
+ value = value.replace(pattern, "");
946
+ wasSanitized = true;
947
+ }
948
+ }
949
+ if (collectThreats) {
950
+ return { value, wasSanitized, threats };
951
+ }
952
+ return value;
953
+ }
954
+ function detectSsti(input) {
955
+ if (typeof input !== "string") return false;
956
+ for (const pattern of SSTI_DETECT_PATTERNS) {
957
+ pattern.lastIndex = 0;
958
+ if (pattern.test(input)) {
959
+ return true;
960
+ }
961
+ }
962
+ return false;
963
+ }
964
+
965
+ // src/sanitizers/xxe.ts
966
+ var XXE_DETECT_PATTERNS = [
967
+ /** DOCTYPE declaration */
968
+ /<!DOCTYPE\b/gi,
969
+ /** ENTITY declaration */
970
+ /<!ENTITY\b/gi,
971
+ /** SYSTEM keyword with URI */
972
+ /\bSYSTEM\s+["']/gi,
973
+ /** PUBLIC keyword with URI */
974
+ /\bPUBLIC\s+["']/gi,
975
+ /** Parameter entity reference (%entity;) */
976
+ /%\s*\w+\s*;/g,
977
+ /** CDATA section (often used to smuggle payloads) */
978
+ /<!\[CDATA\[/gi
979
+ ];
980
+ var XXE_REMOVE_PATTERNS = [
981
+ /** Full DOCTYPE block with optional internal subset: <!DOCTYPE ... [...]> */
982
+ /<!DOCTYPE\s[^[>]*(?:\[[^\]]*\]\s*)?>|<!DOCTYPE\s[^>]*>/gi,
983
+ /** Full ENTITY declaration: <!ENTITY ... > */
984
+ /<!ENTITY[^>]*>/gi,
985
+ /** CDATA sections: <![CDATA[ ... ]]> */
986
+ /<!\[CDATA\[[\s\S]*?\]\]>/gi
987
+ ];
988
+ function sanitizeXxe(input, collectThreats = false) {
989
+ if (typeof input !== "string") {
990
+ return collectThreats ? { value: String(input), wasSanitized: false, threats: [] } : String(input);
991
+ }
992
+ const threats = [];
993
+ let value = input;
994
+ let wasSanitized = false;
995
+ for (const pattern of XXE_REMOVE_PATTERNS) {
996
+ pattern.lastIndex = 0;
997
+ if (pattern.test(value)) {
998
+ pattern.lastIndex = 0;
999
+ if (collectThreats) {
1000
+ const matches = value.match(pattern);
1001
+ if (matches) {
1002
+ for (const match of matches) {
1003
+ threats.push({
1004
+ type: "xxe",
1005
+ pattern: pattern.source,
1006
+ original: match
1007
+ });
1008
+ }
1009
+ }
1010
+ }
1011
+ value = value.replace(pattern, "");
1012
+ wasSanitized = true;
1013
+ }
1014
+ }
1015
+ if (collectThreats) {
1016
+ return { value, wasSanitized, threats };
1017
+ }
1018
+ return value;
1019
+ }
1020
+ function detectXxe(input) {
1021
+ if (typeof input !== "string") return false;
1022
+ for (const pattern of XXE_DETECT_PATTERNS) {
1023
+ pattern.lastIndex = 0;
1024
+ if (pattern.test(input)) {
1025
+ return true;
1026
+ }
1027
+ }
1028
+ return false;
1029
+ }
1030
+
1031
+ // src/sanitizers/jsonp.ts
1032
+ var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.[\]]*$/;
1033
+ var DANGEROUS_CALLBACK_PATTERNS = [
1034
+ /\.\./,
1035
+ // prototype chain traversal
1036
+ /\[\s*\]/
1037
+ // empty bracket access
1038
+ ];
1039
+ function sanitizeJsonpCallback(callback, maxLength = 128) {
1040
+ if (typeof callback !== "string" || callback.length === 0) {
1041
+ return null;
1042
+ }
1043
+ if (callback.length > maxLength) {
1044
+ return null;
1045
+ }
1046
+ if (!SAFE_CALLBACK_PATTERN.test(callback)) {
1047
+ return null;
1048
+ }
1049
+ for (const pattern of DANGEROUS_CALLBACK_PATTERNS) {
1050
+ if (pattern.test(callback)) {
1051
+ return null;
1052
+ }
1053
+ }
1054
+ return callback;
1055
+ }
1056
+ function detectJsonpInjection(callback) {
1057
+ if (typeof callback !== "string" || callback.length === 0) {
1058
+ return false;
1059
+ }
1060
+ if (!SAFE_CALLBACK_PATTERN.test(callback)) {
1061
+ return true;
1062
+ }
1063
+ for (const pattern of DANGEROUS_CALLBACK_PATTERNS) {
1064
+ if (pattern.test(callback)) {
1065
+ return true;
1066
+ }
1067
+ }
1068
+ return false;
1069
+ }
1070
+
883
1071
  // src/sanitizers/headers.ts
884
1072
  var HEADER_INJECTION_PATTERN = /\r\n|\r|\n|\0/g;
885
1073
  function sanitizeHeaderValue(input, collectThreats = false) {
@@ -929,6 +1117,138 @@ function detectHeaderInjection(input) {
929
1117
  return HEADER_INJECTION_PATTERN.test(input);
930
1118
  }
931
1119
 
1120
+ // src/sanitizers/pii.ts
1121
+ 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;
1122
+ var PHONE_RE = /(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
1123
+ var CREDIT_CARD_RE = /\b(?:\d[ -]*?){13,19}\b/g;
1124
+ var SSN_RE = /\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g;
1125
+ 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;
1126
+ var IPV6_RE = /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){1,7}:|::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}\b/g;
1127
+ var PATTERN_MAP = {
1128
+ email: [EMAIL_RE],
1129
+ phone: [PHONE_RE],
1130
+ credit_card: [CREDIT_CARD_RE],
1131
+ ssn: [SSN_RE],
1132
+ ip_address: [IPV4_RE, IPV6_RE]
1133
+ };
1134
+ var ALL_TYPES = ["email", "phone", "credit_card", "ssn", "ip_address"];
1135
+ var TYPE_LABELS = {
1136
+ email: "[EMAIL]",
1137
+ phone: "[PHONE]",
1138
+ credit_card: "[CREDIT_CARD]",
1139
+ ssn: "[SSN]",
1140
+ ip_address: "[IP_ADDRESS]"
1141
+ };
1142
+ function luhnCheck(value) {
1143
+ const digits = value.replace(/[\s-]/g, "");
1144
+ if (!/^\d{13,19}$/.test(digits)) return false;
1145
+ let sum = 0;
1146
+ let alternate = false;
1147
+ for (let i = digits.length - 1; i >= 0; i--) {
1148
+ let n = parseInt(digits[i], 10);
1149
+ if (alternate) {
1150
+ n *= 2;
1151
+ if (n > 9) n -= 9;
1152
+ }
1153
+ sum += n;
1154
+ alternate = !alternate;
1155
+ }
1156
+ return sum % 10 === 0;
1157
+ }
1158
+ function scanPii(input, options = {}) {
1159
+ if (!input || typeof input !== "string") return [];
1160
+ const types = options.types ?? ALL_TYPES;
1161
+ const matches = [];
1162
+ for (const type of types) {
1163
+ const patterns = PATTERN_MAP[type];
1164
+ if (!patterns) continue;
1165
+ for (const pattern of patterns) {
1166
+ const re = new RegExp(pattern.source, pattern.flags);
1167
+ let match;
1168
+ while ((match = re.exec(input)) !== null) {
1169
+ const value = match[0];
1170
+ if (type === "credit_card" && !luhnCheck(value)) continue;
1171
+ if (type === "ssn") {
1172
+ const area = parseInt(value.substring(0, 3), 10);
1173
+ if (area === 0 || area === 666 || area >= 900) continue;
1174
+ }
1175
+ matches.push({
1176
+ type,
1177
+ value,
1178
+ start: match.index,
1179
+ end: match.index + value.length
1180
+ });
1181
+ }
1182
+ }
1183
+ }
1184
+ matches.sort((a, b) => a.start - b.start);
1185
+ return matches;
1186
+ }
1187
+ function detectPii(input, options = {}) {
1188
+ return scanPii(input, options).length > 0;
1189
+ }
1190
+ function redactPii(input, options = {}) {
1191
+ if (!input || typeof input !== "string") return input;
1192
+ const matches = scanPii(input, options);
1193
+ if (matches.length === 0) return input;
1194
+ const replacement = options.replacement ?? "[REDACTED]";
1195
+ let result = input;
1196
+ for (let i = matches.length - 1; i >= 0; i--) {
1197
+ const m = matches[i];
1198
+ const label = options.typeLabels ? TYPE_LABELS[m.type] : replacement;
1199
+ result = result.substring(0, m.start) + label + result.substring(m.end);
1200
+ }
1201
+ return result;
1202
+ }
1203
+ function scanObjectPii(obj, options = {}, path = "") {
1204
+ const results = [];
1205
+ if (!obj || typeof obj !== "object") return results;
1206
+ for (const [key, value] of Object.entries(obj)) {
1207
+ const fieldPath = path ? `${path}.${key}` : key;
1208
+ if (typeof value === "string") {
1209
+ const matches = scanPii(value, options);
1210
+ for (const m of matches) {
1211
+ results.push({ ...m, field: fieldPath });
1212
+ }
1213
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
1214
+ results.push(...scanObjectPii(value, options, fieldPath));
1215
+ } else if (Array.isArray(value)) {
1216
+ for (let i = 0; i < value.length; i++) {
1217
+ const item = value[i];
1218
+ if (typeof item === "string") {
1219
+ const matches = scanPii(item, options);
1220
+ for (const m of matches) {
1221
+ results.push({ ...m, field: `${fieldPath}[${i}]` });
1222
+ }
1223
+ } else if (item && typeof item === "object") {
1224
+ results.push(...scanObjectPii(item, options, `${fieldPath}[${i}]`));
1225
+ }
1226
+ }
1227
+ }
1228
+ }
1229
+ return results;
1230
+ }
1231
+ function redactObjectPii(obj, options = {}) {
1232
+ if (!obj || typeof obj !== "object") return obj;
1233
+ const result = {};
1234
+ for (const [key, value] of Object.entries(obj)) {
1235
+ if (typeof value === "string") {
1236
+ result[key] = redactPii(value, options);
1237
+ } else if (Array.isArray(value)) {
1238
+ result[key] = value.map((item) => {
1239
+ if (typeof item === "string") return redactPii(item, options);
1240
+ if (item && typeof item === "object") return redactObjectPii(item, options);
1241
+ return item;
1242
+ });
1243
+ } else if (value && typeof value === "object") {
1244
+ result[key] = redactObjectPii(value, options);
1245
+ } else {
1246
+ result[key] = value;
1247
+ }
1248
+ }
1249
+ return result;
1250
+ }
1251
+
932
1252
  // src/validation/schema.ts
933
1253
  function validate(schema, source = "body") {
934
1254
  return (req, res, next) => {
@@ -1270,112 +1590,606 @@ function isDangerousExtension(filename) {
1270
1590
  return ext !== "" && DANGEROUS_EXTENSIONS.has(ext);
1271
1591
  }
1272
1592
 
1273
- // src/logging/redactor.ts
1274
- function createSafeLogger(options = {}) {
1593
+ // src/validation/url.ts
1594
+ function validateUrl(url, options = {}) {
1275
1595
  const {
1276
- redactKeys = [],
1277
- maxLength = REDACTION.DEFAULT_MAX_LENGTH,
1278
- redactPatterns = []
1596
+ allowedProtocols = ["http:", "https:"],
1597
+ blockedHosts = [],
1598
+ allowedHosts = [],
1599
+ allowLocalhost = false,
1600
+ allowPrivate = false
1279
1601
  } = options;
1280
- const allRedactKeys = /* @__PURE__ */ new Set([
1281
- ...Array.from(REDACTION.SENSITIVE_KEYS),
1282
- ...redactKeys.map((k) => k.toLowerCase())
1283
- ]);
1284
- function redact(obj, depth = 0) {
1285
- if (depth > INPUT.MAX_RECURSION_DEPTH) return REDACTION.MAX_DEPTH;
1286
- if (obj === null || obj === void 0) return obj;
1287
- if (typeof obj === "string") {
1288
- return redactString(obj, maxLength, redactPatterns);
1289
- }
1290
- if (typeof obj !== "object") return obj;
1291
- if (Array.isArray(obj)) {
1292
- return obj.map((item) => redact(item, depth + 1));
1293
- }
1294
- const result = {};
1295
- for (const [key, value] of Object.entries(obj)) {
1296
- if (allRedactKeys.has(key.toLowerCase())) {
1297
- result[key] = REDACTION.REPLACEMENT;
1298
- } else {
1299
- result[key] = redact(value, depth + 1);
1300
- }
1301
- }
1302
- return result;
1602
+ if (typeof url !== "string" || url.trim() === "") {
1603
+ return { safe: false, reason: "invalid URL: empty or not a string" };
1303
1604
  }
1304
- function log(level, message, data) {
1305
- const entry = {
1306
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1307
- level,
1308
- message: redactString(message, maxLength, redactPatterns)
1309
- };
1310
- if (data !== void 0) {
1311
- entry.data = redact(data);
1312
- }
1313
- console.log(JSON.stringify(entry));
1605
+ let parsed;
1606
+ try {
1607
+ parsed = new URL(url);
1608
+ } catch {
1609
+ return { safe: false, reason: "invalid URL: failed to parse" };
1314
1610
  }
1315
- return {
1316
- log,
1317
- info: (msg, data) => log("info", msg, data),
1318
- warn: (msg, data) => log("warn", msg, data),
1319
- error: (msg, data) => log("error", msg, data),
1320
- debug: (msg, data) => log("debug", msg, data)
1321
- };
1322
- }
1323
- function redactString(str, maxLength, patterns) {
1324
- let safe = str.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F]/g, "");
1325
- for (const pattern of patterns) {
1326
- safe = safe.replace(pattern, REDACTION.REPLACEMENT);
1611
+ if (!allowedProtocols.includes(parsed.protocol)) {
1612
+ return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
1327
1613
  }
1328
- if (safe.length > maxLength) {
1329
- safe = safe.substring(0, maxLength) + `...${REDACTION.TRUNCATED}`;
1614
+ if (parsed.username || parsed.password) {
1615
+ return { safe: false, reason: "URL contains credentials" };
1330
1616
  }
1331
- return safe;
1332
- }
1333
- function createRedactor(sensitiveKeys = []) {
1334
- const allKeys = /* @__PURE__ */ new Set([
1335
- ...Array.from(REDACTION.SENSITIVE_KEYS),
1336
- ...sensitiveKeys.map((k) => k.toLowerCase())
1337
- ]);
1338
- function redact(obj, depth = 0) {
1339
- if (depth > INPUT.MAX_RECURSION_DEPTH) return REDACTION.MAX_DEPTH;
1340
- if (obj === null || obj === void 0) return obj;
1341
- if (typeof obj !== "object") return obj;
1342
- if (Array.isArray(obj)) {
1343
- return obj.map((item) => redact(item, depth + 1));
1617
+ const hostname = parsed.hostname.toLowerCase();
1618
+ if (allowedHosts.some((h) => hostname === h.toLowerCase())) {
1619
+ return { safe: true };
1620
+ }
1621
+ if (blockedHosts.some((h) => hostname === h.toLowerCase())) {
1622
+ return { safe: false, reason: `blocked host: ${hostname}` };
1623
+ }
1624
+ if (!allowLocalhost) {
1625
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname === "0.0.0.0" || hostname.endsWith(".localhost")) {
1626
+ return { safe: false, reason: "loopback address" };
1344
1627
  }
1345
- const result = {};
1346
- for (const [key, value] of Object.entries(obj)) {
1347
- if (allKeys.has(key.toLowerCase())) {
1348
- result[key] = REDACTION.REPLACEMENT;
1349
- } else {
1350
- result[key] = redact(value, depth + 1);
1351
- }
1628
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1629
+ return { safe: false, reason: "loopback address" };
1352
1630
  }
1353
- return result;
1354
1631
  }
1355
- return redact;
1356
- }
1357
- var safeLog = createSafeLogger;
1358
-
1359
- // src/middleware/main.ts
1360
- function arcis(options = {}) {
1361
- const middlewares = [];
1362
- const cleanupFns = [];
1363
- if (options.headers !== false) {
1364
- const headerOpts = typeof options.headers === "object" ? options.headers : {};
1365
- middlewares.push(createHeaders(headerOpts));
1632
+ if (!allowLocalhost || !allowPrivate) {
1633
+ const decimalCheck = checkDecimalIp(hostname, allowLocalhost, allowPrivate);
1634
+ if (decimalCheck) {
1635
+ return { safe: false, reason: decimalCheck };
1636
+ }
1366
1637
  }
1367
- if (options.rateLimit !== false) {
1368
- const rateLimitOpts = typeof options.rateLimit === "object" ? options.rateLimit : {};
1369
- const rateLimiter = createRateLimiter(rateLimitOpts);
1370
- middlewares.push(rateLimiter);
1371
- cleanupFns.push(() => rateLimiter.close());
1638
+ if (!allowLocalhost || !allowPrivate) {
1639
+ const octalCheck = checkOctalIp(hostname, allowLocalhost, allowPrivate);
1640
+ if (octalCheck) {
1641
+ return { safe: false, reason: octalCheck };
1642
+ }
1372
1643
  }
1373
- if (options.sanitize !== false) {
1374
- const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
1375
- middlewares.push(createSanitizer(sanitizeOpts));
1644
+ if (!allowPrivate) {
1645
+ const privateCheck = checkPrivateIp(hostname);
1646
+ if (privateCheck) {
1647
+ return { safe: false, reason: privateCheck };
1648
+ }
1376
1649
  }
1377
- const result = middlewares;
1378
- result.close = () => {
1650
+ return { safe: true };
1651
+ }
1652
+ function isUrlSafe(url, options = {}) {
1653
+ return validateUrl(url, options).safe;
1654
+ }
1655
+ function checkPrivateIp(hostname) {
1656
+ if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1657
+ return "private address (10.0.0.0/8)";
1658
+ }
1659
+ const match172 = hostname.match(/^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
1660
+ if (match172) {
1661
+ const second = parseInt(match172[1], 10);
1662
+ if (second >= 16 && second <= 31) {
1663
+ return "private address (172.16.0.0/12)";
1664
+ }
1665
+ }
1666
+ if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1667
+ return "private address (192.168.0.0/16)";
1668
+ }
1669
+ if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1670
+ return "link-local address (169.254.0.0/16)";
1671
+ }
1672
+ if (/^0\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1673
+ return "current network address (0.0.0.0/8)";
1674
+ }
1675
+ if (hostname === "metadata.google.internal" || hostname === "metadata.internal" || hostname === "metadata.azure.internal") {
1676
+ return "cloud metadata endpoint";
1677
+ }
1678
+ const ipv6 = hostname.replace(/^\[|\]$/g, "");
1679
+ if (ipv6 === "::1" || ipv6 === "::" || ipv6.startsWith("fc") || ipv6.startsWith("fd") || ipv6.startsWith("fe80")) {
1680
+ return "private IPv6 address";
1681
+ }
1682
+ const mappedDotted = ipv6.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
1683
+ if (mappedDotted) {
1684
+ const mappedIp = mappedDotted[1];
1685
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(mappedIp)) {
1686
+ return "IPv6-mapped loopback address";
1687
+ }
1688
+ const mappedCheck = checkPrivateIp(mappedIp);
1689
+ if (mappedCheck) {
1690
+ return `IPv6-mapped ${mappedCheck}`;
1691
+ }
1692
+ }
1693
+ const mappedHex = ipv6.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
1694
+ if (mappedHex) {
1695
+ const hi = parseInt(mappedHex[1], 16);
1696
+ const lo = parseInt(mappedHex[2], 16);
1697
+ const a = hi >> 8 & 255;
1698
+ const b = hi & 255;
1699
+ const c = lo >> 8 & 255;
1700
+ const d = lo & 255;
1701
+ const dotted = `${a}.${b}.${c}.${d}`;
1702
+ if (a === 127) {
1703
+ return "IPv6-mapped loopback address";
1704
+ }
1705
+ const hexCheck = checkPrivateIp(dotted);
1706
+ if (hexCheck) {
1707
+ return `IPv6-mapped ${hexCheck}`;
1708
+ }
1709
+ }
1710
+ return null;
1711
+ }
1712
+ function checkDecimalIp(hostname, allowLocalhost, allowPrivate) {
1713
+ if (!/^\d+$/.test(hostname)) return null;
1714
+ const num = parseInt(hostname, 10);
1715
+ if (isNaN(num) || num < 0 || num > 4294967295) return null;
1716
+ const a = num >>> 24 & 255;
1717
+ const b = num >>> 16 & 255;
1718
+ const c = num >>> 8 & 255;
1719
+ const d = num & 255;
1720
+ const dotted = `${a}.${b}.${c}.${d}`;
1721
+ if (!allowLocalhost && a === 127) {
1722
+ return `loopback address (decimal IP: ${dotted})`;
1723
+ }
1724
+ if (!allowPrivate) {
1725
+ const privateCheck = checkPrivateIp(dotted);
1726
+ if (privateCheck) {
1727
+ return `${privateCheck} (decimal IP: ${dotted})`;
1728
+ }
1729
+ }
1730
+ return null;
1731
+ }
1732
+ function checkOctalIp(hostname, allowLocalhost, allowPrivate) {
1733
+ const parts = hostname.split(".");
1734
+ if (parts.length !== 4) return null;
1735
+ const hasAlternateNotation = parts.some((p) => /^0[0-7]+$/.test(p) || /^0x[0-9a-fA-F]+$/i.test(p));
1736
+ if (!hasAlternateNotation) return null;
1737
+ const octets = [];
1738
+ for (const part of parts) {
1739
+ let val;
1740
+ if (/^0x[0-9a-fA-F]+$/i.test(part)) {
1741
+ val = parseInt(part, 16);
1742
+ } else if (/^0[0-7]*$/.test(part)) {
1743
+ val = parseInt(part, 8);
1744
+ } else if (/^\d+$/.test(part)) {
1745
+ val = parseInt(part, 10);
1746
+ } else {
1747
+ return null;
1748
+ }
1749
+ if (val < 0 || val > 255) return null;
1750
+ octets.push(val);
1751
+ }
1752
+ const dotted = octets.join(".");
1753
+ if (!allowLocalhost && octets[0] === 127) {
1754
+ return `loopback address (octal IP: ${dotted})`;
1755
+ }
1756
+ if (!allowPrivate) {
1757
+ const privateCheck = checkPrivateIp(dotted);
1758
+ if (privateCheck) {
1759
+ return `${privateCheck} (octal IP: ${dotted})`;
1760
+ }
1761
+ }
1762
+ return null;
1763
+ }
1764
+
1765
+ // src/validation/redirect.ts
1766
+ var DANGEROUS_PROTOCOLS = /^(javascript|data|vbscript|blob):/i;
1767
+ var CONTROL_CHARS = /[\t\n\r]/g;
1768
+ function validateRedirect(url, options = {}) {
1769
+ const {
1770
+ allowedHosts = [],
1771
+ allowProtocolRelative = false,
1772
+ allowedProtocols = ["http:", "https:"]
1773
+ } = options;
1774
+ if (typeof url !== "string" || url.trim() === "") {
1775
+ return { safe: false, reason: "invalid redirect: empty or not a string" };
1776
+ }
1777
+ const cleaned = url.replace(CONTROL_CHARS, "");
1778
+ if (DANGEROUS_PROTOCOLS.test(cleaned)) {
1779
+ const proto = cleaned.match(DANGEROUS_PROTOCOLS);
1780
+ return { safe: false, reason: `dangerous protocol: ${proto[0]}` };
1781
+ }
1782
+ if (cleaned.startsWith("\\")) {
1783
+ return { safe: false, reason: "backslash-prefixed URL (browser treats as protocol-relative)" };
1784
+ }
1785
+ if (cleaned.startsWith("//")) {
1786
+ if (!allowProtocolRelative) {
1787
+ const host2 = extractHost(cleaned);
1788
+ if (host2 && allowedHosts.some((h) => host2 === h.toLowerCase())) {
1789
+ return { safe: true };
1790
+ }
1791
+ return { safe: false, reason: "protocol-relative URL not in allowed hosts" };
1792
+ }
1793
+ const host = extractHost(cleaned);
1794
+ if (host && allowedHosts.length > 0 && !allowedHosts.some((h) => host === h.toLowerCase())) {
1795
+ return { safe: false, reason: "protocol-relative URL not in allowed hosts" };
1796
+ }
1797
+ return { safe: true };
1798
+ }
1799
+ let parsed;
1800
+ try {
1801
+ parsed = new URL(cleaned);
1802
+ } catch {
1803
+ return { safe: true };
1804
+ }
1805
+ if (!allowedProtocols.includes(parsed.protocol)) {
1806
+ return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
1807
+ }
1808
+ const hostname = parsed.hostname.toLowerCase();
1809
+ if (allowedHosts.length === 0) {
1810
+ return { safe: false, reason: "absolute URL not in allowed hosts" };
1811
+ }
1812
+ if (!allowedHosts.some((h) => hostname === h.toLowerCase())) {
1813
+ return { safe: false, reason: `host not allowed: ${hostname}` };
1814
+ }
1815
+ return { safe: true };
1816
+ }
1817
+ function isRedirectSafe(url, options = {}) {
1818
+ return validateRedirect(url, options).safe;
1819
+ }
1820
+ function extractHost(url) {
1821
+ const match = url.match(/^\/\/([^/:?#]+)/);
1822
+ return match ? match[1].toLowerCase() : null;
1823
+ }
1824
+ var MAX_EMAIL_LENGTH = 254;
1825
+ var MAX_LOCAL_LENGTH = 64;
1826
+ var MAX_DOMAIN_LENGTH = 255;
1827
+ var EMAIL_SYNTAX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
1828
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set([
1829
+ "gmail.com",
1830
+ "yahoo.com",
1831
+ "hotmail.com",
1832
+ "outlook.com",
1833
+ "aol.com",
1834
+ "protonmail.com",
1835
+ "proton.me",
1836
+ "icloud.com",
1837
+ "mail.com",
1838
+ "zoho.com",
1839
+ "yandex.com",
1840
+ "gmx.com",
1841
+ "gmx.net",
1842
+ "live.com",
1843
+ "msn.com",
1844
+ "me.com",
1845
+ "mac.com",
1846
+ "fastmail.com",
1847
+ "tutanota.com",
1848
+ "hey.com"
1849
+ ]);
1850
+ var DISPOSABLE_DOMAINS = /* @__PURE__ */ new Set([
1851
+ // Popular disposable services
1852
+ "guerrillamail.com",
1853
+ "guerrillamail.net",
1854
+ "guerrillamail.org",
1855
+ "tempmail.com",
1856
+ "temp-mail.org",
1857
+ "temp-mail.io",
1858
+ "throwaway.email",
1859
+ "throwaway.com",
1860
+ "mailinator.com",
1861
+ "mailinator.net",
1862
+ "yopmail.com",
1863
+ "yopmail.fr",
1864
+ "yopmail.net",
1865
+ "sharklasers.com",
1866
+ "grr.la",
1867
+ "guerrillamail.info",
1868
+ "guerrillamail.biz",
1869
+ "guerrillamail.de",
1870
+ "trashmail.com",
1871
+ "trashmail.me",
1872
+ "trashmail.net",
1873
+ "dispostable.com",
1874
+ "maildrop.cc",
1875
+ "mailnesia.com",
1876
+ "tempail.com",
1877
+ "mohmal.com",
1878
+ "getnada.com",
1879
+ "emailondeck.com",
1880
+ "discard.email",
1881
+ "fakeinbox.com",
1882
+ "mailcatch.com",
1883
+ "mintemail.com",
1884
+ "tempr.email",
1885
+ "tempinbox.com",
1886
+ "burnermail.io",
1887
+ "mailsac.com",
1888
+ "harakirimail.com",
1889
+ "tempmailo.com",
1890
+ "emailfake.com",
1891
+ "crazymailing.com",
1892
+ "armyspy.com",
1893
+ "dayrep.com",
1894
+ "einrot.com",
1895
+ "fleckens.hu",
1896
+ "gustr.com",
1897
+ "jourrapide.com",
1898
+ "rhyta.com",
1899
+ "superrito.com",
1900
+ "teleworm.us",
1901
+ "10minutemail.com",
1902
+ "10minutemail.net",
1903
+ "minutemail.com",
1904
+ "tempsky.com",
1905
+ "spamgourmet.com",
1906
+ "mytrashmail.com",
1907
+ "mailexpire.com",
1908
+ "safetymail.info",
1909
+ "filzmail.com",
1910
+ "trashymail.com",
1911
+ "sharkmail.com",
1912
+ "jetable.org",
1913
+ "nospam.ze.tc",
1914
+ "trash-me.com",
1915
+ "dodgit.com",
1916
+ "mailmoat.com",
1917
+ "spamfree24.org",
1918
+ "incognitomail.org",
1919
+ "tempomail.fr",
1920
+ "ephemail.net",
1921
+ "hidemail.de",
1922
+ "spaml.de",
1923
+ "uggsrock.com",
1924
+ "binkmail.com",
1925
+ "suremail.info",
1926
+ "bugmenot.com"
1927
+ ]);
1928
+ var DOMAIN_TYPOS = {
1929
+ "gmial.com": "gmail.com",
1930
+ "gmaill.com": "gmail.com",
1931
+ "gmai.com": "gmail.com",
1932
+ "gamil.com": "gmail.com",
1933
+ "gnail.com": "gmail.com",
1934
+ "gmal.com": "gmail.com",
1935
+ "gmil.com": "gmail.com",
1936
+ "gmail.co": "gmail.com",
1937
+ "gmail.cm": "gmail.com",
1938
+ "gmail.om": "gmail.com",
1939
+ "gmail.con": "gmail.com",
1940
+ "gmail.cim": "gmail.com",
1941
+ "gmail.comm": "gmail.com",
1942
+ "yahooo.com": "yahoo.com",
1943
+ "yaho.com": "yahoo.com",
1944
+ "yahoo.co": "yahoo.com",
1945
+ "yahoo.cm": "yahoo.com",
1946
+ "yahoo.con": "yahoo.com",
1947
+ "yahho.com": "yahoo.com",
1948
+ "hotmial.com": "hotmail.com",
1949
+ "hotmal.com": "hotmail.com",
1950
+ "hotmai.com": "hotmail.com",
1951
+ "hotmil.com": "hotmail.com",
1952
+ "hotmail.co": "hotmail.com",
1953
+ "hotmail.cm": "hotmail.com",
1954
+ "hotmail.con": "hotmail.com",
1955
+ "outlok.com": "outlook.com",
1956
+ "outloo.com": "outlook.com",
1957
+ "outlook.co": "outlook.com",
1958
+ "outlook.cm": "outlook.com",
1959
+ "protonmal.com": "protonmail.com",
1960
+ "protonmail.co": "protonmail.com",
1961
+ "icloud.co": "icloud.com",
1962
+ "icloud.cm": "icloud.com",
1963
+ "icoud.com": "icloud.com"
1964
+ };
1965
+ function invalidResult(reason, email) {
1966
+ return {
1967
+ valid: false,
1968
+ reason,
1969
+ suggestion: null,
1970
+ isFree: false,
1971
+ isDisposable: false,
1972
+ normalized: email
1973
+ };
1974
+ }
1975
+ function validateEmail(email, options = {}) {
1976
+ const {
1977
+ checkDisposable = true,
1978
+ suggestTypoFix = true,
1979
+ blockedDomains = [],
1980
+ allowedDomains = []
1981
+ } = options;
1982
+ const normalized = email.trim().toLowerCase();
1983
+ if (!normalized || normalized.length > MAX_EMAIL_LENGTH) {
1984
+ return invalidResult("invalid_syntax", normalized);
1985
+ }
1986
+ const atIndex = normalized.lastIndexOf("@");
1987
+ if (atIndex === -1) {
1988
+ return invalidResult("invalid_syntax", normalized);
1989
+ }
1990
+ const localPart = normalized.slice(0, atIndex);
1991
+ const domain = normalized.slice(atIndex + 1);
1992
+ if (localPart.length === 0 || localPart.length > MAX_LOCAL_LENGTH) {
1993
+ return invalidResult("invalid_syntax", normalized);
1994
+ }
1995
+ if (domain.length === 0 || domain.length > MAX_DOMAIN_LENGTH) {
1996
+ return invalidResult("invalid_syntax", normalized);
1997
+ }
1998
+ if (localPart.includes("..")) {
1999
+ return invalidResult("invalid_syntax", normalized);
2000
+ }
2001
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
2002
+ return invalidResult("invalid_syntax", normalized);
2003
+ }
2004
+ if (!EMAIL_SYNTAX.test(normalized)) {
2005
+ return invalidResult("invalid_syntax", normalized);
2006
+ }
2007
+ const allowedSet = new Set(allowedDomains.map((d) => d.toLowerCase()));
2008
+ if (allowedSet.has(domain)) {
2009
+ return {
2010
+ valid: true,
2011
+ reason: "valid",
2012
+ suggestion: null,
2013
+ isFree: FREE_PROVIDERS.has(domain),
2014
+ isDisposable: false,
2015
+ normalized
2016
+ };
2017
+ }
2018
+ const blockedSet = new Set(blockedDomains.map((d) => d.toLowerCase()));
2019
+ if (blockedSet.has(domain)) {
2020
+ return invalidResult("blocked", normalized);
2021
+ }
2022
+ const isDisposable = DISPOSABLE_DOMAINS.has(domain);
2023
+ if (checkDisposable && isDisposable) {
2024
+ return {
2025
+ valid: false,
2026
+ reason: "disposable",
2027
+ suggestion: null,
2028
+ isFree: false,
2029
+ isDisposable: true,
2030
+ normalized
2031
+ };
2032
+ }
2033
+ const isFree = FREE_PROVIDERS.has(domain);
2034
+ if (suggestTypoFix && DOMAIN_TYPOS[domain]) {
2035
+ const corrected = `${localPart}@${DOMAIN_TYPOS[domain]}`;
2036
+ return {
2037
+ valid: true,
2038
+ reason: "typo",
2039
+ suggestion: corrected,
2040
+ isFree: FREE_PROVIDERS.has(DOMAIN_TYPOS[domain]),
2041
+ isDisposable: false,
2042
+ normalized
2043
+ };
2044
+ }
2045
+ return {
2046
+ valid: true,
2047
+ reason: "valid",
2048
+ suggestion: null,
2049
+ isFree,
2050
+ isDisposable,
2051
+ normalized
2052
+ };
2053
+ }
2054
+ async function verifyEmailMx(email) {
2055
+ if (!isValidEmailSyntax(email)) return false;
2056
+ const atIndex = email.lastIndexOf("@");
2057
+ const domain = email.slice(atIndex + 1).trim().toLowerCase();
2058
+ if (!domain) return false;
2059
+ try {
2060
+ const records = await dns.promises.resolveMx(domain);
2061
+ return records.length > 0;
2062
+ } catch {
2063
+ return false;
2064
+ }
2065
+ }
2066
+ function isValidEmailSyntax(email) {
2067
+ const normalized = email.trim().toLowerCase();
2068
+ if (!normalized || normalized.length > MAX_EMAIL_LENGTH) return false;
2069
+ const atIndex = normalized.lastIndexOf("@");
2070
+ if (atIndex === -1) return false;
2071
+ const localPart = normalized.slice(0, atIndex);
2072
+ if (localPart.includes("..") || localPart.startsWith(".") || localPart.endsWith(".")) return false;
2073
+ return EMAIL_SYNTAX.test(normalized);
2074
+ }
2075
+
2076
+ // src/logging/redactor.ts
2077
+ var LOG_LEVELS = {
2078
+ debug: 0,
2079
+ info: 1,
2080
+ warn: 2,
2081
+ error: 3,
2082
+ silent: 4
2083
+ };
2084
+ function createSafeLogger(options = {}) {
2085
+ const {
2086
+ redactKeys = [],
2087
+ maxLength = REDACTION.DEFAULT_MAX_LENGTH,
2088
+ redactPatterns = [],
2089
+ level: minLevel = "debug"
2090
+ } = options;
2091
+ const minLevelNum = LOG_LEVELS[minLevel] ?? 0;
2092
+ const allRedactKeys = /* @__PURE__ */ new Set([
2093
+ ...Array.from(REDACTION.SENSITIVE_KEYS),
2094
+ ...redactKeys.map((k) => k.toLowerCase())
2095
+ ]);
2096
+ function redact(obj, depth = 0) {
2097
+ if (depth > INPUT.MAX_RECURSION_DEPTH) return REDACTION.MAX_DEPTH;
2098
+ if (obj === null || obj === void 0) return obj;
2099
+ if (typeof obj === "string") {
2100
+ return redactString(obj, maxLength, redactPatterns);
2101
+ }
2102
+ if (typeof obj !== "object") return obj;
2103
+ if (Array.isArray(obj)) {
2104
+ return obj.map((item) => redact(item, depth + 1));
2105
+ }
2106
+ const result = {};
2107
+ for (const [key, value] of Object.entries(obj)) {
2108
+ if (allRedactKeys.has(key.toLowerCase())) {
2109
+ result[key] = REDACTION.REPLACEMENT;
2110
+ } else {
2111
+ result[key] = redact(value, depth + 1);
2112
+ }
2113
+ }
2114
+ return result;
2115
+ }
2116
+ function log(level, message, data) {
2117
+ const levelNum = LOG_LEVELS[level] ?? 0;
2118
+ if (levelNum < minLevelNum) return;
2119
+ const entry = {
2120
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2121
+ level,
2122
+ message: redactString(message, maxLength, redactPatterns)
2123
+ };
2124
+ if (data !== void 0) {
2125
+ entry.data = redact(data);
2126
+ }
2127
+ console.log(JSON.stringify(entry));
2128
+ }
2129
+ return {
2130
+ log,
2131
+ info: (msg, data) => log("info", msg, data),
2132
+ warn: (msg, data) => log("warn", msg, data),
2133
+ error: (msg, data) => log("error", msg, data),
2134
+ debug: (msg, data) => log("debug", msg, data)
2135
+ };
2136
+ }
2137
+ function redactString(str, maxLength, patterns) {
2138
+ let safe = str.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F]/g, "");
2139
+ for (const pattern of patterns) {
2140
+ safe = safe.replace(pattern, REDACTION.REPLACEMENT);
2141
+ }
2142
+ if (safe.length > maxLength) {
2143
+ safe = safe.substring(0, maxLength) + `...${REDACTION.TRUNCATED}`;
2144
+ }
2145
+ return safe;
2146
+ }
2147
+ function createRedactor(sensitiveKeys = []) {
2148
+ const allKeys = /* @__PURE__ */ new Set([
2149
+ ...Array.from(REDACTION.SENSITIVE_KEYS),
2150
+ ...sensitiveKeys.map((k) => k.toLowerCase())
2151
+ ]);
2152
+ function redact(obj, depth = 0) {
2153
+ if (depth > INPUT.MAX_RECURSION_DEPTH) return REDACTION.MAX_DEPTH;
2154
+ if (obj === null || obj === void 0) return obj;
2155
+ if (typeof obj !== "object") return obj;
2156
+ if (Array.isArray(obj)) {
2157
+ return obj.map((item) => redact(item, depth + 1));
2158
+ }
2159
+ const result = {};
2160
+ for (const [key, value] of Object.entries(obj)) {
2161
+ if (allKeys.has(key.toLowerCase())) {
2162
+ result[key] = REDACTION.REPLACEMENT;
2163
+ } else {
2164
+ result[key] = redact(value, depth + 1);
2165
+ }
2166
+ }
2167
+ return result;
2168
+ }
2169
+ return redact;
2170
+ }
2171
+ var safeLog = createSafeLogger;
2172
+
2173
+ // src/middleware/main.ts
2174
+ function arcis(options = {}) {
2175
+ const middlewares = [];
2176
+ const cleanupFns = [];
2177
+ if (options.headers !== false) {
2178
+ const headerOpts = typeof options.headers === "object" ? options.headers : {};
2179
+ middlewares.push(createHeaders(headerOpts));
2180
+ }
2181
+ if (options.rateLimit !== false) {
2182
+ const rateLimitOpts = typeof options.rateLimit === "object" ? options.rateLimit : {};
2183
+ const rateLimiter = createRateLimiter(rateLimitOpts);
2184
+ middlewares.push(rateLimiter);
2185
+ cleanupFns.push(() => rateLimiter.close());
2186
+ }
2187
+ if (options.sanitize !== false) {
2188
+ const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
2189
+ middlewares.push(createSanitizer(sanitizeOpts));
2190
+ }
2191
+ const result = middlewares;
2192
+ result.close = () => {
1379
2193
  for (const fn of cleanupFns) {
1380
2194
  fn();
1381
2195
  }
@@ -1391,6 +2205,201 @@ arcisWithMethods.logger = createSafeLogger;
1391
2205
  arcisWithMethods.errorHandler = createErrorHandler;
1392
2206
  var main_default = arcisWithMethods;
1393
2207
 
2208
+ // src/utils/duration.ts
2209
+ var MAX_DURATION_MS = 4294967295;
2210
+ var DURATION_REGEX = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i;
2211
+ var UNIT_TO_MS = {
2212
+ ms: 1,
2213
+ s: 1e3,
2214
+ m: 6e4,
2215
+ h: 36e5,
2216
+ d: 864e5
2217
+ };
2218
+ function parseDuration(value) {
2219
+ if (typeof value === "number") {
2220
+ if (!Number.isFinite(value) || value < 0) {
2221
+ throw new Error(`Invalid duration: ${value}. Must be a non-negative finite number.`);
2222
+ }
2223
+ return Math.min(Math.floor(value), MAX_DURATION_MS);
2224
+ }
2225
+ if (typeof value !== "string" || value.trim() === "") {
2226
+ throw new Error(`Invalid duration: "${value}". Expected a duration string (e.g. "5m", "2h") or number.`);
2227
+ }
2228
+ const match = value.trim().match(DURATION_REGEX);
2229
+ if (!match) {
2230
+ throw new Error(
2231
+ `Invalid duration: "${value}". Expected format: <number><unit> where unit is ms, s, m, h, or d.`
2232
+ );
2233
+ }
2234
+ const amount = parseFloat(match[1]);
2235
+ const unit = match[2].toLowerCase();
2236
+ const ms = Math.floor(amount * UNIT_TO_MS[unit]);
2237
+ if (ms < 0 || ms > MAX_DURATION_MS) {
2238
+ throw new Error(`Duration "${value}" exceeds maximum allowed (${MAX_DURATION_MS}ms / ~49.7 days).`);
2239
+ }
2240
+ return ms;
2241
+ }
2242
+ function formatDuration(ms) {
2243
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
2244
+ if (ms < 1e3) return `${ms}ms`;
2245
+ const days = Math.floor(ms / 864e5);
2246
+ const hours = Math.floor(ms % 864e5 / 36e5);
2247
+ const minutes = Math.floor(ms % 36e5 / 6e4);
2248
+ const seconds = Math.floor(ms % 6e4 / 1e3);
2249
+ const parts = [];
2250
+ if (days > 0) parts.push(`${days}d`);
2251
+ if (hours > 0) parts.push(`${hours}h`);
2252
+ if (minutes > 0) parts.push(`${minutes}m`);
2253
+ if (seconds > 0) parts.push(`${seconds}s`);
2254
+ return parts.join(" ") || "0ms";
2255
+ }
2256
+
2257
+ // src/middleware/rate-limit-sliding.ts
2258
+ function createSlidingWindowLimiter(options = {}) {
2259
+ const {
2260
+ max = RATE_LIMIT.DEFAULT_MAX_REQUESTS,
2261
+ window: windowOpt = RATE_LIMIT.DEFAULT_WINDOW_MS,
2262
+ message = RATE_LIMIT.DEFAULT_MESSAGE,
2263
+ statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
2264
+ keyGenerator = (req) => req.ip ?? req.socket?.remoteAddress ?? "unknown",
2265
+ skip
2266
+ } = options;
2267
+ const windowMs = parseDuration(windowOpt);
2268
+ const currentWindows = /* @__PURE__ */ Object.create(null);
2269
+ const previousWindows = /* @__PURE__ */ Object.create(null);
2270
+ const cleanupInterval = setInterval(() => {
2271
+ const now = Date.now();
2272
+ const cutoff = now - windowMs * 2;
2273
+ for (const key of Object.keys(previousWindows)) {
2274
+ if (previousWindows[key].startTime < cutoff) {
2275
+ delete previousWindows[key];
2276
+ }
2277
+ }
2278
+ for (const key of Object.keys(currentWindows)) {
2279
+ if (currentWindows[key].startTime < cutoff) {
2280
+ delete currentWindows[key];
2281
+ }
2282
+ }
2283
+ }, windowMs);
2284
+ if (typeof cleanupInterval.unref === "function") {
2285
+ cleanupInterval.unref();
2286
+ }
2287
+ const handler = (req, res, next) => {
2288
+ try {
2289
+ if (skip?.(req)) return next();
2290
+ const key = keyGenerator(req);
2291
+ const now = Date.now();
2292
+ const windowStart = Math.floor(now / windowMs) * windowMs;
2293
+ if (!currentWindows[key] || currentWindows[key].startTime < windowStart) {
2294
+ if (currentWindows[key]) {
2295
+ previousWindows[key] = currentWindows[key];
2296
+ }
2297
+ currentWindows[key] = { count: 0, startTime: windowStart };
2298
+ }
2299
+ const elapsed = now - windowStart;
2300
+ const weight = Math.max(0, (windowMs - elapsed) / windowMs);
2301
+ const prevCount = previousWindows[key]?.count ?? 0;
2302
+ const estimatedCount = prevCount * weight + currentWindows[key].count + 1;
2303
+ const remaining = Math.max(0, Math.floor(max - estimatedCount));
2304
+ const resetMs = windowStart + windowMs - now;
2305
+ const resetSeconds = Math.max(1, Math.ceil(resetMs / 1e3));
2306
+ res.setHeader("X-RateLimit-Limit", max.toString());
2307
+ res.setHeader("X-RateLimit-Remaining", remaining.toString());
2308
+ res.setHeader("X-RateLimit-Reset", resetSeconds.toString());
2309
+ res.setHeader("X-RateLimit-Policy", `${max};w=${Math.floor(windowMs / 1e3)}`);
2310
+ if (estimatedCount > max) {
2311
+ res.setHeader("Retry-After", resetSeconds.toString());
2312
+ res.status(statusCode).json({
2313
+ error: message,
2314
+ retryAfter: resetSeconds
2315
+ });
2316
+ return;
2317
+ }
2318
+ currentWindows[key].count++;
2319
+ next();
2320
+ } catch (error) {
2321
+ console.error("[arcis] Sliding window rate limiter error:", error);
2322
+ next();
2323
+ }
2324
+ };
2325
+ const middleware = handler;
2326
+ middleware.close = () => {
2327
+ clearInterval(cleanupInterval);
2328
+ };
2329
+ return middleware;
2330
+ }
2331
+
2332
+ // src/middleware/rate-limit-token.ts
2333
+ function createTokenBucketLimiter(options = {}) {
2334
+ const {
2335
+ capacity = 100,
2336
+ refillRate = 10,
2337
+ cost = 1,
2338
+ message = RATE_LIMIT.DEFAULT_MESSAGE,
2339
+ statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
2340
+ keyGenerator = (req) => req.ip ?? req.socket?.remoteAddress ?? "unknown",
2341
+ skip
2342
+ } = options;
2343
+ if (capacity < 1) throw new RangeError(`Token bucket capacity must be >= 1, got ${capacity}`);
2344
+ if (refillRate <= 0) throw new RangeError(`Token bucket refillRate must be > 0, got ${refillRate}`);
2345
+ if (cost < 1) throw new RangeError(`Token bucket cost must be >= 1, got ${cost}`);
2346
+ if (cost > capacity) throw new RangeError(`Token bucket cost (${cost}) must be <= capacity (${capacity}), otherwise all requests are permanently denied`);
2347
+ const buckets = /* @__PURE__ */ Object.create(null);
2348
+ const cleanupInterval = setInterval(() => {
2349
+ const now = Date.now();
2350
+ const staleThreshold = capacity / refillRate * 1e3 * 2;
2351
+ for (const key of Object.keys(buckets)) {
2352
+ if (now - buckets[key].lastRefill > staleThreshold) {
2353
+ delete buckets[key];
2354
+ }
2355
+ }
2356
+ }, 6e4);
2357
+ if (typeof cleanupInterval.unref === "function") {
2358
+ cleanupInterval.unref();
2359
+ }
2360
+ function refillBucket(bucket, now) {
2361
+ const elapsed = (now - bucket.lastRefill) / 1e3;
2362
+ const tokensToAdd = elapsed * refillRate;
2363
+ bucket.tokens = Math.min(capacity, bucket.tokens + tokensToAdd);
2364
+ bucket.lastRefill = now;
2365
+ }
2366
+ const handler = (req, res, next) => {
2367
+ try {
2368
+ if (skip?.(req)) return next();
2369
+ const key = keyGenerator(req);
2370
+ const now = Date.now();
2371
+ if (!buckets[key]) {
2372
+ buckets[key] = { tokens: capacity, lastRefill: now };
2373
+ }
2374
+ const bucket = buckets[key];
2375
+ refillBucket(bucket, now);
2376
+ const retryAfterSec = bucket.tokens < cost ? Math.ceil((cost - bucket.tokens) / refillRate) : 0;
2377
+ res.setHeader("X-RateLimit-Limit", capacity.toString());
2378
+ res.setHeader("X-RateLimit-Remaining", Math.floor(Math.max(0, bucket.tokens - cost)).toString());
2379
+ res.setHeader("X-RateLimit-Policy", `${capacity};w=${Math.floor(capacity / refillRate)};burst=${capacity}`);
2380
+ if (bucket.tokens < cost) {
2381
+ res.setHeader("Retry-After", retryAfterSec.toString());
2382
+ res.setHeader("X-RateLimit-Reset", retryAfterSec.toString());
2383
+ res.status(statusCode).json({
2384
+ error: message,
2385
+ retryAfter: retryAfterSec
2386
+ });
2387
+ return;
2388
+ }
2389
+ bucket.tokens -= cost;
2390
+ next();
2391
+ } catch (error) {
2392
+ console.error("[arcis] Token bucket rate limiter error:", error);
2393
+ next();
2394
+ }
2395
+ };
2396
+ const middleware = handler;
2397
+ middleware.close = () => {
2398
+ clearInterval(cleanupInterval);
2399
+ };
2400
+ return middleware;
2401
+ }
2402
+
1394
2403
  // src/middleware/cors.ts
1395
2404
  var DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
1396
2405
  var DEFAULT_HEADERS = ["Content-Type", "Authorization"];
@@ -1521,144 +2530,438 @@ function secureCookieDefaults(options = {}) {
1521
2530
  }
1522
2531
  var createSecureCookies = secureCookieDefaults;
1523
2532
 
1524
- // src/validation/url.ts
1525
- function validateUrl(url, options = {}) {
1526
- const {
1527
- allowedProtocols = ["http:", "https:"],
1528
- blockedHosts = [],
1529
- allowedHosts = [],
1530
- allowLocalhost = false,
1531
- allowPrivate = false
1532
- } = options;
1533
- if (typeof url !== "string" || url.trim() === "") {
1534
- return { safe: false, reason: "invalid URL: empty or not a string" };
2533
+ // src/middleware/bot-detection.ts
2534
+ var BOT_PATTERNS = [
2535
+ // --- SEARCH ENGINES (specific variants before generic) ---
2536
+ { pattern: /Googlebot-Image/i, name: "Googlebot-Image", category: "SEARCH_ENGINE" },
2537
+ { pattern: /Googlebot-Video/i, name: "Googlebot-Video", category: "SEARCH_ENGINE" },
2538
+ { pattern: /Googlebot-News/i, name: "Googlebot-News", category: "SEARCH_ENGINE" },
2539
+ { pattern: /Googlebot/i, name: "Googlebot", category: "SEARCH_ENGINE" },
2540
+ { pattern: /AdsBot-Google/i, name: "AdsBot-Google", category: "SEARCH_ENGINE" },
2541
+ { pattern: /Mediapartners-Google/i, name: "Mediapartners-Google", category: "SEARCH_ENGINE" },
2542
+ { pattern: /Bingbot/i, name: "Bingbot", category: "SEARCH_ENGINE" },
2543
+ { pattern: /msnbot/i, name: "msnbot", category: "SEARCH_ENGINE" },
2544
+ { pattern: /Slurp/i, name: "Yahoo Slurp", category: "SEARCH_ENGINE" },
2545
+ { pattern: /DuckDuckBot/i, name: "DuckDuckBot", category: "SEARCH_ENGINE" },
2546
+ { pattern: /Baiduspider/i, name: "Baiduspider", category: "SEARCH_ENGINE" },
2547
+ { pattern: /YandexBot/i, name: "YandexBot", category: "SEARCH_ENGINE" },
2548
+ { pattern: /YandexImages/i, name: "YandexImages", category: "SEARCH_ENGINE" },
2549
+ { pattern: /Sogou/i, name: "Sogou", category: "SEARCH_ENGINE" },
2550
+ { pattern: /Exabot/i, name: "Exabot", category: "SEARCH_ENGINE" },
2551
+ { pattern: /ia_archiver/i, name: "Alexa", category: "SEARCH_ENGINE" },
2552
+ { pattern: /Applebot/i, name: "Applebot", category: "SEARCH_ENGINE" },
2553
+ { pattern: /Qwantify/i, name: "Qwantify", category: "SEARCH_ENGINE" },
2554
+ { pattern: /PetalBot/i, name: "PetalBot", category: "SEARCH_ENGINE" },
2555
+ { pattern: /SeznamBot/i, name: "SeznamBot", category: "SEARCH_ENGINE" },
2556
+ // --- SOCIAL ---
2557
+ { pattern: /Twitterbot/i, name: "Twitterbot", category: "SOCIAL" },
2558
+ { pattern: /facebookexternalhit/i, name: "Facebook", category: "SOCIAL" },
2559
+ { pattern: /Facebot/i, name: "Facebot", category: "SOCIAL" },
2560
+ { pattern: /LinkedInBot/i, name: "LinkedInBot", category: "SOCIAL" },
2561
+ { pattern: /Pinterest/i, name: "Pinterest", category: "SOCIAL" },
2562
+ { pattern: /Slackbot/i, name: "Slackbot", category: "SOCIAL" },
2563
+ { pattern: /TelegramBot/i, name: "TelegramBot", category: "SOCIAL" },
2564
+ { pattern: /WhatsApp/i, name: "WhatsApp", category: "SOCIAL" },
2565
+ { pattern: /Discordbot/i, name: "Discordbot", category: "SOCIAL" },
2566
+ { pattern: /Redditbot/i, name: "Redditbot", category: "SOCIAL" },
2567
+ { pattern: /Embedly/i, name: "Embedly", category: "SOCIAL" },
2568
+ { pattern: /Quora Link Preview/i, name: "Quora", category: "SOCIAL" },
2569
+ { pattern: /Mastodon/i, name: "Mastodon", category: "SOCIAL" },
2570
+ // --- MONITORING ---
2571
+ { pattern: /UptimeRobot/i, name: "UptimeRobot", category: "MONITORING" },
2572
+ { pattern: /Pingdom/i, name: "Pingdom", category: "MONITORING" },
2573
+ { pattern: /Site24x7/i, name: "Site24x7", category: "MONITORING" },
2574
+ { pattern: /StatusCake/i, name: "StatusCake", category: "MONITORING" },
2575
+ { pattern: /Datadog/i, name: "Datadog", category: "MONITORING" },
2576
+ { pattern: /NewRelicPinger/i, name: "New Relic", category: "MONITORING" },
2577
+ { pattern: /Better Uptime Bot/i, name: "Better Uptime", category: "MONITORING" },
2578
+ { pattern: /GTmetrix/i, name: "GTmetrix", category: "MONITORING" },
2579
+ { pattern: /PageSpeed/i, name: "PageSpeed Insights", category: "MONITORING" },
2580
+ // --- AI CRAWLERS ---
2581
+ { pattern: /GPTBot/i, name: "GPTBot", category: "AI_CRAWLER" },
2582
+ { pattern: /ChatGPT-User/i, name: "ChatGPT-User", category: "AI_CRAWLER" },
2583
+ { pattern: /Claude-Web/i, name: "Claude-Web", category: "AI_CRAWLER" },
2584
+ { pattern: /ClaudeBot/i, name: "ClaudeBot", category: "AI_CRAWLER" },
2585
+ { pattern: /anthropic-ai/i, name: "Anthropic", category: "AI_CRAWLER" },
2586
+ { pattern: /Bytespider/i, name: "Bytespider", category: "AI_CRAWLER" },
2587
+ { pattern: /CCBot/i, name: "CCBot", category: "AI_CRAWLER" },
2588
+ { pattern: /cohere-ai/i, name: "Cohere", category: "AI_CRAWLER" },
2589
+ { pattern: /PerplexityBot/i, name: "PerplexityBot", category: "AI_CRAWLER" },
2590
+ { pattern: /YouBot/i, name: "YouBot", category: "AI_CRAWLER" },
2591
+ { pattern: /Google-Extended/i, name: "Google-Extended", category: "AI_CRAWLER" },
2592
+ { pattern: /Diffbot/i, name: "Diffbot", category: "AI_CRAWLER" },
2593
+ { pattern: /Amazonbot/i, name: "Amazonbot", category: "AI_CRAWLER" },
2594
+ { pattern: /meta-externalagent/i, name: "Meta AI", category: "AI_CRAWLER" },
2595
+ // --- AUTOMATED TOOLS (headless browsers, testing frameworks) ---
2596
+ { pattern: /HeadlessChrome/i, name: "Headless Chrome", category: "AUTOMATED" },
2597
+ { pattern: /PhantomJS/i, name: "PhantomJS", category: "AUTOMATED" },
2598
+ { pattern: /Selenium/i, name: "Selenium", category: "AUTOMATED" },
2599
+ { pattern: /Puppeteer/i, name: "Puppeteer", category: "AUTOMATED" },
2600
+ { pattern: /Playwright/i, name: "Playwright", category: "AUTOMATED" },
2601
+ { pattern: /Cypress/i, name: "Cypress", category: "AUTOMATED" },
2602
+ { pattern: /webdriver/i, name: "WebDriver", category: "AUTOMATED" },
2603
+ { pattern: /MSIE 6\.0/i, name: "Fake IE6", category: "AUTOMATED" },
2604
+ // --- SCRAPERS / CLI TOOLS ---
2605
+ { pattern: /^curl\//i, name: "curl", category: "SCRAPER" },
2606
+ { pattern: /^wget\//i, name: "wget", category: "SCRAPER" },
2607
+ { pattern: /^python-requests\//i, name: "python-requests", category: "SCRAPER" },
2608
+ { pattern: /^python-httpx\//i, name: "python-httpx", category: "SCRAPER" },
2609
+ { pattern: /^Python-urllib/i, name: "Python-urllib", category: "SCRAPER" },
2610
+ { pattern: /^aiohttp\//i, name: "aiohttp", category: "SCRAPER" },
2611
+ { pattern: /^Go-http-client/i, name: "Go-http-client", category: "SCRAPER" },
2612
+ { pattern: /^Java\//i, name: "Java HttpClient", category: "SCRAPER" },
2613
+ { pattern: /^Apache-HttpClient/i, name: "Apache HttpClient", category: "SCRAPER" },
2614
+ { pattern: /^okhttp\//i, name: "OkHttp", category: "SCRAPER" },
2615
+ { pattern: /^node-fetch\//i, name: "node-fetch", category: "SCRAPER" },
2616
+ { pattern: /^axios\//i, name: "axios", category: "SCRAPER" },
2617
+ { pattern: /^got\//i, name: "got", category: "SCRAPER" },
2618
+ { pattern: /^libwww-perl/i, name: "libwww-perl", category: "SCRAPER" },
2619
+ { pattern: /^Ruby/i, name: "Ruby", category: "SCRAPER" },
2620
+ { pattern: /^PHP\//i, name: "PHP", category: "SCRAPER" },
2621
+ { pattern: /Scrapy/i, name: "Scrapy", category: "SCRAPER" },
2622
+ { pattern: /^Postman/i, name: "Postman", category: "SCRAPER" },
2623
+ { pattern: /^Insomnia/i, name: "Insomnia", category: "SCRAPER" },
2624
+ { pattern: /^HTTPie\//i, name: "HTTPie", category: "SCRAPER" }
2625
+ ];
2626
+ function detectBehavioralSignals(req) {
2627
+ const signals = [];
2628
+ const headers = req.headers;
2629
+ if (!headers["user-agent"]) {
2630
+ signals.push("missing_user_agent");
1535
2631
  }
1536
- let parsed;
1537
- try {
1538
- parsed = new URL(url);
1539
- } catch {
1540
- return { safe: false, reason: "invalid URL: failed to parse" };
2632
+ if (!headers["accept"]) {
2633
+ signals.push("missing_accept");
1541
2634
  }
1542
- if (!allowedProtocols.includes(parsed.protocol)) {
1543
- return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
2635
+ if (!headers["accept-language"]) {
2636
+ signals.push("missing_accept_language");
1544
2637
  }
1545
- if (parsed.username || parsed.password) {
1546
- return { safe: false, reason: "URL contains credentials" };
2638
+ if (!headers["accept-encoding"]) {
2639
+ signals.push("missing_accept_encoding");
1547
2640
  }
1548
- const hostname = parsed.hostname.toLowerCase();
1549
- if (allowedHosts.some((h) => hostname === h.toLowerCase())) {
1550
- return { safe: true };
2641
+ if (headers["connection"] === "close") {
2642
+ signals.push("connection_close");
1551
2643
  }
1552
- if (blockedHosts.some((h) => hostname === h.toLowerCase())) {
1553
- return { safe: false, reason: `blocked host: ${hostname}` };
2644
+ return signals;
2645
+ }
2646
+ function detectBot(req) {
2647
+ const rawUa = req.headers["user-agent"] ?? "";
2648
+ const ua = rawUa.length > 2048 ? rawUa.slice(0, 2048) : rawUa;
2649
+ const signals = detectBehavioralSignals(req);
2650
+ if (!ua) {
2651
+ return {
2652
+ isBot: true,
2653
+ category: "UNKNOWN",
2654
+ name: null,
2655
+ confidence: 0.8,
2656
+ signals
2657
+ };
1554
2658
  }
1555
- if (!allowLocalhost) {
1556
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname === "0.0.0.0" || hostname.endsWith(".localhost")) {
1557
- return { safe: false, reason: "loopback address" };
1558
- }
1559
- if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1560
- return { safe: false, reason: "loopback address" };
2659
+ for (const bot of BOT_PATTERNS) {
2660
+ if (bot.pattern.test(ua)) {
2661
+ return {
2662
+ isBot: true,
2663
+ category: bot.category,
2664
+ name: bot.name,
2665
+ confidence: 0.95,
2666
+ signals
2667
+ };
1561
2668
  }
1562
2669
  }
1563
- if (!allowPrivate) {
1564
- const privateCheck = checkPrivateIp(hostname);
1565
- if (privateCheck) {
1566
- return { safe: false, reason: privateCheck };
1567
- }
2670
+ const behaviorScore = signals.length;
2671
+ if (behaviorScore >= 3) {
2672
+ return {
2673
+ isBot: true,
2674
+ category: "UNKNOWN",
2675
+ name: null,
2676
+ confidence: Math.min(1, 0.6 + behaviorScore * 0.1),
2677
+ signals
2678
+ };
1568
2679
  }
1569
- return { safe: true };
2680
+ return {
2681
+ isBot: false,
2682
+ category: "HUMAN",
2683
+ name: null,
2684
+ confidence: Math.max(0, 1 - behaviorScore * 0.15),
2685
+ signals
2686
+ };
1570
2687
  }
1571
- function isUrlSafe(url, options = {}) {
1572
- return validateUrl(url, options).safe;
2688
+ function botProtection(options = {}) {
2689
+ const {
2690
+ allow = ["SEARCH_ENGINE", "SOCIAL", "MONITORING"],
2691
+ deny = ["AUTOMATED"],
2692
+ defaultAction = "allow",
2693
+ statusCode = 403,
2694
+ message = "Access denied.",
2695
+ onDetected
2696
+ } = options;
2697
+ const allowSet = new Set(allow);
2698
+ const denySet = new Set(deny);
2699
+ return (req, res, next) => {
2700
+ const result = detectBot(req);
2701
+ req.botDetection = result;
2702
+ if (!result.isBot) {
2703
+ return next();
2704
+ }
2705
+ if (allowSet.has(result.category)) {
2706
+ return next();
2707
+ }
2708
+ if (denySet.has(result.category)) {
2709
+ if (onDetected) {
2710
+ return onDetected(req, res, result);
2711
+ }
2712
+ res.status(statusCode).json({ error: message });
2713
+ return;
2714
+ }
2715
+ if (defaultAction === "deny") {
2716
+ if (onDetected) {
2717
+ return onDetected(req, res, result);
2718
+ }
2719
+ res.status(statusCode).json({ error: message });
2720
+ return;
2721
+ }
2722
+ next();
2723
+ };
1573
2724
  }
1574
- function checkPrivateIp(hostname) {
1575
- if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1576
- return "private address (10.0.0.0/8)";
1577
- }
1578
- const match172 = hostname.match(/^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
1579
- if (match172) {
1580
- const second = parseInt(match172[1], 10);
1581
- if (second >= 16 && second <= 31) {
1582
- return "private address (172.16.0.0/12)";
2725
+ var DEFAULTS = {
2726
+ cookieName: "_csrf",
2727
+ headerName: "x-csrf-token",
2728
+ fieldName: "_csrf",
2729
+ tokenLength: 32,
2730
+ protectedMethods: ["POST", "PUT", "PATCH", "DELETE"]
2731
+ };
2732
+ function generateCsrfToken(length = 32) {
2733
+ return crypto.randomBytes(length).toString("hex");
2734
+ }
2735
+ function validateCsrfToken(cookieToken, requestToken) {
2736
+ if (!cookieToken || !requestToken) return false;
2737
+ if (cookieToken.length !== requestToken.length) return false;
2738
+ let result = 0;
2739
+ for (let i = 0; i < cookieToken.length; i++) {
2740
+ result |= cookieToken.charCodeAt(i) ^ requestToken.charCodeAt(i);
2741
+ }
2742
+ return result === 0;
2743
+ }
2744
+ function getRequestToken(req, headerName, fieldName) {
2745
+ const headerToken = req.headers[headerName.toLowerCase()];
2746
+ if (typeof headerToken === "string" && headerToken) return headerToken;
2747
+ if (req.body && typeof req.body === "object" && fieldName in req.body) {
2748
+ const bodyToken = req.body[fieldName];
2749
+ if (typeof bodyToken === "string" && bodyToken) return bodyToken;
2750
+ }
2751
+ if (req.query && fieldName in req.query) {
2752
+ const queryToken = req.query[fieldName];
2753
+ if (typeof queryToken === "string" && queryToken) return queryToken;
2754
+ }
2755
+ return void 0;
2756
+ }
2757
+ function csrfProtection(options = {}) {
2758
+ const cookieName = options.cookieName ?? DEFAULTS.cookieName;
2759
+ const headerName = options.headerName ?? DEFAULTS.headerName;
2760
+ const fieldName = options.fieldName ?? DEFAULTS.fieldName;
2761
+ const tokenLength = options.tokenLength ?? DEFAULTS.tokenLength;
2762
+ const protectedMethods = options.protectedMethods ?? [...DEFAULTS.protectedMethods];
2763
+ const excludePaths = options.excludePaths ?? [];
2764
+ const isProduction = process.env.NODE_ENV === "production";
2765
+ const cookieOpts = {
2766
+ path: options.cookie?.path ?? "/",
2767
+ httpOnly: options.cookie?.httpOnly ?? false,
2768
+ // Must be readable by client JS
2769
+ secure: options.cookie?.secure ?? isProduction,
2770
+ sameSite: options.cookie?.sameSite ?? "Lax",
2771
+ domain: options.cookie?.domain
2772
+ };
2773
+ const defaultOnError = (_req, res, _next) => {
2774
+ res.status(403).json({
2775
+ error: "CSRF token validation failed",
2776
+ message: "Invalid or missing CSRF token. Include the token from the cookie in the X-CSRF-Token header."
2777
+ });
2778
+ };
2779
+ const onError = options.onError ?? defaultOnError;
2780
+ const protectedSet = new Set(protectedMethods.map((m) => m.toUpperCase()));
2781
+ return (req, res, next) => {
2782
+ const method = req.method.toUpperCase();
2783
+ const requestPath = req.path || req.url;
2784
+ if (excludePaths.some((p) => requestPath === p || requestPath.startsWith(p + "/"))) {
2785
+ return next();
1583
2786
  }
2787
+ req.csrfToken = () => {
2788
+ const existing = getCookieValue(req, cookieName);
2789
+ if (existing) return existing;
2790
+ const token = generateCsrfToken(tokenLength);
2791
+ setCsrfCookie(res, cookieName, token, cookieOpts);
2792
+ return token;
2793
+ };
2794
+ if (!protectedSet.has(method)) {
2795
+ const existing = getCookieValue(req, cookieName);
2796
+ if (!existing) {
2797
+ const token = generateCsrfToken(tokenLength);
2798
+ setCsrfCookie(res, cookieName, token, cookieOpts);
2799
+ }
2800
+ return next();
2801
+ }
2802
+ const cookieToken = getCookieValue(req, cookieName);
2803
+ if (!cookieToken) {
2804
+ return onError(req, res, next);
2805
+ }
2806
+ const requestToken = getRequestToken(req, headerName, fieldName);
2807
+ if (!requestToken) {
2808
+ return onError(req, res, next);
2809
+ }
2810
+ if (!validateCsrfToken(cookieToken, requestToken)) {
2811
+ return onError(req, res, next);
2812
+ }
2813
+ next();
2814
+ };
2815
+ }
2816
+ function getCookieValue(req, name) {
2817
+ if (req.cookies && typeof req.cookies === "object" && name in req.cookies) {
2818
+ return req.cookies[name];
2819
+ }
2820
+ const cookieHeader = req.headers.cookie;
2821
+ if (!cookieHeader) return void 0;
2822
+ const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${escapeRegex(name)}=([^;]*)`));
2823
+ return match ? decodeURIComponent(match[1]) : void 0;
2824
+ }
2825
+ function setCsrfCookie(res, name, token, opts) {
2826
+ const parts = [`${name}=${token}`];
2827
+ parts.push(`Path=${opts.path}`);
2828
+ if (opts.httpOnly) parts.push("HttpOnly");
2829
+ if (opts.secure) parts.push("Secure");
2830
+ parts.push(`SameSite=${opts.sameSite}`);
2831
+ if (opts.domain) parts.push(`Domain=${opts.domain}`);
2832
+ res.setHeader("Set-Cookie", parts.join("; "));
2833
+ }
2834
+ function escapeRegex(str) {
2835
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2836
+ }
2837
+ var createCsrf = csrfProtection;
2838
+
2839
+ // src/utils/ip.ts
2840
+ var PLATFORM_HEADERS = {
2841
+ cloudflare: "cf-connecting-ip",
2842
+ vercel: "x-real-ip",
2843
+ flyio: "fly-client-ip",
2844
+ render: "x-render-client-ip",
2845
+ firebase: "x-appengine-user-ip",
2846
+ "aws-alb": "x-forwarded-for"
2847
+ };
2848
+ function detectPlatform() {
2849
+ const env = typeof process !== "undefined" ? process.env : {};
2850
+ if (env.CF_PAGES || env.CF_WORKERS) return "cloudflare";
2851
+ if (env.VERCEL) return "vercel";
2852
+ if (env.FLY_APP_NAME) return "flyio";
2853
+ if (env.RENDER) return "render";
2854
+ if (env.FIREBASE_CONFIG || env.GCLOUD_PROJECT) return "firebase";
2855
+ if (env.AWS_EXECUTION_ENV || env.AWS_LAMBDA_FUNCTION_NAME) return "aws-alb";
2856
+ return "generic";
2857
+ }
2858
+ var _cachedPlatform = null;
2859
+ function getCachedPlatform() {
2860
+ if (_cachedPlatform === null) {
2861
+ _cachedPlatform = detectPlatform();
1584
2862
  }
1585
- if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1586
- return "private address (192.168.0.0/16)";
1587
- }
1588
- if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1589
- return "link-local address (169.254.0.0/16)";
1590
- }
1591
- if (/^0\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1592
- return "current network address (0.0.0.0/8)";
1593
- }
1594
- if (hostname === "metadata.google.internal" || hostname === "metadata.internal") {
1595
- return "cloud metadata endpoint";
2863
+ return _cachedPlatform;
2864
+ }
2865
+ var MAX_IP_LENGTH = 45;
2866
+ function sanitizeIp(ip) {
2867
+ const trimmed = ip.trim();
2868
+ if (trimmed.length > MAX_IP_LENGTH) return trimmed.slice(0, MAX_IP_LENGTH);
2869
+ return trimmed;
2870
+ }
2871
+ function getHeader(req, name) {
2872
+ const val = req.headers[name];
2873
+ if (Array.isArray(val)) return val[0];
2874
+ return val;
2875
+ }
2876
+ function parseForwardedFor(header, trustedProxyCount) {
2877
+ const ips = header.split(",").map((ip) => ip.trim()).filter(Boolean);
2878
+ if (ips.length === 0) return void 0;
2879
+ const clientIndex = Math.max(0, ips.length - trustedProxyCount);
2880
+ return ips[clientIndex] || void 0;
2881
+ }
2882
+ function detectClientIp(req, options = {}) {
2883
+ const { platform = "auto", trustedProxyCount = 1 } = options;
2884
+ const r = req;
2885
+ const resolvedPlatform = platform === "auto" ? getCachedPlatform() : platform;
2886
+ if (resolvedPlatform !== "generic" && resolvedPlatform in PLATFORM_HEADERS) {
2887
+ const headerName = PLATFORM_HEADERS[resolvedPlatform];
2888
+ if (headerName) {
2889
+ if (resolvedPlatform === "aws-alb") {
2890
+ const xff2 = getHeader(r, "x-forwarded-for");
2891
+ if (xff2) {
2892
+ const ip = parseForwardedFor(xff2, trustedProxyCount);
2893
+ if (ip) return sanitizeIp(ip);
2894
+ }
2895
+ } else {
2896
+ const ip = getHeader(r, headerName);
2897
+ if (ip) return sanitizeIp(ip);
2898
+ }
2899
+ }
1596
2900
  }
1597
- const ipv6 = hostname.replace(/^\[|\]$/g, "");
1598
- if (ipv6 === "::1" || ipv6 === "::" || ipv6.startsWith("fc") || ipv6.startsWith("fd") || ipv6.startsWith("fe80")) {
1599
- return "private IPv6 address";
2901
+ if (r.ip) return sanitizeIp(r.ip);
2902
+ const xff = getHeader(r, "x-forwarded-for");
2903
+ if (xff) {
2904
+ const ip = parseForwardedFor(xff, trustedProxyCount);
2905
+ if (ip) return sanitizeIp(ip);
1600
2906
  }
1601
- return null;
2907
+ const realIp = getHeader(r, "x-real-ip");
2908
+ if (realIp) return sanitizeIp(realIp);
2909
+ const socketIp = r.socket?.remoteAddress ?? r.connection?.remoteAddress;
2910
+ if (socketIp) return sanitizeIp(socketIp);
2911
+ return "unknown";
1602
2912
  }
1603
-
1604
- // src/validation/redirect.ts
1605
- var DANGEROUS_PROTOCOLS = /^(javascript|data|vbscript|blob):/i;
1606
- var CONTROL_CHARS = /[\t\n\r]/g;
1607
- function validateRedirect(url, options = {}) {
2913
+ function isPrivateIp(ip) {
2914
+ const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
2915
+ if (/^127\./.test(normalized)) return true;
2916
+ if (/^10\./.test(normalized)) return true;
2917
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
2918
+ if (/^192\.168\./.test(normalized)) return true;
2919
+ if (/^169\.254\./.test(normalized)) return true;
2920
+ if (/^0\./.test(normalized)) return true;
2921
+ if (ip === "::1") return true;
2922
+ if (/^fe80:/i.test(ip)) return true;
2923
+ if (/^fc00:/i.test(ip)) return true;
2924
+ if (/^fd/i.test(ip)) return true;
2925
+ return false;
2926
+ }
2927
+ function getHeader2(req, name) {
2928
+ const val = req.headers[name];
2929
+ if (Array.isArray(val)) return val[0] ?? "";
2930
+ return val ?? "";
2931
+ }
2932
+ function fingerprint(req, options = {}) {
1608
2933
  const {
1609
- allowedHosts = [],
1610
- allowProtocolRelative = false,
1611
- allowedProtocols = ["http:", "https:"]
2934
+ ip = true,
2935
+ userAgent = true,
2936
+ accept = true,
2937
+ acceptLanguage = true,
2938
+ acceptEncoding = true,
2939
+ custom = [],
2940
+ ipOptions
1612
2941
  } = options;
1613
- if (typeof url !== "string" || url.trim() === "") {
1614
- return { safe: false, reason: "invalid redirect: empty or not a string" };
2942
+ const components = [];
2943
+ if (ip) {
2944
+ components.push(`ip:${detectClientIp(req, ipOptions)}`);
1615
2945
  }
1616
- const cleaned = url.replace(CONTROL_CHARS, "");
1617
- if (DANGEROUS_PROTOCOLS.test(cleaned)) {
1618
- const proto = cleaned.match(DANGEROUS_PROTOCOLS);
1619
- return { safe: false, reason: `dangerous protocol: ${proto[0]}` };
2946
+ if (userAgent) {
2947
+ components.push(`ua:${getHeader2(req, "user-agent")}`);
1620
2948
  }
1621
- if (cleaned.startsWith("\\")) {
1622
- return { safe: false, reason: "backslash-prefixed URL (browser treats as protocol-relative)" };
2949
+ if (accept) {
2950
+ components.push(`accept:${getHeader2(req, "accept")}`);
1623
2951
  }
1624
- if (cleaned.startsWith("//")) {
1625
- if (!allowProtocolRelative) {
1626
- const host2 = extractHost(cleaned);
1627
- if (host2 && allowedHosts.some((h) => host2 === h.toLowerCase())) {
1628
- return { safe: true };
1629
- }
1630
- return { safe: false, reason: "protocol-relative URL not in allowed hosts" };
1631
- }
1632
- const host = extractHost(cleaned);
1633
- if (host && allowedHosts.length > 0 && !allowedHosts.some((h) => host === h.toLowerCase())) {
1634
- return { safe: false, reason: "protocol-relative URL not in allowed hosts" };
1635
- }
1636
- return { safe: true };
2952
+ if (acceptLanguage) {
2953
+ components.push(`lang:${getHeader2(req, "accept-language")}`);
1637
2954
  }
1638
- let parsed;
1639
- try {
1640
- parsed = new URL(cleaned);
1641
- } catch {
1642
- return { safe: true };
2955
+ if (acceptEncoding) {
2956
+ components.push(`enc:${getHeader2(req, "accept-encoding")}`);
1643
2957
  }
1644
- if (!allowedProtocols.includes(parsed.protocol)) {
1645
- return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
2958
+ for (const c of custom) {
2959
+ if (c != null) components.push(`custom:${c}`);
1646
2960
  }
1647
- const hostname = parsed.hostname.toLowerCase();
1648
- if (allowedHosts.length === 0) {
1649
- return { safe: false, reason: "absolute URL not in allowed hosts" };
1650
- }
1651
- if (!allowedHosts.some((h) => hostname === h.toLowerCase())) {
1652
- return { safe: false, reason: `host not allowed: ${hostname}` };
1653
- }
1654
- return { safe: true };
1655
- }
1656
- function isRedirectSafe(url, options = {}) {
1657
- return validateRedirect(url, options).safe;
1658
- }
1659
- function extractHost(url) {
1660
- const match = url.match(/^\/\/([^/:?#]+)/);
1661
- return match ? match[1].toLowerCase() : null;
2961
+ components.sort();
2962
+ const hash = crypto.createHash("sha256");
2963
+ hash.update(components.join("|"));
2964
+ return hash.digest("hex");
1662
2965
  }
1663
2966
 
1664
2967
  // src/stores/memory.ts
@@ -1813,7 +3116,9 @@ exports.SecurityThreatError = SecurityThreatError;
1813
3116
  exports.VALIDATION = VALIDATION;
1814
3117
  exports.arcis = arcis;
1815
3118
  exports.arcisFunction = arcisWithMethods;
3119
+ exports.botProtection = botProtection;
1816
3120
  exports.createCors = createCors;
3121
+ exports.createCsrf = createCsrf;
1817
3122
  exports.createErrorHandler = createErrorHandler;
1818
3123
  exports.createHeaders = createHeaders;
1819
3124
  exports.createRateLimiter = createRateLimiter;
@@ -1822,39 +3127,64 @@ exports.createRedisStore = createRedisStore;
1822
3127
  exports.createSafeLogger = createSafeLogger;
1823
3128
  exports.createSanitizer = createSanitizer;
1824
3129
  exports.createSecureCookies = createSecureCookies;
3130
+ exports.createSlidingWindowLimiter = createSlidingWindowLimiter;
3131
+ exports.createTokenBucketLimiter = createTokenBucketLimiter;
1825
3132
  exports.createValidator = createValidator;
3133
+ exports.csrfProtection = csrfProtection;
1826
3134
  exports.default = main_default;
3135
+ exports.detectBot = detectBot;
3136
+ exports.detectClientIp = detectClientIp;
1827
3137
  exports.detectCommandInjection = detectCommandInjection;
1828
3138
  exports.detectHeaderInjection = detectHeaderInjection;
3139
+ exports.detectJsonpInjection = detectJsonpInjection;
1829
3140
  exports.detectNoSqlInjection = detectNoSqlInjection;
1830
3141
  exports.detectPathTraversal = detectPathTraversal;
3142
+ exports.detectPii = detectPii;
1831
3143
  exports.detectPrototypePollution = detectPrototypePollution;
1832
3144
  exports.detectSql = detectSql;
3145
+ exports.detectSsti = detectSsti;
1833
3146
  exports.detectXss = detectXss;
3147
+ exports.detectXxe = detectXxe;
1834
3148
  exports.enforceSecureCookie = enforceSecureCookie;
1835
3149
  exports.errorHandler = errorHandler;
3150
+ exports.fingerprint = fingerprint;
3151
+ exports.formatDuration = formatDuration;
3152
+ exports.generateCsrfToken = generateCsrfToken;
1836
3153
  exports.isDangerousExtension = isDangerousExtension;
1837
3154
  exports.isDangerousNoSqlKey = isDangerousNoSqlKey;
1838
3155
  exports.isDangerousProtoKey = isDangerousProtoKey;
3156
+ exports.isPrivateIp = isPrivateIp;
1839
3157
  exports.isRedirectSafe = isRedirectSafe;
1840
3158
  exports.isUrlSafe = isUrlSafe;
3159
+ exports.isValidEmailSyntax = isValidEmailSyntax;
3160
+ exports.parseDuration = parseDuration;
1841
3161
  exports.rateLimit = rateLimit;
3162
+ exports.redactObjectPii = redactObjectPii;
3163
+ exports.redactPii = redactPii;
1842
3164
  exports.safeCors = safeCors;
1843
3165
  exports.safeLog = safeLog;
1844
3166
  exports.sanitizeCommand = sanitizeCommand;
1845
3167
  exports.sanitizeFilename = sanitizeFilename;
1846
3168
  exports.sanitizeHeaderValue = sanitizeHeaderValue;
1847
3169
  exports.sanitizeHeaders = sanitizeHeaders;
3170
+ exports.sanitizeJsonpCallback = sanitizeJsonpCallback;
1848
3171
  exports.sanitizeObject = sanitizeObject;
1849
3172
  exports.sanitizePath = sanitizePath;
1850
3173
  exports.sanitizeSql = sanitizeSql;
3174
+ exports.sanitizeSsti = sanitizeSsti;
1851
3175
  exports.sanitizeString = sanitizeString;
1852
3176
  exports.sanitizeXss = sanitizeXss;
3177
+ exports.sanitizeXxe = sanitizeXxe;
3178
+ exports.scanObjectPii = scanObjectPii;
3179
+ exports.scanPii = scanPii;
1853
3180
  exports.secureCookieDefaults = secureCookieDefaults;
1854
3181
  exports.securityHeaders = securityHeaders;
1855
3182
  exports.validate = validate;
3183
+ exports.validateCsrfToken = validateCsrfToken;
3184
+ exports.validateEmail = validateEmail;
1856
3185
  exports.validateFile = validateFile;
1857
3186
  exports.validateRedirect = validateRedirect;
1858
3187
  exports.validateUrl = validateUrl;
3188
+ exports.verifyEmailMx = verifyEmailMx;
1859
3189
  //# sourceMappingURL=index.js.map
1860
3190
  //# sourceMappingURL=index.js.map