@arcis/node 1.0.0 → 1.1.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.
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) */
@@ -1270,6 +1273,397 @@ function isDangerousExtension(filename) {
1270
1273
  return ext !== "" && DANGEROUS_EXTENSIONS.has(ext);
1271
1274
  }
1272
1275
 
1276
+ // src/validation/url.ts
1277
+ function validateUrl(url, options = {}) {
1278
+ const {
1279
+ allowedProtocols = ["http:", "https:"],
1280
+ blockedHosts = [],
1281
+ allowedHosts = [],
1282
+ allowLocalhost = false,
1283
+ allowPrivate = false
1284
+ } = options;
1285
+ if (typeof url !== "string" || url.trim() === "") {
1286
+ return { safe: false, reason: "invalid URL: empty or not a string" };
1287
+ }
1288
+ let parsed;
1289
+ try {
1290
+ parsed = new URL(url);
1291
+ } catch {
1292
+ return { safe: false, reason: "invalid URL: failed to parse" };
1293
+ }
1294
+ if (!allowedProtocols.includes(parsed.protocol)) {
1295
+ return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
1296
+ }
1297
+ if (parsed.username || parsed.password) {
1298
+ return { safe: false, reason: "URL contains credentials" };
1299
+ }
1300
+ const hostname = parsed.hostname.toLowerCase();
1301
+ if (allowedHosts.some((h) => hostname === h.toLowerCase())) {
1302
+ return { safe: true };
1303
+ }
1304
+ if (blockedHosts.some((h) => hostname === h.toLowerCase())) {
1305
+ return { safe: false, reason: `blocked host: ${hostname}` };
1306
+ }
1307
+ if (!allowLocalhost) {
1308
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname === "0.0.0.0" || hostname.endsWith(".localhost")) {
1309
+ return { safe: false, reason: "loopback address" };
1310
+ }
1311
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1312
+ return { safe: false, reason: "loopback address" };
1313
+ }
1314
+ }
1315
+ if (!allowPrivate) {
1316
+ const privateCheck = checkPrivateIp(hostname);
1317
+ if (privateCheck) {
1318
+ return { safe: false, reason: privateCheck };
1319
+ }
1320
+ }
1321
+ return { safe: true };
1322
+ }
1323
+ function isUrlSafe(url, options = {}) {
1324
+ return validateUrl(url, options).safe;
1325
+ }
1326
+ function checkPrivateIp(hostname) {
1327
+ if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1328
+ return "private address (10.0.0.0/8)";
1329
+ }
1330
+ const match172 = hostname.match(/^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
1331
+ if (match172) {
1332
+ const second = parseInt(match172[1], 10);
1333
+ if (second >= 16 && second <= 31) {
1334
+ return "private address (172.16.0.0/12)";
1335
+ }
1336
+ }
1337
+ if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1338
+ return "private address (192.168.0.0/16)";
1339
+ }
1340
+ if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1341
+ return "link-local address (169.254.0.0/16)";
1342
+ }
1343
+ if (/^0\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
1344
+ return "current network address (0.0.0.0/8)";
1345
+ }
1346
+ if (hostname === "metadata.google.internal" || hostname === "metadata.internal") {
1347
+ return "cloud metadata endpoint";
1348
+ }
1349
+ const ipv6 = hostname.replace(/^\[|\]$/g, "");
1350
+ if (ipv6 === "::1" || ipv6 === "::" || ipv6.startsWith("fc") || ipv6.startsWith("fd") || ipv6.startsWith("fe80")) {
1351
+ return "private IPv6 address";
1352
+ }
1353
+ return null;
1354
+ }
1355
+
1356
+ // src/validation/redirect.ts
1357
+ var DANGEROUS_PROTOCOLS = /^(javascript|data|vbscript|blob):/i;
1358
+ var CONTROL_CHARS = /[\t\n\r]/g;
1359
+ function validateRedirect(url, options = {}) {
1360
+ const {
1361
+ allowedHosts = [],
1362
+ allowProtocolRelative = false,
1363
+ allowedProtocols = ["http:", "https:"]
1364
+ } = options;
1365
+ if (typeof url !== "string" || url.trim() === "") {
1366
+ return { safe: false, reason: "invalid redirect: empty or not a string" };
1367
+ }
1368
+ const cleaned = url.replace(CONTROL_CHARS, "");
1369
+ if (DANGEROUS_PROTOCOLS.test(cleaned)) {
1370
+ const proto = cleaned.match(DANGEROUS_PROTOCOLS);
1371
+ return { safe: false, reason: `dangerous protocol: ${proto[0]}` };
1372
+ }
1373
+ if (cleaned.startsWith("\\")) {
1374
+ return { safe: false, reason: "backslash-prefixed URL (browser treats as protocol-relative)" };
1375
+ }
1376
+ if (cleaned.startsWith("//")) {
1377
+ if (!allowProtocolRelative) {
1378
+ const host2 = extractHost(cleaned);
1379
+ if (host2 && allowedHosts.some((h) => host2 === h.toLowerCase())) {
1380
+ return { safe: true };
1381
+ }
1382
+ return { safe: false, reason: "protocol-relative URL not in allowed hosts" };
1383
+ }
1384
+ const host = extractHost(cleaned);
1385
+ if (host && allowedHosts.length > 0 && !allowedHosts.some((h) => host === h.toLowerCase())) {
1386
+ return { safe: false, reason: "protocol-relative URL not in allowed hosts" };
1387
+ }
1388
+ return { safe: true };
1389
+ }
1390
+ let parsed;
1391
+ try {
1392
+ parsed = new URL(cleaned);
1393
+ } catch {
1394
+ return { safe: true };
1395
+ }
1396
+ if (!allowedProtocols.includes(parsed.protocol)) {
1397
+ return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
1398
+ }
1399
+ const hostname = parsed.hostname.toLowerCase();
1400
+ if (allowedHosts.length === 0) {
1401
+ return { safe: false, reason: "absolute URL not in allowed hosts" };
1402
+ }
1403
+ if (!allowedHosts.some((h) => hostname === h.toLowerCase())) {
1404
+ return { safe: false, reason: `host not allowed: ${hostname}` };
1405
+ }
1406
+ return { safe: true };
1407
+ }
1408
+ function isRedirectSafe(url, options = {}) {
1409
+ return validateRedirect(url, options).safe;
1410
+ }
1411
+ function extractHost(url) {
1412
+ const match = url.match(/^\/\/([^/:?#]+)/);
1413
+ return match ? match[1].toLowerCase() : null;
1414
+ }
1415
+ var MAX_EMAIL_LENGTH = 254;
1416
+ var MAX_LOCAL_LENGTH = 64;
1417
+ var MAX_DOMAIN_LENGTH = 255;
1418
+ 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,}$/;
1419
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set([
1420
+ "gmail.com",
1421
+ "yahoo.com",
1422
+ "hotmail.com",
1423
+ "outlook.com",
1424
+ "aol.com",
1425
+ "protonmail.com",
1426
+ "proton.me",
1427
+ "icloud.com",
1428
+ "mail.com",
1429
+ "zoho.com",
1430
+ "yandex.com",
1431
+ "gmx.com",
1432
+ "gmx.net",
1433
+ "live.com",
1434
+ "msn.com",
1435
+ "me.com",
1436
+ "mac.com",
1437
+ "fastmail.com",
1438
+ "tutanota.com",
1439
+ "hey.com"
1440
+ ]);
1441
+ var DISPOSABLE_DOMAINS = /* @__PURE__ */ new Set([
1442
+ // Popular disposable services
1443
+ "guerrillamail.com",
1444
+ "guerrillamail.net",
1445
+ "guerrillamail.org",
1446
+ "tempmail.com",
1447
+ "temp-mail.org",
1448
+ "temp-mail.io",
1449
+ "throwaway.email",
1450
+ "throwaway.com",
1451
+ "mailinator.com",
1452
+ "mailinator.net",
1453
+ "yopmail.com",
1454
+ "yopmail.fr",
1455
+ "yopmail.net",
1456
+ "sharklasers.com",
1457
+ "grr.la",
1458
+ "guerrillamail.info",
1459
+ "guerrillamail.biz",
1460
+ "guerrillamail.de",
1461
+ "trashmail.com",
1462
+ "trashmail.me",
1463
+ "trashmail.net",
1464
+ "dispostable.com",
1465
+ "maildrop.cc",
1466
+ "mailnesia.com",
1467
+ "tempail.com",
1468
+ "mohmal.com",
1469
+ "getnada.com",
1470
+ "emailondeck.com",
1471
+ "discard.email",
1472
+ "fakeinbox.com",
1473
+ "mailcatch.com",
1474
+ "mintemail.com",
1475
+ "tempr.email",
1476
+ "tempinbox.com",
1477
+ "burnermail.io",
1478
+ "mailsac.com",
1479
+ "harakirimail.com",
1480
+ "tempmailo.com",
1481
+ "emailfake.com",
1482
+ "crazymailing.com",
1483
+ "armyspy.com",
1484
+ "dayrep.com",
1485
+ "einrot.com",
1486
+ "fleckens.hu",
1487
+ "gustr.com",
1488
+ "jourrapide.com",
1489
+ "rhyta.com",
1490
+ "superrito.com",
1491
+ "teleworm.us",
1492
+ "10minutemail.com",
1493
+ "10minutemail.net",
1494
+ "minutemail.com",
1495
+ "tempsky.com",
1496
+ "spamgourmet.com",
1497
+ "mytrashmail.com",
1498
+ "mailexpire.com",
1499
+ "safetymail.info",
1500
+ "filzmail.com",
1501
+ "trashymail.com",
1502
+ "sharkmail.com",
1503
+ "jetable.org",
1504
+ "nospam.ze.tc",
1505
+ "trash-me.com",
1506
+ "dodgit.com",
1507
+ "mailmoat.com",
1508
+ "spamfree24.org",
1509
+ "incognitomail.org",
1510
+ "tempomail.fr",
1511
+ "ephemail.net",
1512
+ "hidemail.de",
1513
+ "spaml.de",
1514
+ "uggsrock.com",
1515
+ "binkmail.com",
1516
+ "suremail.info",
1517
+ "bugmenot.com"
1518
+ ]);
1519
+ var DOMAIN_TYPOS = {
1520
+ "gmial.com": "gmail.com",
1521
+ "gmaill.com": "gmail.com",
1522
+ "gmai.com": "gmail.com",
1523
+ "gamil.com": "gmail.com",
1524
+ "gnail.com": "gmail.com",
1525
+ "gmal.com": "gmail.com",
1526
+ "gmil.com": "gmail.com",
1527
+ "gmail.co": "gmail.com",
1528
+ "gmail.cm": "gmail.com",
1529
+ "gmail.om": "gmail.com",
1530
+ "gmail.con": "gmail.com",
1531
+ "gmail.cim": "gmail.com",
1532
+ "gmail.comm": "gmail.com",
1533
+ "yahooo.com": "yahoo.com",
1534
+ "yaho.com": "yahoo.com",
1535
+ "yahoo.co": "yahoo.com",
1536
+ "yahoo.cm": "yahoo.com",
1537
+ "yahoo.con": "yahoo.com",
1538
+ "yahho.com": "yahoo.com",
1539
+ "hotmial.com": "hotmail.com",
1540
+ "hotmal.com": "hotmail.com",
1541
+ "hotmai.com": "hotmail.com",
1542
+ "hotmil.com": "hotmail.com",
1543
+ "hotmail.co": "hotmail.com",
1544
+ "hotmail.cm": "hotmail.com",
1545
+ "hotmail.con": "hotmail.com",
1546
+ "outlok.com": "outlook.com",
1547
+ "outloo.com": "outlook.com",
1548
+ "outlook.co": "outlook.com",
1549
+ "outlook.cm": "outlook.com",
1550
+ "protonmal.com": "protonmail.com",
1551
+ "protonmail.co": "protonmail.com",
1552
+ "icloud.co": "icloud.com",
1553
+ "icloud.cm": "icloud.com",
1554
+ "icoud.com": "icloud.com"
1555
+ };
1556
+ function invalidResult(reason, email) {
1557
+ return {
1558
+ valid: false,
1559
+ reason,
1560
+ suggestion: null,
1561
+ isFree: false,
1562
+ isDisposable: false,
1563
+ normalized: email
1564
+ };
1565
+ }
1566
+ function validateEmail(email, options = {}) {
1567
+ const {
1568
+ checkDisposable = true,
1569
+ suggestTypoFix = true,
1570
+ blockedDomains = [],
1571
+ allowedDomains = []
1572
+ } = options;
1573
+ const normalized = email.trim().toLowerCase();
1574
+ if (!normalized || normalized.length > MAX_EMAIL_LENGTH) {
1575
+ return invalidResult("invalid_syntax", normalized);
1576
+ }
1577
+ const atIndex = normalized.lastIndexOf("@");
1578
+ if (atIndex === -1) {
1579
+ return invalidResult("invalid_syntax", normalized);
1580
+ }
1581
+ const localPart = normalized.slice(0, atIndex);
1582
+ const domain = normalized.slice(atIndex + 1);
1583
+ if (localPart.length === 0 || localPart.length > MAX_LOCAL_LENGTH) {
1584
+ return invalidResult("invalid_syntax", normalized);
1585
+ }
1586
+ if (domain.length === 0 || domain.length > MAX_DOMAIN_LENGTH) {
1587
+ return invalidResult("invalid_syntax", normalized);
1588
+ }
1589
+ if (localPart.includes("..")) {
1590
+ return invalidResult("invalid_syntax", normalized);
1591
+ }
1592
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
1593
+ return invalidResult("invalid_syntax", normalized);
1594
+ }
1595
+ if (!EMAIL_SYNTAX.test(normalized)) {
1596
+ return invalidResult("invalid_syntax", normalized);
1597
+ }
1598
+ const allowedSet = new Set(allowedDomains.map((d) => d.toLowerCase()));
1599
+ if (allowedSet.has(domain)) {
1600
+ return {
1601
+ valid: true,
1602
+ reason: "valid",
1603
+ suggestion: null,
1604
+ isFree: FREE_PROVIDERS.has(domain),
1605
+ isDisposable: false,
1606
+ normalized
1607
+ };
1608
+ }
1609
+ const blockedSet = new Set(blockedDomains.map((d) => d.toLowerCase()));
1610
+ if (blockedSet.has(domain)) {
1611
+ return invalidResult("blocked", normalized);
1612
+ }
1613
+ const isDisposable = DISPOSABLE_DOMAINS.has(domain);
1614
+ if (checkDisposable && isDisposable) {
1615
+ return {
1616
+ valid: false,
1617
+ reason: "disposable",
1618
+ suggestion: null,
1619
+ isFree: false,
1620
+ isDisposable: true,
1621
+ normalized
1622
+ };
1623
+ }
1624
+ const isFree = FREE_PROVIDERS.has(domain);
1625
+ if (suggestTypoFix && DOMAIN_TYPOS[domain]) {
1626
+ const corrected = `${localPart}@${DOMAIN_TYPOS[domain]}`;
1627
+ return {
1628
+ valid: true,
1629
+ reason: "typo",
1630
+ suggestion: corrected,
1631
+ isFree: FREE_PROVIDERS.has(DOMAIN_TYPOS[domain]),
1632
+ isDisposable: false,
1633
+ normalized
1634
+ };
1635
+ }
1636
+ return {
1637
+ valid: true,
1638
+ reason: "valid",
1639
+ suggestion: null,
1640
+ isFree,
1641
+ isDisposable,
1642
+ normalized
1643
+ };
1644
+ }
1645
+ async function verifyEmailMx(email) {
1646
+ if (!isValidEmailSyntax(email)) return false;
1647
+ const atIndex = email.lastIndexOf("@");
1648
+ const domain = email.slice(atIndex + 1).trim().toLowerCase();
1649
+ if (!domain) return false;
1650
+ try {
1651
+ const records = await dns.promises.resolveMx(domain);
1652
+ return records.length > 0;
1653
+ } catch {
1654
+ return false;
1655
+ }
1656
+ }
1657
+ function isValidEmailSyntax(email) {
1658
+ const normalized = email.trim().toLowerCase();
1659
+ if (!normalized || normalized.length > MAX_EMAIL_LENGTH) return false;
1660
+ const atIndex = normalized.lastIndexOf("@");
1661
+ if (atIndex === -1) return false;
1662
+ const localPart = normalized.slice(0, atIndex);
1663
+ if (localPart.includes("..") || localPart.startsWith(".") || localPart.endsWith(".")) return false;
1664
+ return EMAIL_SYNTAX.test(normalized);
1665
+ }
1666
+
1273
1667
  // src/logging/redactor.ts
1274
1668
  function createSafeLogger(options = {}) {
1275
1669
  const {
@@ -1391,6 +1785,201 @@ arcisWithMethods.logger = createSafeLogger;
1391
1785
  arcisWithMethods.errorHandler = createErrorHandler;
1392
1786
  var main_default = arcisWithMethods;
1393
1787
 
1788
+ // src/utils/duration.ts
1789
+ var MAX_DURATION_MS = 4294967295;
1790
+ var DURATION_REGEX = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i;
1791
+ var UNIT_TO_MS = {
1792
+ ms: 1,
1793
+ s: 1e3,
1794
+ m: 6e4,
1795
+ h: 36e5,
1796
+ d: 864e5
1797
+ };
1798
+ function parseDuration(value) {
1799
+ if (typeof value === "number") {
1800
+ if (!Number.isFinite(value) || value < 0) {
1801
+ throw new Error(`Invalid duration: ${value}. Must be a non-negative finite number.`);
1802
+ }
1803
+ return Math.min(Math.floor(value), MAX_DURATION_MS);
1804
+ }
1805
+ if (typeof value !== "string" || value.trim() === "") {
1806
+ throw new Error(`Invalid duration: "${value}". Expected a duration string (e.g. "5m", "2h") or number.`);
1807
+ }
1808
+ const match = value.trim().match(DURATION_REGEX);
1809
+ if (!match) {
1810
+ throw new Error(
1811
+ `Invalid duration: "${value}". Expected format: <number><unit> where unit is ms, s, m, h, or d.`
1812
+ );
1813
+ }
1814
+ const amount = parseFloat(match[1]);
1815
+ const unit = match[2].toLowerCase();
1816
+ const ms = Math.floor(amount * UNIT_TO_MS[unit]);
1817
+ if (ms < 0 || ms > MAX_DURATION_MS) {
1818
+ throw new Error(`Duration "${value}" exceeds maximum allowed (${MAX_DURATION_MS}ms / ~49.7 days).`);
1819
+ }
1820
+ return ms;
1821
+ }
1822
+ function formatDuration(ms) {
1823
+ if (!Number.isFinite(ms) || ms < 0) return "0ms";
1824
+ if (ms < 1e3) return `${ms}ms`;
1825
+ const days = Math.floor(ms / 864e5);
1826
+ const hours = Math.floor(ms % 864e5 / 36e5);
1827
+ const minutes = Math.floor(ms % 36e5 / 6e4);
1828
+ const seconds = Math.floor(ms % 6e4 / 1e3);
1829
+ const parts = [];
1830
+ if (days > 0) parts.push(`${days}d`);
1831
+ if (hours > 0) parts.push(`${hours}h`);
1832
+ if (minutes > 0) parts.push(`${minutes}m`);
1833
+ if (seconds > 0) parts.push(`${seconds}s`);
1834
+ return parts.join(" ") || "0ms";
1835
+ }
1836
+
1837
+ // src/middleware/rate-limit-sliding.ts
1838
+ function createSlidingWindowLimiter(options = {}) {
1839
+ const {
1840
+ max = RATE_LIMIT.DEFAULT_MAX_REQUESTS,
1841
+ window: windowOpt = RATE_LIMIT.DEFAULT_WINDOW_MS,
1842
+ message = RATE_LIMIT.DEFAULT_MESSAGE,
1843
+ statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
1844
+ keyGenerator = (req) => req.ip ?? req.socket?.remoteAddress ?? "unknown",
1845
+ skip
1846
+ } = options;
1847
+ const windowMs = parseDuration(windowOpt);
1848
+ const currentWindows = /* @__PURE__ */ Object.create(null);
1849
+ const previousWindows = /* @__PURE__ */ Object.create(null);
1850
+ const cleanupInterval = setInterval(() => {
1851
+ const now = Date.now();
1852
+ const cutoff = now - windowMs * 2;
1853
+ for (const key of Object.keys(previousWindows)) {
1854
+ if (previousWindows[key].startTime < cutoff) {
1855
+ delete previousWindows[key];
1856
+ }
1857
+ }
1858
+ for (const key of Object.keys(currentWindows)) {
1859
+ if (currentWindows[key].startTime < cutoff) {
1860
+ delete currentWindows[key];
1861
+ }
1862
+ }
1863
+ }, windowMs);
1864
+ if (typeof cleanupInterval.unref === "function") {
1865
+ cleanupInterval.unref();
1866
+ }
1867
+ const handler = (req, res, next) => {
1868
+ try {
1869
+ if (skip?.(req)) return next();
1870
+ const key = keyGenerator(req);
1871
+ const now = Date.now();
1872
+ const windowStart = Math.floor(now / windowMs) * windowMs;
1873
+ if (!currentWindows[key] || currentWindows[key].startTime < windowStart) {
1874
+ if (currentWindows[key]) {
1875
+ previousWindows[key] = currentWindows[key];
1876
+ }
1877
+ currentWindows[key] = { count: 0, startTime: windowStart };
1878
+ }
1879
+ const elapsed = now - windowStart;
1880
+ const weight = Math.max(0, (windowMs - elapsed) / windowMs);
1881
+ const prevCount = previousWindows[key]?.count ?? 0;
1882
+ const estimatedCount = prevCount * weight + currentWindows[key].count + 1;
1883
+ const remaining = Math.max(0, Math.floor(max - estimatedCount));
1884
+ const resetMs = windowStart + windowMs - now;
1885
+ const resetSeconds = Math.max(1, Math.ceil(resetMs / 1e3));
1886
+ res.setHeader("X-RateLimit-Limit", max.toString());
1887
+ res.setHeader("X-RateLimit-Remaining", remaining.toString());
1888
+ res.setHeader("X-RateLimit-Reset", resetSeconds.toString());
1889
+ res.setHeader("X-RateLimit-Policy", `${max};w=${Math.floor(windowMs / 1e3)}`);
1890
+ if (estimatedCount > max) {
1891
+ res.setHeader("Retry-After", resetSeconds.toString());
1892
+ res.status(statusCode).json({
1893
+ error: message,
1894
+ retryAfter: resetSeconds
1895
+ });
1896
+ return;
1897
+ }
1898
+ currentWindows[key].count++;
1899
+ next();
1900
+ } catch (error) {
1901
+ console.error("[arcis] Sliding window rate limiter error:", error);
1902
+ next();
1903
+ }
1904
+ };
1905
+ const middleware = handler;
1906
+ middleware.close = () => {
1907
+ clearInterval(cleanupInterval);
1908
+ };
1909
+ return middleware;
1910
+ }
1911
+
1912
+ // src/middleware/rate-limit-token.ts
1913
+ function createTokenBucketLimiter(options = {}) {
1914
+ const {
1915
+ capacity = 100,
1916
+ refillRate = 10,
1917
+ cost = 1,
1918
+ message = RATE_LIMIT.DEFAULT_MESSAGE,
1919
+ statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
1920
+ keyGenerator = (req) => req.ip ?? req.socket?.remoteAddress ?? "unknown",
1921
+ skip
1922
+ } = options;
1923
+ if (capacity < 1) throw new RangeError(`Token bucket capacity must be >= 1, got ${capacity}`);
1924
+ if (refillRate <= 0) throw new RangeError(`Token bucket refillRate must be > 0, got ${refillRate}`);
1925
+ if (cost < 1) throw new RangeError(`Token bucket cost must be >= 1, got ${cost}`);
1926
+ if (cost > capacity) throw new RangeError(`Token bucket cost (${cost}) must be <= capacity (${capacity}), otherwise all requests are permanently denied`);
1927
+ const buckets = /* @__PURE__ */ Object.create(null);
1928
+ const cleanupInterval = setInterval(() => {
1929
+ const now = Date.now();
1930
+ const staleThreshold = capacity / refillRate * 1e3 * 2;
1931
+ for (const key of Object.keys(buckets)) {
1932
+ if (now - buckets[key].lastRefill > staleThreshold) {
1933
+ delete buckets[key];
1934
+ }
1935
+ }
1936
+ }, 6e4);
1937
+ if (typeof cleanupInterval.unref === "function") {
1938
+ cleanupInterval.unref();
1939
+ }
1940
+ function refillBucket(bucket, now) {
1941
+ const elapsed = (now - bucket.lastRefill) / 1e3;
1942
+ const tokensToAdd = elapsed * refillRate;
1943
+ bucket.tokens = Math.min(capacity, bucket.tokens + tokensToAdd);
1944
+ bucket.lastRefill = now;
1945
+ }
1946
+ const handler = (req, res, next) => {
1947
+ try {
1948
+ if (skip?.(req)) return next();
1949
+ const key = keyGenerator(req);
1950
+ const now = Date.now();
1951
+ if (!buckets[key]) {
1952
+ buckets[key] = { tokens: capacity, lastRefill: now };
1953
+ }
1954
+ const bucket = buckets[key];
1955
+ refillBucket(bucket, now);
1956
+ const retryAfterSec = bucket.tokens < cost ? Math.ceil((cost - bucket.tokens) / refillRate) : 0;
1957
+ res.setHeader("X-RateLimit-Limit", capacity.toString());
1958
+ res.setHeader("X-RateLimit-Remaining", Math.floor(Math.max(0, bucket.tokens - cost)).toString());
1959
+ res.setHeader("X-RateLimit-Policy", `${capacity};w=${Math.floor(capacity / refillRate)};burst=${capacity}`);
1960
+ if (bucket.tokens < cost) {
1961
+ res.setHeader("Retry-After", retryAfterSec.toString());
1962
+ res.setHeader("X-RateLimit-Reset", retryAfterSec.toString());
1963
+ res.status(statusCode).json({
1964
+ error: message,
1965
+ retryAfter: retryAfterSec
1966
+ });
1967
+ return;
1968
+ }
1969
+ bucket.tokens -= cost;
1970
+ next();
1971
+ } catch (error) {
1972
+ console.error("[arcis] Token bucket rate limiter error:", error);
1973
+ next();
1974
+ }
1975
+ };
1976
+ const middleware = handler;
1977
+ middleware.close = () => {
1978
+ clearInterval(cleanupInterval);
1979
+ };
1980
+ return middleware;
1981
+ }
1982
+
1394
1983
  // src/middleware/cors.ts
1395
1984
  var DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
1396
1985
  var DEFAULT_HEADERS = ["Content-Type", "Authorization"];
@@ -1521,144 +2110,325 @@ function secureCookieDefaults(options = {}) {
1521
2110
  }
1522
2111
  var createSecureCookies = secureCookieDefaults;
1523
2112
 
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" };
2113
+ // src/middleware/bot-detection.ts
2114
+ var BOT_PATTERNS = [
2115
+ // --- SEARCH ENGINES (specific variants before generic) ---
2116
+ { pattern: /Googlebot-Image/i, name: "Googlebot-Image", category: "SEARCH_ENGINE" },
2117
+ { pattern: /Googlebot-Video/i, name: "Googlebot-Video", category: "SEARCH_ENGINE" },
2118
+ { pattern: /Googlebot-News/i, name: "Googlebot-News", category: "SEARCH_ENGINE" },
2119
+ { pattern: /Googlebot/i, name: "Googlebot", category: "SEARCH_ENGINE" },
2120
+ { pattern: /AdsBot-Google/i, name: "AdsBot-Google", category: "SEARCH_ENGINE" },
2121
+ { pattern: /Mediapartners-Google/i, name: "Mediapartners-Google", category: "SEARCH_ENGINE" },
2122
+ { pattern: /Bingbot/i, name: "Bingbot", category: "SEARCH_ENGINE" },
2123
+ { pattern: /msnbot/i, name: "msnbot", category: "SEARCH_ENGINE" },
2124
+ { pattern: /Slurp/i, name: "Yahoo Slurp", category: "SEARCH_ENGINE" },
2125
+ { pattern: /DuckDuckBot/i, name: "DuckDuckBot", category: "SEARCH_ENGINE" },
2126
+ { pattern: /Baiduspider/i, name: "Baiduspider", category: "SEARCH_ENGINE" },
2127
+ { pattern: /YandexBot/i, name: "YandexBot", category: "SEARCH_ENGINE" },
2128
+ { pattern: /YandexImages/i, name: "YandexImages", category: "SEARCH_ENGINE" },
2129
+ { pattern: /Sogou/i, name: "Sogou", category: "SEARCH_ENGINE" },
2130
+ { pattern: /Exabot/i, name: "Exabot", category: "SEARCH_ENGINE" },
2131
+ { pattern: /ia_archiver/i, name: "Alexa", category: "SEARCH_ENGINE" },
2132
+ { pattern: /Applebot/i, name: "Applebot", category: "SEARCH_ENGINE" },
2133
+ { pattern: /Qwantify/i, name: "Qwantify", category: "SEARCH_ENGINE" },
2134
+ { pattern: /PetalBot/i, name: "PetalBot", category: "SEARCH_ENGINE" },
2135
+ { pattern: /SeznamBot/i, name: "SeznamBot", category: "SEARCH_ENGINE" },
2136
+ // --- SOCIAL ---
2137
+ { pattern: /Twitterbot/i, name: "Twitterbot", category: "SOCIAL" },
2138
+ { pattern: /facebookexternalhit/i, name: "Facebook", category: "SOCIAL" },
2139
+ { pattern: /Facebot/i, name: "Facebot", category: "SOCIAL" },
2140
+ { pattern: /LinkedInBot/i, name: "LinkedInBot", category: "SOCIAL" },
2141
+ { pattern: /Pinterest/i, name: "Pinterest", category: "SOCIAL" },
2142
+ { pattern: /Slackbot/i, name: "Slackbot", category: "SOCIAL" },
2143
+ { pattern: /TelegramBot/i, name: "TelegramBot", category: "SOCIAL" },
2144
+ { pattern: /WhatsApp/i, name: "WhatsApp", category: "SOCIAL" },
2145
+ { pattern: /Discordbot/i, name: "Discordbot", category: "SOCIAL" },
2146
+ { pattern: /Redditbot/i, name: "Redditbot", category: "SOCIAL" },
2147
+ { pattern: /Embedly/i, name: "Embedly", category: "SOCIAL" },
2148
+ { pattern: /Quora Link Preview/i, name: "Quora", category: "SOCIAL" },
2149
+ { pattern: /Mastodon/i, name: "Mastodon", category: "SOCIAL" },
2150
+ // --- MONITORING ---
2151
+ { pattern: /UptimeRobot/i, name: "UptimeRobot", category: "MONITORING" },
2152
+ { pattern: /Pingdom/i, name: "Pingdom", category: "MONITORING" },
2153
+ { pattern: /Site24x7/i, name: "Site24x7", category: "MONITORING" },
2154
+ { pattern: /StatusCake/i, name: "StatusCake", category: "MONITORING" },
2155
+ { pattern: /Datadog/i, name: "Datadog", category: "MONITORING" },
2156
+ { pattern: /NewRelicPinger/i, name: "New Relic", category: "MONITORING" },
2157
+ { pattern: /Better Uptime Bot/i, name: "Better Uptime", category: "MONITORING" },
2158
+ { pattern: /GTmetrix/i, name: "GTmetrix", category: "MONITORING" },
2159
+ { pattern: /PageSpeed/i, name: "PageSpeed Insights", category: "MONITORING" },
2160
+ // --- AI CRAWLERS ---
2161
+ { pattern: /GPTBot/i, name: "GPTBot", category: "AI_CRAWLER" },
2162
+ { pattern: /ChatGPT-User/i, name: "ChatGPT-User", category: "AI_CRAWLER" },
2163
+ { pattern: /Claude-Web/i, name: "Claude-Web", category: "AI_CRAWLER" },
2164
+ { pattern: /ClaudeBot/i, name: "ClaudeBot", category: "AI_CRAWLER" },
2165
+ { pattern: /anthropic-ai/i, name: "Anthropic", category: "AI_CRAWLER" },
2166
+ { pattern: /Bytespider/i, name: "Bytespider", category: "AI_CRAWLER" },
2167
+ { pattern: /CCBot/i, name: "CCBot", category: "AI_CRAWLER" },
2168
+ { pattern: /cohere-ai/i, name: "Cohere", category: "AI_CRAWLER" },
2169
+ { pattern: /PerplexityBot/i, name: "PerplexityBot", category: "AI_CRAWLER" },
2170
+ { pattern: /YouBot/i, name: "YouBot", category: "AI_CRAWLER" },
2171
+ { pattern: /Google-Extended/i, name: "Google-Extended", category: "AI_CRAWLER" },
2172
+ { pattern: /Diffbot/i, name: "Diffbot", category: "AI_CRAWLER" },
2173
+ { pattern: /Amazonbot/i, name: "Amazonbot", category: "AI_CRAWLER" },
2174
+ { pattern: /meta-externalagent/i, name: "Meta AI", category: "AI_CRAWLER" },
2175
+ // --- AUTOMATED TOOLS (headless browsers, testing frameworks) ---
2176
+ { pattern: /HeadlessChrome/i, name: "Headless Chrome", category: "AUTOMATED" },
2177
+ { pattern: /PhantomJS/i, name: "PhantomJS", category: "AUTOMATED" },
2178
+ { pattern: /Selenium/i, name: "Selenium", category: "AUTOMATED" },
2179
+ { pattern: /Puppeteer/i, name: "Puppeteer", category: "AUTOMATED" },
2180
+ { pattern: /Playwright/i, name: "Playwright", category: "AUTOMATED" },
2181
+ { pattern: /Cypress/i, name: "Cypress", category: "AUTOMATED" },
2182
+ { pattern: /webdriver/i, name: "WebDriver", category: "AUTOMATED" },
2183
+ { pattern: /MSIE 6\.0/i, name: "Fake IE6", category: "AUTOMATED" },
2184
+ // --- SCRAPERS / CLI TOOLS ---
2185
+ { pattern: /^curl\//i, name: "curl", category: "SCRAPER" },
2186
+ { pattern: /^wget\//i, name: "wget", category: "SCRAPER" },
2187
+ { pattern: /^python-requests\//i, name: "python-requests", category: "SCRAPER" },
2188
+ { pattern: /^python-httpx\//i, name: "python-httpx", category: "SCRAPER" },
2189
+ { pattern: /^Python-urllib/i, name: "Python-urllib", category: "SCRAPER" },
2190
+ { pattern: /^aiohttp\//i, name: "aiohttp", category: "SCRAPER" },
2191
+ { pattern: /^Go-http-client/i, name: "Go-http-client", category: "SCRAPER" },
2192
+ { pattern: /^Java\//i, name: "Java HttpClient", category: "SCRAPER" },
2193
+ { pattern: /^Apache-HttpClient/i, name: "Apache HttpClient", category: "SCRAPER" },
2194
+ { pattern: /^okhttp\//i, name: "OkHttp", category: "SCRAPER" },
2195
+ { pattern: /^node-fetch\//i, name: "node-fetch", category: "SCRAPER" },
2196
+ { pattern: /^axios\//i, name: "axios", category: "SCRAPER" },
2197
+ { pattern: /^got\//i, name: "got", category: "SCRAPER" },
2198
+ { pattern: /^libwww-perl/i, name: "libwww-perl", category: "SCRAPER" },
2199
+ { pattern: /^Ruby/i, name: "Ruby", category: "SCRAPER" },
2200
+ { pattern: /^PHP\//i, name: "PHP", category: "SCRAPER" },
2201
+ { pattern: /Scrapy/i, name: "Scrapy", category: "SCRAPER" },
2202
+ { pattern: /^Postman/i, name: "Postman", category: "SCRAPER" },
2203
+ { pattern: /^Insomnia/i, name: "Insomnia", category: "SCRAPER" },
2204
+ { pattern: /^HTTPie\//i, name: "HTTPie", category: "SCRAPER" }
2205
+ ];
2206
+ function detectBehavioralSignals(req) {
2207
+ const signals = [];
2208
+ const headers = req.headers;
2209
+ if (!headers["user-agent"]) {
2210
+ signals.push("missing_user_agent");
1535
2211
  }
1536
- let parsed;
1537
- try {
1538
- parsed = new URL(url);
1539
- } catch {
1540
- return { safe: false, reason: "invalid URL: failed to parse" };
2212
+ if (!headers["accept"]) {
2213
+ signals.push("missing_accept");
1541
2214
  }
1542
- if (!allowedProtocols.includes(parsed.protocol)) {
1543
- return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
2215
+ if (!headers["accept-language"]) {
2216
+ signals.push("missing_accept_language");
1544
2217
  }
1545
- if (parsed.username || parsed.password) {
1546
- return { safe: false, reason: "URL contains credentials" };
2218
+ if (!headers["accept-encoding"]) {
2219
+ signals.push("missing_accept_encoding");
1547
2220
  }
1548
- const hostname = parsed.hostname.toLowerCase();
1549
- if (allowedHosts.some((h) => hostname === h.toLowerCase())) {
1550
- return { safe: true };
2221
+ if (headers["connection"] === "close") {
2222
+ signals.push("connection_close");
1551
2223
  }
1552
- if (blockedHosts.some((h) => hostname === h.toLowerCase())) {
1553
- return { safe: false, reason: `blocked host: ${hostname}` };
2224
+ return signals;
2225
+ }
2226
+ function detectBot(req) {
2227
+ const rawUa = req.headers["user-agent"] ?? "";
2228
+ const ua = rawUa.length > 2048 ? rawUa.slice(0, 2048) : rawUa;
2229
+ const signals = detectBehavioralSignals(req);
2230
+ if (!ua) {
2231
+ return {
2232
+ isBot: true,
2233
+ category: "UNKNOWN",
2234
+ name: null,
2235
+ confidence: 0.8,
2236
+ signals
2237
+ };
1554
2238
  }
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" };
2239
+ for (const bot of BOT_PATTERNS) {
2240
+ if (bot.pattern.test(ua)) {
2241
+ return {
2242
+ isBot: true,
2243
+ category: bot.category,
2244
+ name: bot.name,
2245
+ confidence: 0.95,
2246
+ signals
2247
+ };
1561
2248
  }
1562
2249
  }
1563
- if (!allowPrivate) {
1564
- const privateCheck = checkPrivateIp(hostname);
1565
- if (privateCheck) {
1566
- return { safe: false, reason: privateCheck };
1567
- }
2250
+ const behaviorScore = signals.length;
2251
+ if (behaviorScore >= 3) {
2252
+ return {
2253
+ isBot: true,
2254
+ category: "UNKNOWN",
2255
+ name: null,
2256
+ confidence: Math.min(1, 0.6 + behaviorScore * 0.1),
2257
+ signals
2258
+ };
1568
2259
  }
1569
- return { safe: true };
2260
+ return {
2261
+ isBot: false,
2262
+ category: "HUMAN",
2263
+ name: null,
2264
+ confidence: Math.max(0, 1 - behaviorScore * 0.15),
2265
+ signals
2266
+ };
1570
2267
  }
1571
- function isUrlSafe(url, options = {}) {
1572
- return validateUrl(url, options).safe;
2268
+ function botProtection(options = {}) {
2269
+ const {
2270
+ allow = ["SEARCH_ENGINE", "SOCIAL", "MONITORING"],
2271
+ deny = ["AUTOMATED"],
2272
+ defaultAction = "allow",
2273
+ statusCode = 403,
2274
+ message = "Access denied.",
2275
+ onDetected
2276
+ } = options;
2277
+ const allowSet = new Set(allow);
2278
+ const denySet = new Set(deny);
2279
+ return (req, res, next) => {
2280
+ const result = detectBot(req);
2281
+ req.botDetection = result;
2282
+ if (!result.isBot) {
2283
+ return next();
2284
+ }
2285
+ if (allowSet.has(result.category)) {
2286
+ return next();
2287
+ }
2288
+ if (denySet.has(result.category)) {
2289
+ if (onDetected) {
2290
+ return onDetected(req, res, result);
2291
+ }
2292
+ res.status(statusCode).json({ error: message });
2293
+ return;
2294
+ }
2295
+ if (defaultAction === "deny") {
2296
+ if (onDetected) {
2297
+ return onDetected(req, res, result);
2298
+ }
2299
+ res.status(statusCode).json({ error: message });
2300
+ return;
2301
+ }
2302
+ next();
2303
+ };
1573
2304
  }
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)";
2305
+
2306
+ // src/utils/ip.ts
2307
+ var PLATFORM_HEADERS = {
2308
+ cloudflare: "cf-connecting-ip",
2309
+ vercel: "x-real-ip",
2310
+ flyio: "fly-client-ip",
2311
+ render: "x-render-client-ip",
2312
+ firebase: "x-appengine-user-ip",
2313
+ "aws-alb": "x-forwarded-for"
2314
+ };
2315
+ function detectPlatform() {
2316
+ const env = typeof process !== "undefined" ? process.env : {};
2317
+ if (env.CF_PAGES || env.CF_WORKERS) return "cloudflare";
2318
+ if (env.VERCEL) return "vercel";
2319
+ if (env.FLY_APP_NAME) return "flyio";
2320
+ if (env.RENDER) return "render";
2321
+ if (env.FIREBASE_CONFIG || env.GCLOUD_PROJECT) return "firebase";
2322
+ if (env.AWS_EXECUTION_ENV || env.AWS_LAMBDA_FUNCTION_NAME) return "aws-alb";
2323
+ return "generic";
2324
+ }
2325
+ var _cachedPlatform = null;
2326
+ function getCachedPlatform() {
2327
+ if (_cachedPlatform === null) {
2328
+ _cachedPlatform = detectPlatform();
1577
2329
  }
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)";
2330
+ return _cachedPlatform;
2331
+ }
2332
+ var MAX_IP_LENGTH = 45;
2333
+ function sanitizeIp(ip) {
2334
+ const trimmed = ip.trim();
2335
+ if (trimmed.length > MAX_IP_LENGTH) return trimmed.slice(0, MAX_IP_LENGTH);
2336
+ return trimmed;
2337
+ }
2338
+ function getHeader(req, name) {
2339
+ const val = req.headers[name];
2340
+ if (Array.isArray(val)) return val[0];
2341
+ return val;
2342
+ }
2343
+ function parseForwardedFor(header, trustedProxyCount) {
2344
+ const ips = header.split(",").map((ip) => ip.trim()).filter(Boolean);
2345
+ if (ips.length === 0) return void 0;
2346
+ const clientIndex = Math.max(0, ips.length - trustedProxyCount);
2347
+ return ips[clientIndex] || void 0;
2348
+ }
2349
+ function detectClientIp(req, options = {}) {
2350
+ const { platform = "auto", trustedProxyCount = 1 } = options;
2351
+ const r = req;
2352
+ const resolvedPlatform = platform === "auto" ? getCachedPlatform() : platform;
2353
+ if (resolvedPlatform !== "generic" && resolvedPlatform in PLATFORM_HEADERS) {
2354
+ const headerName = PLATFORM_HEADERS[resolvedPlatform];
2355
+ if (headerName) {
2356
+ if (resolvedPlatform === "aws-alb") {
2357
+ const xff2 = getHeader(r, "x-forwarded-for");
2358
+ if (xff2) {
2359
+ const ip = parseForwardedFor(xff2, trustedProxyCount);
2360
+ if (ip) return sanitizeIp(ip);
2361
+ }
2362
+ } else {
2363
+ const ip = getHeader(r, headerName);
2364
+ if (ip) return sanitizeIp(ip);
2365
+ }
1583
2366
  }
1584
2367
  }
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)";
2368
+ if (r.ip) return sanitizeIp(r.ip);
2369
+ const xff = getHeader(r, "x-forwarded-for");
2370
+ if (xff) {
2371
+ const ip = parseForwardedFor(xff, trustedProxyCount);
2372
+ if (ip) return sanitizeIp(ip);
1593
2373
  }
1594
- if (hostname === "metadata.google.internal" || hostname === "metadata.internal") {
1595
- return "cloud metadata endpoint";
1596
- }
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";
1600
- }
1601
- return null;
2374
+ const realIp = getHeader(r, "x-real-ip");
2375
+ if (realIp) return sanitizeIp(realIp);
2376
+ const socketIp = r.socket?.remoteAddress ?? r.connection?.remoteAddress;
2377
+ if (socketIp) return sanitizeIp(socketIp);
2378
+ return "unknown";
1602
2379
  }
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 = {}) {
2380
+ function isPrivateIp(ip) {
2381
+ const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
2382
+ if (/^127\./.test(normalized)) return true;
2383
+ if (/^10\./.test(normalized)) return true;
2384
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(normalized)) return true;
2385
+ if (/^192\.168\./.test(normalized)) return true;
2386
+ if (/^169\.254\./.test(normalized)) return true;
2387
+ if (/^0\./.test(normalized)) return true;
2388
+ if (ip === "::1") return true;
2389
+ if (/^fe80:/i.test(ip)) return true;
2390
+ if (/^fc00:/i.test(ip)) return true;
2391
+ if (/^fd/i.test(ip)) return true;
2392
+ return false;
2393
+ }
2394
+ function getHeader2(req, name) {
2395
+ const val = req.headers[name];
2396
+ if (Array.isArray(val)) return val[0] ?? "";
2397
+ return val ?? "";
2398
+ }
2399
+ function fingerprint(req, options = {}) {
1608
2400
  const {
1609
- allowedHosts = [],
1610
- allowProtocolRelative = false,
1611
- allowedProtocols = ["http:", "https:"]
2401
+ ip = true,
2402
+ userAgent = true,
2403
+ accept = true,
2404
+ acceptLanguage = true,
2405
+ acceptEncoding = true,
2406
+ custom = [],
2407
+ ipOptions
1612
2408
  } = options;
1613
- if (typeof url !== "string" || url.trim() === "") {
1614
- return { safe: false, reason: "invalid redirect: empty or not a string" };
2409
+ const components = [];
2410
+ if (ip) {
2411
+ components.push(`ip:${detectClientIp(req, ipOptions)}`);
1615
2412
  }
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]}` };
1620
- }
1621
- if (cleaned.startsWith("\\")) {
1622
- return { safe: false, reason: "backslash-prefixed URL (browser treats as protocol-relative)" };
1623
- }
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 };
2413
+ if (userAgent) {
2414
+ components.push(`ua:${getHeader2(req, "user-agent")}`);
1637
2415
  }
1638
- let parsed;
1639
- try {
1640
- parsed = new URL(cleaned);
1641
- } catch {
1642
- return { safe: true };
2416
+ if (accept) {
2417
+ components.push(`accept:${getHeader2(req, "accept")}`);
1643
2418
  }
1644
- if (!allowedProtocols.includes(parsed.protocol)) {
1645
- return { safe: false, reason: `disallowed protocol: ${parsed.protocol}` };
2419
+ if (acceptLanguage) {
2420
+ components.push(`lang:${getHeader2(req, "accept-language")}`);
1646
2421
  }
1647
- const hostname = parsed.hostname.toLowerCase();
1648
- if (allowedHosts.length === 0) {
1649
- return { safe: false, reason: "absolute URL not in allowed hosts" };
2422
+ if (acceptEncoding) {
2423
+ components.push(`enc:${getHeader2(req, "accept-encoding")}`);
1650
2424
  }
1651
- if (!allowedHosts.some((h) => hostname === h.toLowerCase())) {
1652
- return { safe: false, reason: `host not allowed: ${hostname}` };
2425
+ for (const c of custom) {
2426
+ if (c != null) components.push(`custom:${c}`);
1653
2427
  }
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;
2428
+ components.sort();
2429
+ const hash = crypto.createHash("sha256");
2430
+ hash.update(components.join("|"));
2431
+ return hash.digest("hex");
1662
2432
  }
1663
2433
 
1664
2434
  // src/stores/memory.ts
@@ -1813,6 +2583,7 @@ exports.SecurityThreatError = SecurityThreatError;
1813
2583
  exports.VALIDATION = VALIDATION;
1814
2584
  exports.arcis = arcis;
1815
2585
  exports.arcisFunction = arcisWithMethods;
2586
+ exports.botProtection = botProtection;
1816
2587
  exports.createCors = createCors;
1817
2588
  exports.createErrorHandler = createErrorHandler;
1818
2589
  exports.createHeaders = createHeaders;
@@ -1822,8 +2593,12 @@ exports.createRedisStore = createRedisStore;
1822
2593
  exports.createSafeLogger = createSafeLogger;
1823
2594
  exports.createSanitizer = createSanitizer;
1824
2595
  exports.createSecureCookies = createSecureCookies;
2596
+ exports.createSlidingWindowLimiter = createSlidingWindowLimiter;
2597
+ exports.createTokenBucketLimiter = createTokenBucketLimiter;
1825
2598
  exports.createValidator = createValidator;
1826
2599
  exports.default = main_default;
2600
+ exports.detectBot = detectBot;
2601
+ exports.detectClientIp = detectClientIp;
1827
2602
  exports.detectCommandInjection = detectCommandInjection;
1828
2603
  exports.detectHeaderInjection = detectHeaderInjection;
1829
2604
  exports.detectNoSqlInjection = detectNoSqlInjection;
@@ -1833,11 +2608,16 @@ exports.detectSql = detectSql;
1833
2608
  exports.detectXss = detectXss;
1834
2609
  exports.enforceSecureCookie = enforceSecureCookie;
1835
2610
  exports.errorHandler = errorHandler;
2611
+ exports.fingerprint = fingerprint;
2612
+ exports.formatDuration = formatDuration;
1836
2613
  exports.isDangerousExtension = isDangerousExtension;
1837
2614
  exports.isDangerousNoSqlKey = isDangerousNoSqlKey;
1838
2615
  exports.isDangerousProtoKey = isDangerousProtoKey;
2616
+ exports.isPrivateIp = isPrivateIp;
1839
2617
  exports.isRedirectSafe = isRedirectSafe;
1840
2618
  exports.isUrlSafe = isUrlSafe;
2619
+ exports.isValidEmailSyntax = isValidEmailSyntax;
2620
+ exports.parseDuration = parseDuration;
1841
2621
  exports.rateLimit = rateLimit;
1842
2622
  exports.safeCors = safeCors;
1843
2623
  exports.safeLog = safeLog;
@@ -1853,8 +2633,10 @@ exports.sanitizeXss = sanitizeXss;
1853
2633
  exports.secureCookieDefaults = secureCookieDefaults;
1854
2634
  exports.securityHeaders = securityHeaders;
1855
2635
  exports.validate = validate;
2636
+ exports.validateEmail = validateEmail;
1856
2637
  exports.validateFile = validateFile;
1857
2638
  exports.validateRedirect = validateRedirect;
1858
2639
  exports.validateUrl = validateUrl;
2640
+ exports.verifyEmailMx = verifyEmailMx;
1859
2641
  //# sourceMappingURL=index.js.map
1860
2642
  //# sourceMappingURL=index.js.map