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