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