@arcis/node 1.4.2 → 1.4.4
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/README.md +11 -3
- package/dist/cli/arcis.d.ts +23 -0
- package/dist/cli/arcis.d.ts.map +1 -0
- package/dist/cli/arcis.js +312 -0
- package/dist/cli/arcis.js.map +1 -0
- package/dist/cli/arcis.mjs +309 -0
- package/dist/cli/arcis.mjs.map +1 -0
- package/dist/core/constants.d.ts +2 -2
- package/dist/core/constants.d.ts.map +1 -1
- package/dist/core/index.js +4 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +4 -1
- package/dist/core/index.mjs.map +1 -1
- package/dist/core/types.d.ts +17 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +658 -161
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +655 -162
- package/dist/index.mjs.map +1 -1
- package/dist/logging/index.js.map +1 -1
- package/dist/logging/index.mjs.map +1 -1
- package/dist/middleware/bot-detection.d.ts.map +1 -1
- package/dist/middleware/cookies.d.ts.map +1 -1
- package/dist/middleware/csrf.d.ts +10 -0
- package/dist/middleware/csrf.d.ts.map +1 -1
- package/dist/middleware/hpp.d.ts.map +1 -1
- package/dist/middleware/index.d.ts +2 -0
- package/dist/middleware/index.d.ts.map +1 -1
- package/dist/middleware/index.js +833 -12
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/index.mjs +832 -13
- package/dist/middleware/index.mjs.map +1 -1
- package/dist/middleware/main.d.ts.map +1 -1
- package/dist/middleware/rate-limit.d.ts.map +1 -1
- package/dist/middleware/signup-protection.d.ts +65 -0
- package/dist/middleware/signup-protection.d.ts.map +1 -0
- package/dist/middleware/telemetry.d.ts +36 -0
- package/dist/middleware/telemetry.d.ts.map +1 -0
- package/dist/sanitizers/index.d.ts +2 -1
- package/dist/sanitizers/index.d.ts.map +1 -1
- package/dist/sanitizers/index.js +238 -152
- package/dist/sanitizers/index.js.map +1 -1
- package/dist/sanitizers/index.mjs +238 -153
- package/dist/sanitizers/index.mjs.map +1 -1
- package/dist/sanitizers/pii.d.ts.map +1 -1
- package/dist/sanitizers/sanitize.d.ts +13 -0
- package/dist/sanitizers/sanitize.d.ts.map +1 -1
- package/dist/sanitizers/ssti.d.ts.map +1 -1
- package/dist/sanitizers/xxe.d.ts.map +1 -1
- package/dist/stores/index.js.map +1 -1
- package/dist/stores/index.mjs.map +1 -1
- package/dist/telemetry/client.d.ts +63 -0
- package/dist/telemetry/client.d.ts.map +1 -0
- package/dist/telemetry/index.d.ts +3 -0
- package/dist/telemetry/index.d.ts.map +1 -0
- package/dist/telemetry/types.d.ts +71 -0
- package/dist/telemetry/types.d.ts.map +1 -0
- package/dist/validation/index.js +3 -0
- package/dist/validation/index.js.map +1 -1
- package/dist/validation/index.mjs +3 -0
- package/dist/validation/index.mjs.map +1 -1
- package/package.json +10 -1
package/dist/middleware/index.js
CHANGED
|
@@ -44,11 +44,47 @@ var HEADERS = {
|
|
|
44
44
|
/** Default Cache-Control value for security */
|
|
45
45
|
CACHE_CONTROL: "no-store, no-cache, must-revalidate, proxy-revalidate"
|
|
46
46
|
};
|
|
47
|
+
var XSS_PATTERNS = [
|
|
48
|
+
/** Script tags (ReDoS-safe version) */
|
|
49
|
+
/<script[^>]*>[\s\S]*?<\/script>/gi,
|
|
50
|
+
/** javascript: protocol (allow optional spaces before colon) */
|
|
51
|
+
/javascript\s*:/gi,
|
|
52
|
+
/** vbscript: protocol */
|
|
53
|
+
/vbscript\s*:/gi,
|
|
54
|
+
/** Event handlers (onclick, onerror, etc.) — any separator before attribute */
|
|
55
|
+
/(?:[\s/])on\w+\s*=/gi,
|
|
56
|
+
/** iframe tags */
|
|
57
|
+
/<iframe/gi,
|
|
58
|
+
/** object tags */
|
|
59
|
+
/<object/gi,
|
|
60
|
+
/** embed tags */
|
|
61
|
+
/<embed/gi,
|
|
62
|
+
/** data: URIs (only dangerous ones, avoid false positives) */
|
|
63
|
+
/(?:^|[\s"'=])data:/gi,
|
|
64
|
+
/** URL-encoded script tags */
|
|
65
|
+
/%3Cscript/gi,
|
|
66
|
+
/** SVG with onload */
|
|
67
|
+
/<svg[^>]*onload/gi,
|
|
68
|
+
/** form tags — phishing/credential harvesting via action= redirection */
|
|
69
|
+
/<form[\s>]/gi,
|
|
70
|
+
/** meta tags — http-equiv refresh redirects or CSP bypass */
|
|
71
|
+
/<meta[\s>]/gi,
|
|
72
|
+
/** base href hijacking — redirects all relative URLs to attacker domain */
|
|
73
|
+
/<base[\s>]/gi,
|
|
74
|
+
/** link tag injection — stylesheet or preload CSRF attacks */
|
|
75
|
+
/<link[\s>]/gi,
|
|
76
|
+
/** style tag — CSS expression() / behavior: / IE-era attacks. Mirrors
|
|
77
|
+
* Python's xss-style-tag from packages/core/patterns.json. */
|
|
78
|
+
/<style[\s>]/gi
|
|
79
|
+
];
|
|
47
80
|
var XSS_REMOVE_PATTERNS = [
|
|
48
81
|
/** Full script blocks (content + tags) */
|
|
49
82
|
/<script[^>]*>[\s\S]*?<\/script>/gi,
|
|
50
83
|
/** Standalone/unclosed script tags */
|
|
51
84
|
/<script[^>]*>/gi,
|
|
85
|
+
/** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
|
|
86
|
+
/<style[^>]*>[\s\S]*?<\/style>/gi,
|
|
87
|
+
/<style[^>]*/gi,
|
|
52
88
|
/** iframe — full block and partial/unclosed */
|
|
53
89
|
/<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
|
|
54
90
|
/<iframe[^>]*/gi,
|
|
@@ -361,13 +397,13 @@ function createRateLimiter(options = {}) {
|
|
|
361
397
|
statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
|
|
362
398
|
keyGenerator = (req) => {
|
|
363
399
|
const ip = req.ip ?? req.socket?.remoteAddress;
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
return
|
|
400
|
+
if (ip) return ip;
|
|
401
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
402
|
+
const lang = req.headers["accept-language"] ?? "";
|
|
403
|
+
const fp = `${ua}|${lang}`;
|
|
404
|
+
let hash = 0;
|
|
405
|
+
for (let i = 0; i < fp.length; i++) hash = (hash << 5) - hash + fp.charCodeAt(i) | 0;
|
|
406
|
+
return `unknown:${hash.toString(36)}`;
|
|
371
407
|
},
|
|
372
408
|
skip,
|
|
373
409
|
store: externalStore
|
|
@@ -559,6 +595,80 @@ var SecurityThreatError = class extends ArcisError {
|
|
|
559
595
|
}
|
|
560
596
|
};
|
|
561
597
|
|
|
598
|
+
// src/middleware/telemetry.ts
|
|
599
|
+
var THREAT_TO_VECTOR = {
|
|
600
|
+
xss: "xss",
|
|
601
|
+
sql_injection: "sql",
|
|
602
|
+
nosql_injection: "nosql",
|
|
603
|
+
path_traversal: "path",
|
|
604
|
+
command_injection: "command",
|
|
605
|
+
prototype_pollution: "prototype",
|
|
606
|
+
header_injection: "header",
|
|
607
|
+
ssti: "ssti",
|
|
608
|
+
xxe: "xxe"
|
|
609
|
+
};
|
|
610
|
+
function createTelemetryEmitter(client) {
|
|
611
|
+
return (req, res, next) => {
|
|
612
|
+
const start = performance.now();
|
|
613
|
+
res.on("finish", () => {
|
|
614
|
+
try {
|
|
615
|
+
const event = buildEvent(req, res.statusCode, performance.now() - start);
|
|
616
|
+
client.record(event);
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
next();
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function tapSanitizerThreats(handler) {
|
|
624
|
+
return (req, res, next) => {
|
|
625
|
+
handler(req, res, (err) => {
|
|
626
|
+
if (err instanceof SecurityThreatError) {
|
|
627
|
+
const vector = THREAT_TO_VECTOR[err.threatType] ?? err.threatType;
|
|
628
|
+
req.__arcis = {
|
|
629
|
+
vector,
|
|
630
|
+
rule: `${vector}/match`,
|
|
631
|
+
severity: "high",
|
|
632
|
+
matchedPattern: err.pattern,
|
|
633
|
+
reason: err.message,
|
|
634
|
+
decision: "deny"
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
next(err);
|
|
638
|
+
});
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function buildEvent(req, status, latencyMs) {
|
|
642
|
+
const marker = req.__arcis;
|
|
643
|
+
const decision = marker?.decision ?? inferDecision(status);
|
|
644
|
+
return {
|
|
645
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
646
|
+
ip: extractIp(req),
|
|
647
|
+
method: (req.method ?? "GET").toUpperCase(),
|
|
648
|
+
path: req.path ?? req.url ?? "/",
|
|
649
|
+
decision,
|
|
650
|
+
vector: marker?.vector ?? (status === 429 ? "rate-limit" : void 0),
|
|
651
|
+
rule: marker?.rule ?? (status === 429 ? "rate-limit/exceeded" : void 0),
|
|
652
|
+
severity: marker?.severity ?? (status === 429 ? "medium" : void 0),
|
|
653
|
+
userAgent: typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "",
|
|
654
|
+
reason: marker?.reason,
|
|
655
|
+
status,
|
|
656
|
+
matchedPattern: marker?.matchedPattern,
|
|
657
|
+
latencyMs: Math.max(0, latencyMs)
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
function inferDecision(status) {
|
|
661
|
+
if (status === 429) return "deny";
|
|
662
|
+
if (status === 400) return "deny";
|
|
663
|
+
if (status === 403) return "deny";
|
|
664
|
+
return "allow";
|
|
665
|
+
}
|
|
666
|
+
function extractIp(req) {
|
|
667
|
+
if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
|
|
668
|
+
const remote = req.socket?.remoteAddress;
|
|
669
|
+
return typeof remote === "string" ? remote : "0.0.0.0";
|
|
670
|
+
}
|
|
671
|
+
|
|
562
672
|
// src/sanitizers/utils.ts
|
|
563
673
|
function encodeHtmlEntities(str) {
|
|
564
674
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -604,6 +714,20 @@ function sanitizeXss(input, collectThreats = false, htmlEncode = false) {
|
|
|
604
714
|
}
|
|
605
715
|
return value;
|
|
606
716
|
}
|
|
717
|
+
function detectXss(input) {
|
|
718
|
+
if (typeof input !== "string") return false;
|
|
719
|
+
if (/\s+on\w+\s*=/i.test(input)) return true;
|
|
720
|
+
if (/javascript\s*:/i.test(input)) return true;
|
|
721
|
+
if (/vbscript\s*:/i.test(input)) return true;
|
|
722
|
+
if (/data\s*:\s*text\/html/i.test(input)) return true;
|
|
723
|
+
for (const pattern of XSS_PATTERNS) {
|
|
724
|
+
pattern.lastIndex = 0;
|
|
725
|
+
if (pattern.test(input)) {
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
607
731
|
|
|
608
732
|
// src/sanitizers/sql.ts
|
|
609
733
|
function sanitizeSql(input, collectThreats = false) {
|
|
@@ -687,6 +811,17 @@ function sanitizePath(input, collectThreats = false) {
|
|
|
687
811
|
}
|
|
688
812
|
return value;
|
|
689
813
|
}
|
|
814
|
+
function detectPathTraversal(input) {
|
|
815
|
+
if (typeof input !== "string") return false;
|
|
816
|
+
const normalized = input.normalize("NFKC");
|
|
817
|
+
for (const pattern of PATH_PATTERNS) {
|
|
818
|
+
pattern.lastIndex = 0;
|
|
819
|
+
if (pattern.test(normalized)) {
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return false;
|
|
824
|
+
}
|
|
690
825
|
|
|
691
826
|
// src/sanitizers/command.ts
|
|
692
827
|
function sanitizeCommand(input, collectThreats = false) {
|
|
@@ -732,6 +867,60 @@ function detectCommandInjection(input) {
|
|
|
732
867
|
return false;
|
|
733
868
|
}
|
|
734
869
|
|
|
870
|
+
// src/sanitizers/ssti.ts
|
|
871
|
+
var SSTI_DETECT_PATTERNS = [
|
|
872
|
+
/** Jinja2 / Twig / Nunjucks: {{ ... }} */
|
|
873
|
+
/\{\{.*?\}\}/g,
|
|
874
|
+
/** Freemarker / Thymeleaf / Spring EL: ${ ... } */
|
|
875
|
+
/\$\{.*?\}/g,
|
|
876
|
+
/** ERB / EJS: <%= ... %> or <% ... %> */
|
|
877
|
+
/<%[=\-]?.*?%>/gs,
|
|
878
|
+
/** Pug / Jade / Slim: #{ ... } */
|
|
879
|
+
/#\{.*?\}/g,
|
|
880
|
+
/** Python dunder sandbox escape */
|
|
881
|
+
/__(?:class|mro|subclasses|globals|builtins|import)__/gi,
|
|
882
|
+
/** Jinja2 config leak: {{config.X}} or {{config['X']}} */
|
|
883
|
+
/\{\{\s*config[.\[]/gi,
|
|
884
|
+
/** Jinja2 built-in objects */
|
|
885
|
+
/\{\{\s*(?:self|request|lipsum|cycler|joiner|namespace|range)\b/gi
|
|
886
|
+
];
|
|
887
|
+
function detectSsti(input) {
|
|
888
|
+
if (typeof input !== "string") return false;
|
|
889
|
+
for (const pattern of SSTI_DETECT_PATTERNS) {
|
|
890
|
+
pattern.lastIndex = 0;
|
|
891
|
+
if (pattern.test(input)) {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return false;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// src/sanitizers/xxe.ts
|
|
899
|
+
var XXE_DETECT_PATTERNS = [
|
|
900
|
+
/** DOCTYPE declaration */
|
|
901
|
+
/<!DOCTYPE\b/gi,
|
|
902
|
+
/** ENTITY declaration */
|
|
903
|
+
/<!ENTITY\b/gi,
|
|
904
|
+
/** SYSTEM keyword with URI */
|
|
905
|
+
/\bSYSTEM\s+["']/gi,
|
|
906
|
+
/** PUBLIC keyword with URI */
|
|
907
|
+
/\bPUBLIC\s+["']/gi,
|
|
908
|
+
/** Parameter entity reference (%entity;) */
|
|
909
|
+
/%\s*\w+\s*;/g,
|
|
910
|
+
/** CDATA section (often used to smuggle payloads) */
|
|
911
|
+
/<!\[CDATA\[/gi
|
|
912
|
+
];
|
|
913
|
+
function detectXxe(input) {
|
|
914
|
+
if (typeof input !== "string") return false;
|
|
915
|
+
for (const pattern of XXE_DETECT_PATTERNS) {
|
|
916
|
+
pattern.lastIndex = 0;
|
|
917
|
+
if (pattern.test(input)) {
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
|
|
735
924
|
// src/sanitizers/sanitize.ts
|
|
736
925
|
function sanitizeString(value, options = {}) {
|
|
737
926
|
if (typeof value !== "string") return value;
|
|
@@ -801,9 +990,73 @@ function sanitizeObjectDepth(obj, options, depth) {
|
|
|
801
990
|
}
|
|
802
991
|
return result;
|
|
803
992
|
}
|
|
993
|
+
function scanThreats(data, depth = 0) {
|
|
994
|
+
if (depth > INPUT.MAX_RECURSION_DEPTH) return null;
|
|
995
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
996
|
+
for (const key of Object.keys(data)) {
|
|
997
|
+
const lower = key.toLowerCase();
|
|
998
|
+
if (DANGEROUS_PROTO_KEYS.has(lower)) {
|
|
999
|
+
return { vector: "prototype", rule: "prototype/match", matchedPattern: key };
|
|
1000
|
+
}
|
|
1001
|
+
if (NOSQL_DANGEROUS_KEYS.has(key)) {
|
|
1002
|
+
return { vector: "nosql", rule: "nosql/match", matchedPattern: key };
|
|
1003
|
+
}
|
|
1004
|
+
const inner = scanThreats(data[key], depth + 1);
|
|
1005
|
+
if (inner) return inner;
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
if (Array.isArray(data)) {
|
|
1010
|
+
for (const item of data) {
|
|
1011
|
+
const inner = scanThreats(item, depth + 1);
|
|
1012
|
+
if (inner) return inner;
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
if (typeof data !== "string") return null;
|
|
1017
|
+
const sample = data.slice(0, 80);
|
|
1018
|
+
if (detectXss(data)) {
|
|
1019
|
+
return { vector: "xss", rule: "xss/match", matchedPattern: sample };
|
|
1020
|
+
}
|
|
1021
|
+
if (detectSsti(data)) {
|
|
1022
|
+
return { vector: "ssti", rule: "ssti/match", matchedPattern: sample };
|
|
1023
|
+
}
|
|
1024
|
+
if (detectXxe(data)) {
|
|
1025
|
+
return { vector: "xxe", rule: "xxe/match", matchedPattern: sample };
|
|
1026
|
+
}
|
|
1027
|
+
if (detectSql(data)) {
|
|
1028
|
+
return { vector: "sql", rule: "sql/match", matchedPattern: sample };
|
|
1029
|
+
}
|
|
1030
|
+
if (detectPathTraversal(data)) {
|
|
1031
|
+
return { vector: "path", rule: "path/match", matchedPattern: sample };
|
|
1032
|
+
}
|
|
1033
|
+
if (detectCommandInjection(data)) {
|
|
1034
|
+
return { vector: "command", rule: "command/match", matchedPattern: sample };
|
|
1035
|
+
}
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
804
1038
|
function createSanitizer(options = {}) {
|
|
805
|
-
return (req,
|
|
1039
|
+
return (req, res, next) => {
|
|
806
1040
|
try {
|
|
1041
|
+
if (options.block) {
|
|
1042
|
+
const hit = scanThreats(req.body) || scanThreats(req.query) || scanThreats(req.params) || scanThreats(req.path);
|
|
1043
|
+
if (hit) {
|
|
1044
|
+
req.__arcis = {
|
|
1045
|
+
vector: hit.vector,
|
|
1046
|
+
rule: hit.rule,
|
|
1047
|
+
severity: "high",
|
|
1048
|
+
matchedPattern: hit.matchedPattern,
|
|
1049
|
+
reason: `${hit.vector} pattern detected in request`,
|
|
1050
|
+
decision: "deny"
|
|
1051
|
+
};
|
|
1052
|
+
res.status(403).json({
|
|
1053
|
+
error: "Request blocked for security reasons",
|
|
1054
|
+
code: "SECURITY_THREAT",
|
|
1055
|
+
vector: hit.vector
|
|
1056
|
+
});
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
807
1060
|
if (req.body && typeof req.body === "object") {
|
|
808
1061
|
req.body = sanitizeObject(req.body, options);
|
|
809
1062
|
}
|
|
@@ -993,6 +1246,238 @@ function validateField(field, value, rules) {
|
|
|
993
1246
|
// Audio/Video
|
|
994
1247
|
"audio/mpeg": [Buffer.from([255, 251]), Buffer.from([255, 243]), Buffer.from([73, 68, 51])]});
|
|
995
1248
|
|
|
1249
|
+
// src/validation/email.ts
|
|
1250
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
1251
|
+
var MAX_LOCAL_LENGTH = 64;
|
|
1252
|
+
var MAX_DOMAIN_LENGTH = 255;
|
|
1253
|
+
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,}$/;
|
|
1254
|
+
var FREE_PROVIDERS = /* @__PURE__ */ new Set([
|
|
1255
|
+
"gmail.com",
|
|
1256
|
+
"yahoo.com",
|
|
1257
|
+
"hotmail.com",
|
|
1258
|
+
"outlook.com",
|
|
1259
|
+
"aol.com",
|
|
1260
|
+
"protonmail.com",
|
|
1261
|
+
"proton.me",
|
|
1262
|
+
"icloud.com",
|
|
1263
|
+
"mail.com",
|
|
1264
|
+
"zoho.com",
|
|
1265
|
+
"yandex.com",
|
|
1266
|
+
"gmx.com",
|
|
1267
|
+
"gmx.net",
|
|
1268
|
+
"live.com",
|
|
1269
|
+
"msn.com",
|
|
1270
|
+
"me.com",
|
|
1271
|
+
"mac.com",
|
|
1272
|
+
"fastmail.com",
|
|
1273
|
+
"tutanota.com",
|
|
1274
|
+
"hey.com"
|
|
1275
|
+
]);
|
|
1276
|
+
var DISPOSABLE_DOMAINS = /* @__PURE__ */ new Set([
|
|
1277
|
+
// Popular disposable services
|
|
1278
|
+
"guerrillamail.com",
|
|
1279
|
+
"guerrillamail.net",
|
|
1280
|
+
"guerrillamail.org",
|
|
1281
|
+
"tempmail.com",
|
|
1282
|
+
"temp-mail.org",
|
|
1283
|
+
"temp-mail.io",
|
|
1284
|
+
"throwaway.email",
|
|
1285
|
+
"throwaway.com",
|
|
1286
|
+
"mailinator.com",
|
|
1287
|
+
"mailinator.net",
|
|
1288
|
+
"yopmail.com",
|
|
1289
|
+
"yopmail.fr",
|
|
1290
|
+
"yopmail.net",
|
|
1291
|
+
"sharklasers.com",
|
|
1292
|
+
"grr.la",
|
|
1293
|
+
"guerrillamail.info",
|
|
1294
|
+
"guerrillamail.biz",
|
|
1295
|
+
"guerrillamail.de",
|
|
1296
|
+
"trashmail.com",
|
|
1297
|
+
"trashmail.me",
|
|
1298
|
+
"trashmail.net",
|
|
1299
|
+
"dispostable.com",
|
|
1300
|
+
"maildrop.cc",
|
|
1301
|
+
"mailnesia.com",
|
|
1302
|
+
"tempail.com",
|
|
1303
|
+
"mohmal.com",
|
|
1304
|
+
"getnada.com",
|
|
1305
|
+
"emailondeck.com",
|
|
1306
|
+
"discard.email",
|
|
1307
|
+
"fakeinbox.com",
|
|
1308
|
+
"mailcatch.com",
|
|
1309
|
+
"mintemail.com",
|
|
1310
|
+
"tempr.email",
|
|
1311
|
+
"tempinbox.com",
|
|
1312
|
+
"burnermail.io",
|
|
1313
|
+
"mailsac.com",
|
|
1314
|
+
"harakirimail.com",
|
|
1315
|
+
"tempmailo.com",
|
|
1316
|
+
"emailfake.com",
|
|
1317
|
+
"crazymailing.com",
|
|
1318
|
+
"armyspy.com",
|
|
1319
|
+
"dayrep.com",
|
|
1320
|
+
"einrot.com",
|
|
1321
|
+
"fleckens.hu",
|
|
1322
|
+
"gustr.com",
|
|
1323
|
+
"jourrapide.com",
|
|
1324
|
+
"rhyta.com",
|
|
1325
|
+
"superrito.com",
|
|
1326
|
+
"teleworm.us",
|
|
1327
|
+
"10minutemail.com",
|
|
1328
|
+
"10minutemail.net",
|
|
1329
|
+
"minutemail.com",
|
|
1330
|
+
"tempsky.com",
|
|
1331
|
+
"spamgourmet.com",
|
|
1332
|
+
"mytrashmail.com",
|
|
1333
|
+
"mailexpire.com",
|
|
1334
|
+
"safetymail.info",
|
|
1335
|
+
"filzmail.com",
|
|
1336
|
+
"trashymail.com",
|
|
1337
|
+
"sharkmail.com",
|
|
1338
|
+
"jetable.org",
|
|
1339
|
+
"nospam.ze.tc",
|
|
1340
|
+
"trash-me.com",
|
|
1341
|
+
"dodgit.com",
|
|
1342
|
+
"mailmoat.com",
|
|
1343
|
+
"spamfree24.org",
|
|
1344
|
+
"incognitomail.org",
|
|
1345
|
+
"tempomail.fr",
|
|
1346
|
+
"ephemail.net",
|
|
1347
|
+
"hidemail.de",
|
|
1348
|
+
"spaml.de",
|
|
1349
|
+
"uggsrock.com",
|
|
1350
|
+
"binkmail.com",
|
|
1351
|
+
"suremail.info",
|
|
1352
|
+
"bugmenot.com"
|
|
1353
|
+
]);
|
|
1354
|
+
var DOMAIN_TYPOS = {
|
|
1355
|
+
"gmial.com": "gmail.com",
|
|
1356
|
+
"gmaill.com": "gmail.com",
|
|
1357
|
+
"gmai.com": "gmail.com",
|
|
1358
|
+
"gamil.com": "gmail.com",
|
|
1359
|
+
"gnail.com": "gmail.com",
|
|
1360
|
+
"gmal.com": "gmail.com",
|
|
1361
|
+
"gmil.com": "gmail.com",
|
|
1362
|
+
"gmail.co": "gmail.com",
|
|
1363
|
+
"gmail.cm": "gmail.com",
|
|
1364
|
+
"gmail.om": "gmail.com",
|
|
1365
|
+
"gmail.con": "gmail.com",
|
|
1366
|
+
"gmail.cim": "gmail.com",
|
|
1367
|
+
"gmail.comm": "gmail.com",
|
|
1368
|
+
"yahooo.com": "yahoo.com",
|
|
1369
|
+
"yaho.com": "yahoo.com",
|
|
1370
|
+
"yahoo.co": "yahoo.com",
|
|
1371
|
+
"yahoo.cm": "yahoo.com",
|
|
1372
|
+
"yahoo.con": "yahoo.com",
|
|
1373
|
+
"yahho.com": "yahoo.com",
|
|
1374
|
+
"hotmial.com": "hotmail.com",
|
|
1375
|
+
"hotmal.com": "hotmail.com",
|
|
1376
|
+
"hotmai.com": "hotmail.com",
|
|
1377
|
+
"hotmil.com": "hotmail.com",
|
|
1378
|
+
"hotmail.co": "hotmail.com",
|
|
1379
|
+
"hotmail.cm": "hotmail.com",
|
|
1380
|
+
"hotmail.con": "hotmail.com",
|
|
1381
|
+
"outlok.com": "outlook.com",
|
|
1382
|
+
"outloo.com": "outlook.com",
|
|
1383
|
+
"outlook.co": "outlook.com",
|
|
1384
|
+
"outlook.cm": "outlook.com",
|
|
1385
|
+
"protonmal.com": "protonmail.com",
|
|
1386
|
+
"protonmail.co": "protonmail.com",
|
|
1387
|
+
"icloud.co": "icloud.com",
|
|
1388
|
+
"icloud.cm": "icloud.com",
|
|
1389
|
+
"icoud.com": "icloud.com"
|
|
1390
|
+
};
|
|
1391
|
+
function invalidResult(reason, email) {
|
|
1392
|
+
return {
|
|
1393
|
+
valid: false,
|
|
1394
|
+
reason,
|
|
1395
|
+
suggestion: null,
|
|
1396
|
+
isFree: false,
|
|
1397
|
+
isDisposable: false,
|
|
1398
|
+
normalized: email
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
function validateEmail(email, options = {}) {
|
|
1402
|
+
const {
|
|
1403
|
+
checkDisposable = true,
|
|
1404
|
+
suggestTypoFix = true,
|
|
1405
|
+
blockedDomains = [],
|
|
1406
|
+
allowedDomains = []
|
|
1407
|
+
} = options;
|
|
1408
|
+
const normalized = email.trim().toLowerCase();
|
|
1409
|
+
if (!normalized || normalized.length > MAX_EMAIL_LENGTH) {
|
|
1410
|
+
return invalidResult("invalid_syntax", normalized);
|
|
1411
|
+
}
|
|
1412
|
+
const atIndex = normalized.lastIndexOf("@");
|
|
1413
|
+
if (atIndex === -1) {
|
|
1414
|
+
return invalidResult("invalid_syntax", normalized);
|
|
1415
|
+
}
|
|
1416
|
+
const localPart = normalized.slice(0, atIndex);
|
|
1417
|
+
const domain = normalized.slice(atIndex + 1);
|
|
1418
|
+
if (localPart.length === 0 || localPart.length > MAX_LOCAL_LENGTH) {
|
|
1419
|
+
return invalidResult("invalid_syntax", normalized);
|
|
1420
|
+
}
|
|
1421
|
+
if (domain.length === 0 || domain.length > MAX_DOMAIN_LENGTH) {
|
|
1422
|
+
return invalidResult("invalid_syntax", normalized);
|
|
1423
|
+
}
|
|
1424
|
+
if (localPart.includes("..")) {
|
|
1425
|
+
return invalidResult("invalid_syntax", normalized);
|
|
1426
|
+
}
|
|
1427
|
+
if (localPart.startsWith(".") || localPart.endsWith(".")) {
|
|
1428
|
+
return invalidResult("invalid_syntax", normalized);
|
|
1429
|
+
}
|
|
1430
|
+
if (!EMAIL_SYNTAX.test(normalized)) {
|
|
1431
|
+
return invalidResult("invalid_syntax", normalized);
|
|
1432
|
+
}
|
|
1433
|
+
const allowedSet = new Set(allowedDomains.map((d) => d.toLowerCase()));
|
|
1434
|
+
if (allowedSet.has(domain)) {
|
|
1435
|
+
return {
|
|
1436
|
+
valid: true,
|
|
1437
|
+
reason: "valid",
|
|
1438
|
+
suggestion: null,
|
|
1439
|
+
isFree: FREE_PROVIDERS.has(domain),
|
|
1440
|
+
isDisposable: false,
|
|
1441
|
+
normalized
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
const blockedSet = new Set(blockedDomains.map((d) => d.toLowerCase()));
|
|
1445
|
+
if (blockedSet.has(domain)) {
|
|
1446
|
+
return invalidResult("blocked", normalized);
|
|
1447
|
+
}
|
|
1448
|
+
const isDisposable = DISPOSABLE_DOMAINS.has(domain);
|
|
1449
|
+
if (checkDisposable && isDisposable) {
|
|
1450
|
+
return {
|
|
1451
|
+
valid: false,
|
|
1452
|
+
reason: "disposable",
|
|
1453
|
+
suggestion: null,
|
|
1454
|
+
isFree: false,
|
|
1455
|
+
isDisposable: true,
|
|
1456
|
+
normalized
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
const isFree = FREE_PROVIDERS.has(domain);
|
|
1460
|
+
if (suggestTypoFix && DOMAIN_TYPOS[domain]) {
|
|
1461
|
+
const corrected = `${localPart}@${DOMAIN_TYPOS[domain]}`;
|
|
1462
|
+
return {
|
|
1463
|
+
valid: true,
|
|
1464
|
+
reason: "typo",
|
|
1465
|
+
suggestion: corrected,
|
|
1466
|
+
isFree: FREE_PROVIDERS.has(DOMAIN_TYPOS[domain]),
|
|
1467
|
+
isDisposable: false,
|
|
1468
|
+
normalized
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
return {
|
|
1472
|
+
valid: true,
|
|
1473
|
+
reason: "valid",
|
|
1474
|
+
suggestion: null,
|
|
1475
|
+
isFree,
|
|
1476
|
+
isDisposable,
|
|
1477
|
+
normalized
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
|
|
996
1481
|
// src/logging/redactor.ts
|
|
997
1482
|
var LOG_LEVELS = {
|
|
998
1483
|
debug: 0,
|
|
@@ -1065,10 +1550,213 @@ function redactString(str, maxLength, patterns) {
|
|
|
1065
1550
|
return safe;
|
|
1066
1551
|
}
|
|
1067
1552
|
|
|
1553
|
+
// src/telemetry/client.ts
|
|
1554
|
+
var DEFAULT_BATCH_SIZE = 50;
|
|
1555
|
+
var MAX_BATCH_SIZE = 500;
|
|
1556
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
1557
|
+
var MIN_FLUSH_INTERVAL_MS = 500;
|
|
1558
|
+
var FLUSH_TIMEOUT_MS = 1e4;
|
|
1559
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e4;
|
|
1560
|
+
var TelemetryClient = class {
|
|
1561
|
+
constructor(options) {
|
|
1562
|
+
this.queue = [];
|
|
1563
|
+
this.flushing = false;
|
|
1564
|
+
this.closed = false;
|
|
1565
|
+
// Counts events dropped since the last successful flush. Resets to 0
|
|
1566
|
+
// each flush so onQueueOverflow callbacks see "drops in this window"
|
|
1567
|
+
// rather than a monotonic lifetime counter.
|
|
1568
|
+
this.droppedSinceLastFlush = 0;
|
|
1569
|
+
if (!options.endpoint || typeof options.endpoint !== "string") {
|
|
1570
|
+
throw new TypeError("TelemetryClient: `endpoint` is required");
|
|
1571
|
+
}
|
|
1572
|
+
this.endpoint = options.endpoint;
|
|
1573
|
+
this.apiKey = options.apiKey;
|
|
1574
|
+
this.workspaceId = options.workspaceId;
|
|
1575
|
+
this.batchSize = clamp(
|
|
1576
|
+
options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
1577
|
+
1,
|
|
1578
|
+
MAX_BATCH_SIZE
|
|
1579
|
+
);
|
|
1580
|
+
this.flushIntervalMs = Math.max(
|
|
1581
|
+
options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
1582
|
+
MIN_FLUSH_INTERVAL_MS
|
|
1583
|
+
);
|
|
1584
|
+
this.maxQueueSize = Math.max(
|
|
1585
|
+
options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
1586
|
+
this.batchSize
|
|
1587
|
+
);
|
|
1588
|
+
this.onError = options.onError ?? (() => {
|
|
1589
|
+
});
|
|
1590
|
+
this.onQueueOverflow = options.onQueueOverflow ?? (() => {
|
|
1591
|
+
});
|
|
1592
|
+
this.startTimer();
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Enqueue an event. Fast, synchronous, cannot throw.
|
|
1596
|
+
* Triggers a flush if the queue has reached `batchSize`.
|
|
1597
|
+
*/
|
|
1598
|
+
record(event) {
|
|
1599
|
+
if (this.closed) return;
|
|
1600
|
+
this.queue.push(event);
|
|
1601
|
+
if (this.queue.length > this.maxQueueSize) {
|
|
1602
|
+
const drop = this.queue.length - this.maxQueueSize;
|
|
1603
|
+
this.queue.splice(0, drop);
|
|
1604
|
+
this.droppedSinceLastFlush += drop;
|
|
1605
|
+
try {
|
|
1606
|
+
this.onQueueOverflow(this.droppedSinceLastFlush);
|
|
1607
|
+
} catch {
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
if (this.queue.length >= this.batchSize) {
|
|
1611
|
+
void this.flush();
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Manually flush the queue. Pulls up to `batchSize` events into a batch and
|
|
1616
|
+
* POSTs them. Returns a resolved promise on success OR on handled failure.
|
|
1617
|
+
* Never throws.
|
|
1618
|
+
*/
|
|
1619
|
+
async flush() {
|
|
1620
|
+
if (this.flushing) return;
|
|
1621
|
+
if (this.queue.length === 0) return;
|
|
1622
|
+
this.flushing = true;
|
|
1623
|
+
try {
|
|
1624
|
+
const batch = this.queue.splice(0, this.batchSize);
|
|
1625
|
+
await this.send(batch);
|
|
1626
|
+
this.droppedSinceLastFlush = 0;
|
|
1627
|
+
} catch (err) {
|
|
1628
|
+
this.safeNotify(err);
|
|
1629
|
+
} finally {
|
|
1630
|
+
this.flushing = false;
|
|
1631
|
+
}
|
|
1632
|
+
if (!this.closed && this.queue.length > 0) {
|
|
1633
|
+
void this.flush();
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Shut down: stop the interval timer and attempt one final flush.
|
|
1638
|
+
* Safe to call multiple times.
|
|
1639
|
+
*/
|
|
1640
|
+
async close() {
|
|
1641
|
+
if (this.closed) return;
|
|
1642
|
+
this.closed = true;
|
|
1643
|
+
if (this.timer !== void 0) {
|
|
1644
|
+
clearInterval(this.timer);
|
|
1645
|
+
this.timer = void 0;
|
|
1646
|
+
}
|
|
1647
|
+
if (this.signalHandler !== void 0) {
|
|
1648
|
+
process.off("SIGTERM", this.signalHandler);
|
|
1649
|
+
process.off("SIGINT", this.signalHandler);
|
|
1650
|
+
this.signalHandler = void 0;
|
|
1651
|
+
}
|
|
1652
|
+
try {
|
|
1653
|
+
await this.flush();
|
|
1654
|
+
} catch {
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Register `SIGTERM` / `SIGINT` handlers that call `close()` to drain
|
|
1659
|
+
* the queue on graceful shutdown. Opt-in — libraries should not silently
|
|
1660
|
+
* attach global signal handlers. Safe to call multiple times.
|
|
1661
|
+
*/
|
|
1662
|
+
installShutdownHooks() {
|
|
1663
|
+
if (this.signalHandler !== void 0 || this.closed) return;
|
|
1664
|
+
const handler = () => {
|
|
1665
|
+
void this.close();
|
|
1666
|
+
};
|
|
1667
|
+
this.signalHandler = handler;
|
|
1668
|
+
process.once("SIGTERM", handler);
|
|
1669
|
+
process.once("SIGINT", handler);
|
|
1670
|
+
}
|
|
1671
|
+
/** Count of events currently waiting to be sent. Useful for tests. */
|
|
1672
|
+
get pendingCount() {
|
|
1673
|
+
return this.queue.length;
|
|
1674
|
+
}
|
|
1675
|
+
// ── internals ─────────────────────────────────────────────────────────
|
|
1676
|
+
async send(batch) {
|
|
1677
|
+
const headers = {
|
|
1678
|
+
"content-type": "application/json"
|
|
1679
|
+
};
|
|
1680
|
+
if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
|
|
1681
|
+
if (this.workspaceId) headers["x-workspace-id"] = this.workspaceId;
|
|
1682
|
+
const controller = new AbortController();
|
|
1683
|
+
const abortTimer = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
|
1684
|
+
try {
|
|
1685
|
+
const res = await fetch(this.endpoint, {
|
|
1686
|
+
method: "POST",
|
|
1687
|
+
headers,
|
|
1688
|
+
body: JSON.stringify({ events: batch }),
|
|
1689
|
+
signal: controller.signal
|
|
1690
|
+
});
|
|
1691
|
+
if (!res.ok) {
|
|
1692
|
+
const text = await safeReadBody(res);
|
|
1693
|
+
throw new TelemetryHttpError(res.status, text);
|
|
1694
|
+
}
|
|
1695
|
+
} finally {
|
|
1696
|
+
clearTimeout(abortTimer);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
startTimer() {
|
|
1700
|
+
this.timer = setInterval(() => {
|
|
1701
|
+
void this.flush();
|
|
1702
|
+
}, this.flushIntervalMs);
|
|
1703
|
+
this.timer.unref?.();
|
|
1704
|
+
}
|
|
1705
|
+
safeNotify(err) {
|
|
1706
|
+
try {
|
|
1707
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
1708
|
+
} catch {
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
};
|
|
1712
|
+
var TelemetryHttpError = class extends Error {
|
|
1713
|
+
constructor(status, responseBody) {
|
|
1714
|
+
super(`Telemetry ingest returned HTTP ${status}`);
|
|
1715
|
+
this.status = status;
|
|
1716
|
+
this.responseBody = responseBody;
|
|
1717
|
+
this.name = "TelemetryHttpError";
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
function clamp(value, min, max) {
|
|
1721
|
+
if (!Number.isFinite(value)) return min;
|
|
1722
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
1723
|
+
}
|
|
1724
|
+
async function safeReadBody(res) {
|
|
1725
|
+
try {
|
|
1726
|
+
const text = await res.text();
|
|
1727
|
+
return text.slice(0, 500);
|
|
1728
|
+
} catch {
|
|
1729
|
+
return "";
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1068
1733
|
// src/middleware/main.ts
|
|
1734
|
+
function buildTelemetryFromEnv() {
|
|
1735
|
+
const env = typeof process !== "undefined" ? process.env : void 0;
|
|
1736
|
+
const endpoint = env?.ARCIS_ENDPOINT;
|
|
1737
|
+
if (!endpoint) return void 0;
|
|
1738
|
+
const opts = { endpoint };
|
|
1739
|
+
if (env?.ARCIS_WORKSPACE_ID) opts.workspaceId = env.ARCIS_WORKSPACE_ID;
|
|
1740
|
+
if (env?.ARCIS_KEY) opts.apiKey = env.ARCIS_KEY;
|
|
1741
|
+
const batch = env?.ARCIS_BATCH_SIZE ? parseInt(env.ARCIS_BATCH_SIZE, 10) : NaN;
|
|
1742
|
+
if (!Number.isNaN(batch)) opts.batchSize = batch;
|
|
1743
|
+
const flush = env?.ARCIS_FLUSH_INTERVAL_MS ? parseInt(env.ARCIS_FLUSH_INTERVAL_MS, 10) : NaN;
|
|
1744
|
+
if (!Number.isNaN(flush)) opts.flushIntervalMs = flush;
|
|
1745
|
+
return opts;
|
|
1746
|
+
}
|
|
1069
1747
|
function arcis(options = {}) {
|
|
1070
1748
|
const middlewares = [];
|
|
1071
1749
|
const cleanupFns = [];
|
|
1750
|
+
let telemetryClient;
|
|
1751
|
+
const telemetryOpts = options.telemetry?.endpoint ? options.telemetry : buildTelemetryFromEnv();
|
|
1752
|
+
if (telemetryOpts) {
|
|
1753
|
+
const client = new TelemetryClient(telemetryOpts);
|
|
1754
|
+
telemetryClient = client;
|
|
1755
|
+
middlewares.push(createTelemetryEmitter(client));
|
|
1756
|
+
cleanupFns.push(() => {
|
|
1757
|
+
void client.close();
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1072
1760
|
if (options.headers !== false) {
|
|
1073
1761
|
const headerOpts = typeof options.headers === "object" ? options.headers : {};
|
|
1074
1762
|
middlewares.push(createHeaders(headerOpts));
|
|
@@ -1080,8 +1768,12 @@ function arcis(options = {}) {
|
|
|
1080
1768
|
cleanupFns.push(() => rateLimiter.close());
|
|
1081
1769
|
}
|
|
1082
1770
|
if (options.sanitize !== false) {
|
|
1083
|
-
const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
|
|
1084
|
-
|
|
1771
|
+
const sanitizeOpts = typeof options.sanitize === "object" ? { ...options.sanitize } : {};
|
|
1772
|
+
if (options.block && sanitizeOpts.block === void 0) {
|
|
1773
|
+
sanitizeOpts.block = true;
|
|
1774
|
+
}
|
|
1775
|
+
const sanitizer = createSanitizer(sanitizeOpts);
|
|
1776
|
+
middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
|
|
1085
1777
|
}
|
|
1086
1778
|
const result = middlewares;
|
|
1087
1779
|
result.close = () => {
|
|
@@ -1394,6 +2086,16 @@ function secureCookieDefaults(options = {}) {
|
|
|
1394
2086
|
sameSite: options.sameSite ?? "Lax",
|
|
1395
2087
|
path: options.path
|
|
1396
2088
|
};
|
|
2089
|
+
if (resolved.sameSite === "None" && resolved.secure === false) {
|
|
2090
|
+
throw new Error(
|
|
2091
|
+
"[arcis] secureCookieDefaults: sameSite=None requires secure=true (modern browsers reject the cookie otherwise)"
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
if (resolved.httpOnly === false && resolved.secure === false && isProduction) {
|
|
2095
|
+
console.warn(
|
|
2096
|
+
"[arcis] secureCookieDefaults: httpOnly and secure are both disabled in production \u2014 cookies will be readable by JS and sent over HTTP"
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
1397
2099
|
return (_req, res, next) => {
|
|
1398
2100
|
const originalSetHeader = res.setHeader.bind(res);
|
|
1399
2101
|
res.setHeader = function patchedSetHeader(name, value) {
|
|
@@ -1526,7 +2228,16 @@ function detectBehavioralSignals(req) {
|
|
|
1526
2228
|
}
|
|
1527
2229
|
function detectBot(req) {
|
|
1528
2230
|
const rawUa = req.headers["user-agent"] ?? "";
|
|
1529
|
-
|
|
2231
|
+
if (rawUa.length > 2048) {
|
|
2232
|
+
return {
|
|
2233
|
+
isBot: true,
|
|
2234
|
+
category: "UNKNOWN",
|
|
2235
|
+
name: null,
|
|
2236
|
+
confidence: 0.9,
|
|
2237
|
+
signals: detectBehavioralSignals(req)
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
const ua = rawUa;
|
|
1530
2241
|
const signals = detectBehavioralSignals(req);
|
|
1531
2242
|
if (!ua) {
|
|
1532
2243
|
return {
|
|
@@ -1587,6 +2298,13 @@ function botProtection(options = {}) {
|
|
|
1587
2298
|
return next();
|
|
1588
2299
|
}
|
|
1589
2300
|
if (denySet.has(result.category)) {
|
|
2301
|
+
req.__arcis = {
|
|
2302
|
+
vector: "bot",
|
|
2303
|
+
rule: `bot/${result.category.toLowerCase()}`,
|
|
2304
|
+
severity: "medium",
|
|
2305
|
+
reason: result.name ? `Bot detected: ${result.name}` : "Bot detected",
|
|
2306
|
+
decision: "deny"
|
|
2307
|
+
};
|
|
1590
2308
|
if (onDetected) {
|
|
1591
2309
|
return onDetected(req, res, result);
|
|
1592
2310
|
}
|
|
@@ -1594,6 +2312,13 @@ function botProtection(options = {}) {
|
|
|
1594
2312
|
return;
|
|
1595
2313
|
}
|
|
1596
2314
|
if (defaultAction === "deny") {
|
|
2315
|
+
req.__arcis = {
|
|
2316
|
+
vector: "bot",
|
|
2317
|
+
rule: "bot/uncategorized",
|
|
2318
|
+
severity: "medium",
|
|
2319
|
+
reason: "Uncategorized bot under defaultAction=deny",
|
|
2320
|
+
decision: "deny"
|
|
2321
|
+
};
|
|
1597
2322
|
if (onDetected) {
|
|
1598
2323
|
return onDetected(req, res, result);
|
|
1599
2324
|
}
|
|
@@ -1647,7 +2372,14 @@ function csrfProtection(options = {}) {
|
|
|
1647
2372
|
sameSite: options.cookie?.sameSite ?? "Lax",
|
|
1648
2373
|
domain: options.cookie?.domain
|
|
1649
2374
|
};
|
|
1650
|
-
const defaultOnError = (
|
|
2375
|
+
const defaultOnError = (req, res, _next) => {
|
|
2376
|
+
req.__arcis = {
|
|
2377
|
+
vector: "csrf",
|
|
2378
|
+
rule: "csrf/token-mismatch",
|
|
2379
|
+
severity: "high",
|
|
2380
|
+
reason: "CSRF token missing or invalid",
|
|
2381
|
+
decision: "deny"
|
|
2382
|
+
};
|
|
1651
2383
|
res.status(403).json({
|
|
1652
2384
|
error: "CSRF token validation failed",
|
|
1653
2385
|
message: "Invalid or missing CSRF token. Include the token from the cookie in the X-CSRF-Token header."
|
|
@@ -1690,6 +2422,10 @@ function csrfProtection(options = {}) {
|
|
|
1690
2422
|
if (!validateCsrfToken(cookieToken, requestToken)) {
|
|
1691
2423
|
return onError(req, res, next);
|
|
1692
2424
|
}
|
|
2425
|
+
if (options.rotateOnUse) {
|
|
2426
|
+
const freshToken = generateCsrfToken(tokenLength);
|
|
2427
|
+
setCsrfCookie(res, cookieName, freshToken, cookieOpts);
|
|
2428
|
+
}
|
|
1693
2429
|
next();
|
|
1694
2430
|
};
|
|
1695
2431
|
}
|
|
@@ -1724,9 +2460,93 @@ function escapeRegex(str) {
|
|
|
1724
2460
|
}
|
|
1725
2461
|
var createCsrf = csrfProtection;
|
|
1726
2462
|
|
|
2463
|
+
// src/middleware/signup-protection.ts
|
|
2464
|
+
function checkSignup(req, options = {}) {
|
|
2465
|
+
const {
|
|
2466
|
+
emailField = "email",
|
|
2467
|
+
checkEmail = true,
|
|
2468
|
+
blockDisposable = true,
|
|
2469
|
+
checkBot = true,
|
|
2470
|
+
allowedBotCategories = [],
|
|
2471
|
+
allowedEmailDomains = [],
|
|
2472
|
+
blockedEmailDomains = []
|
|
2473
|
+
} = options;
|
|
2474
|
+
if (checkBot) {
|
|
2475
|
+
const bot = detectBot(req);
|
|
2476
|
+
if (bot.isBot && !allowedBotCategories.includes(bot.category)) {
|
|
2477
|
+
return {
|
|
2478
|
+
allowed: false,
|
|
2479
|
+
reason: "bot",
|
|
2480
|
+
details: { category: bot.category, name: bot.name, confidence: bot.confidence }
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
if (checkEmail) {
|
|
2485
|
+
const email = req.body?.[emailField];
|
|
2486
|
+
if (typeof email !== "string" || email.length === 0) {
|
|
2487
|
+
return { allowed: false, reason: "missing_email" };
|
|
2488
|
+
}
|
|
2489
|
+
const result = validateEmail(email, {
|
|
2490
|
+
checkDisposable: blockDisposable,
|
|
2491
|
+
allowedDomains: allowedEmailDomains,
|
|
2492
|
+
blockedDomains: blockedEmailDomains
|
|
2493
|
+
});
|
|
2494
|
+
if (!result.valid) {
|
|
2495
|
+
const reason = result.reason === "disposable" ? "disposable_email" : "invalid_email";
|
|
2496
|
+
return { allowed: false, reason, details: { emailReason: result.reason } };
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
return { allowed: true, reason: "ok" };
|
|
2500
|
+
}
|
|
2501
|
+
function signupProtection(options = {}) {
|
|
2502
|
+
const rateLimitCfg = options.rateLimit;
|
|
2503
|
+
const limiter = rateLimitCfg === false ? null : createRateLimiter({
|
|
2504
|
+
max: rateLimitCfg?.max ?? 5,
|
|
2505
|
+
windowMs: rateLimitCfg?.windowMs ?? 6e4,
|
|
2506
|
+
message: "Too many signup attempts"
|
|
2507
|
+
});
|
|
2508
|
+
const handler = (req, res, next) => {
|
|
2509
|
+
const result = checkSignup(req, options);
|
|
2510
|
+
if (!result.allowed) {
|
|
2511
|
+
options.onBlocked?.(req, result);
|
|
2512
|
+
const status = result.reason === "bot" ? 403 : 400;
|
|
2513
|
+
res.status(status).json({ error: "signup_blocked", reason: result.reason });
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
if (limiter) {
|
|
2517
|
+
let rateLimited = false;
|
|
2518
|
+
const rateLimitNext = (err) => {
|
|
2519
|
+
if (err) return next(err);
|
|
2520
|
+
if (!rateLimited) next();
|
|
2521
|
+
};
|
|
2522
|
+
const patchedRes = new Proxy(res, {
|
|
2523
|
+
get(target, prop) {
|
|
2524
|
+
if (prop === "status") {
|
|
2525
|
+
return (code) => {
|
|
2526
|
+
if (code === 429) {
|
|
2527
|
+
rateLimited = true;
|
|
2528
|
+
options.onBlocked?.(req, { allowed: false, reason: "rate_limited" });
|
|
2529
|
+
}
|
|
2530
|
+
return target.status.call(target, code);
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
return Reflect.get(target, prop);
|
|
2534
|
+
}
|
|
2535
|
+
});
|
|
2536
|
+
limiter(req, patchedRes, rateLimitNext);
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
next();
|
|
2540
|
+
};
|
|
2541
|
+
const middleware = handler;
|
|
2542
|
+
middleware.close = () => limiter?.close();
|
|
2543
|
+
return middleware;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
1727
2546
|
exports.arcis = arcis;
|
|
1728
2547
|
exports.arcisFunction = arcisWithMethods;
|
|
1729
2548
|
exports.botProtection = botProtection;
|
|
2549
|
+
exports.checkSignup = checkSignup;
|
|
1730
2550
|
exports.createCors = createCors;
|
|
1731
2551
|
exports.createCsrf = createCsrf;
|
|
1732
2552
|
exports.createErrorHandler = createErrorHandler;
|
|
@@ -1745,6 +2565,7 @@ exports.rateLimit = rateLimit;
|
|
|
1745
2565
|
exports.safeCors = safeCors;
|
|
1746
2566
|
exports.secureCookieDefaults = secureCookieDefaults;
|
|
1747
2567
|
exports.securityHeaders = securityHeaders;
|
|
2568
|
+
exports.signupProtection = signupProtection;
|
|
1748
2569
|
exports.validateCsrfToken = validateCsrfToken;
|
|
1749
2570
|
//# sourceMappingURL=index.js.map
|
|
1750
2571
|
//# sourceMappingURL=index.js.map
|