@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/index.js
CHANGED
|
@@ -78,13 +78,19 @@ var XSS_PATTERNS = [
|
|
|
78
78
|
/** base href hijacking — redirects all relative URLs to attacker domain */
|
|
79
79
|
/<base[\s>]/gi,
|
|
80
80
|
/** link tag injection — stylesheet or preload CSRF attacks */
|
|
81
|
-
/<link[\s>]/gi
|
|
81
|
+
/<link[\s>]/gi,
|
|
82
|
+
/** style tag — CSS expression() / behavior: / IE-era attacks. Mirrors
|
|
83
|
+
* Python's xss-style-tag from packages/core/patterns.json. */
|
|
84
|
+
/<style[\s>]/gi
|
|
82
85
|
];
|
|
83
86
|
var XSS_REMOVE_PATTERNS = [
|
|
84
87
|
/** Full script blocks (content + tags) */
|
|
85
88
|
/<script[^>]*>[\s\S]*?<\/script>/gi,
|
|
86
89
|
/** Standalone/unclosed script tags */
|
|
87
90
|
/<script[^>]*>/gi,
|
|
91
|
+
/** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
|
|
92
|
+
/<style[^>]*>[\s\S]*?<\/style>/gi,
|
|
93
|
+
/<style[^>]*/gi,
|
|
88
94
|
/** iframe — full block and partial/unclosed */
|
|
89
95
|
/<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
|
|
90
96
|
/<iframe[^>]*/gi,
|
|
@@ -400,13 +406,13 @@ function createRateLimiter(options = {}) {
|
|
|
400
406
|
statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
|
|
401
407
|
keyGenerator = (req) => {
|
|
402
408
|
const ip = req.ip ?? req.socket?.remoteAddress;
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return
|
|
409
|
+
if (ip) return ip;
|
|
410
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
411
|
+
const lang = req.headers["accept-language"] ?? "";
|
|
412
|
+
const fp = `${ua}|${lang}`;
|
|
413
|
+
let hash = 0;
|
|
414
|
+
for (let i = 0; i < fp.length; i++) hash = (hash << 5) - hash + fp.charCodeAt(i) | 0;
|
|
415
|
+
return `unknown:${hash.toString(36)}`;
|
|
410
416
|
},
|
|
411
417
|
skip,
|
|
412
418
|
store: externalStore
|
|
@@ -618,6 +624,80 @@ var SanitizationError = class extends ArcisError {
|
|
|
618
624
|
}
|
|
619
625
|
};
|
|
620
626
|
|
|
627
|
+
// src/middleware/telemetry.ts
|
|
628
|
+
var THREAT_TO_VECTOR = {
|
|
629
|
+
xss: "xss",
|
|
630
|
+
sql_injection: "sql",
|
|
631
|
+
nosql_injection: "nosql",
|
|
632
|
+
path_traversal: "path",
|
|
633
|
+
command_injection: "command",
|
|
634
|
+
prototype_pollution: "prototype",
|
|
635
|
+
header_injection: "header",
|
|
636
|
+
ssti: "ssti",
|
|
637
|
+
xxe: "xxe"
|
|
638
|
+
};
|
|
639
|
+
function createTelemetryEmitter(client) {
|
|
640
|
+
return (req, res, next) => {
|
|
641
|
+
const start = performance.now();
|
|
642
|
+
res.on("finish", () => {
|
|
643
|
+
try {
|
|
644
|
+
const event = buildEvent(req, res.statusCode, performance.now() - start);
|
|
645
|
+
client.record(event);
|
|
646
|
+
} catch {
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
next();
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function tapSanitizerThreats(handler) {
|
|
653
|
+
return (req, res, next) => {
|
|
654
|
+
handler(req, res, (err) => {
|
|
655
|
+
if (err instanceof SecurityThreatError) {
|
|
656
|
+
const vector = THREAT_TO_VECTOR[err.threatType] ?? err.threatType;
|
|
657
|
+
req.__arcis = {
|
|
658
|
+
vector,
|
|
659
|
+
rule: `${vector}/match`,
|
|
660
|
+
severity: "high",
|
|
661
|
+
matchedPattern: err.pattern,
|
|
662
|
+
reason: err.message,
|
|
663
|
+
decision: "deny"
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
next(err);
|
|
667
|
+
});
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function buildEvent(req, status, latencyMs) {
|
|
671
|
+
const marker = req.__arcis;
|
|
672
|
+
const decision = marker?.decision ?? inferDecision(status);
|
|
673
|
+
return {
|
|
674
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
675
|
+
ip: extractIp(req),
|
|
676
|
+
method: (req.method ?? "GET").toUpperCase(),
|
|
677
|
+
path: req.path ?? req.url ?? "/",
|
|
678
|
+
decision,
|
|
679
|
+
vector: marker?.vector ?? (status === 429 ? "rate-limit" : void 0),
|
|
680
|
+
rule: marker?.rule ?? (status === 429 ? "rate-limit/exceeded" : void 0),
|
|
681
|
+
severity: marker?.severity ?? (status === 429 ? "medium" : void 0),
|
|
682
|
+
userAgent: typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "",
|
|
683
|
+
reason: marker?.reason,
|
|
684
|
+
status,
|
|
685
|
+
matchedPattern: marker?.matchedPattern,
|
|
686
|
+
latencyMs: Math.max(0, latencyMs)
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
function inferDecision(status) {
|
|
690
|
+
if (status === 429) return "deny";
|
|
691
|
+
if (status === 400) return "deny";
|
|
692
|
+
if (status === 403) return "deny";
|
|
693
|
+
return "allow";
|
|
694
|
+
}
|
|
695
|
+
function extractIp(req) {
|
|
696
|
+
if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
|
|
697
|
+
const remote = req.socket?.remoteAddress;
|
|
698
|
+
return typeof remote === "string" ? remote : "0.0.0.0";
|
|
699
|
+
}
|
|
700
|
+
|
|
621
701
|
// src/sanitizers/utils.ts
|
|
622
702
|
function encodeHtmlEntities(str) {
|
|
623
703
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -816,144 +896,6 @@ function detectCommandInjection(input) {
|
|
|
816
896
|
return false;
|
|
817
897
|
}
|
|
818
898
|
|
|
819
|
-
// src/sanitizers/sanitize.ts
|
|
820
|
-
function sanitizeString(value, options = {}) {
|
|
821
|
-
if (typeof value !== "string") return value;
|
|
822
|
-
const maxSize = options.maxSize ?? INPUT.DEFAULT_MAX_SIZE;
|
|
823
|
-
if (value.length > maxSize) {
|
|
824
|
-
throw new InputTooLargeError(maxSize, value.length);
|
|
825
|
-
}
|
|
826
|
-
const reject = options.mode === "reject";
|
|
827
|
-
let result = value;
|
|
828
|
-
if (options.sql !== false) {
|
|
829
|
-
if (reject) {
|
|
830
|
-
if (detectSql(result)) {
|
|
831
|
-
throw new SecurityThreatError("sql_injection", "SQL pattern detected in input");
|
|
832
|
-
}
|
|
833
|
-
} else {
|
|
834
|
-
result = sanitizeSql(result);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
if (options.path !== false) {
|
|
838
|
-
result = sanitizePath(result);
|
|
839
|
-
}
|
|
840
|
-
if (options.command !== false) {
|
|
841
|
-
if (reject) {
|
|
842
|
-
if (detectCommandInjection(result)) {
|
|
843
|
-
throw new SecurityThreatError("command_injection", "Shell metacharacter detected in input");
|
|
844
|
-
}
|
|
845
|
-
} else {
|
|
846
|
-
result = sanitizeCommand(result);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
if (options.xss !== false) {
|
|
850
|
-
result = sanitizeXss(result, false, options.htmlEncode ?? false);
|
|
851
|
-
}
|
|
852
|
-
return result;
|
|
853
|
-
}
|
|
854
|
-
function sanitizeObject(obj, options = {}) {
|
|
855
|
-
if (obj === null || obj === void 0) return obj;
|
|
856
|
-
if (typeof obj === "string") return sanitizeString(obj, options);
|
|
857
|
-
if (typeof obj !== "object") return obj;
|
|
858
|
-
if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item, options));
|
|
859
|
-
const result = sanitizeObjectDepth(obj, options, 0);
|
|
860
|
-
return options.freeze ? Object.freeze(result) : result;
|
|
861
|
-
}
|
|
862
|
-
function sanitizeObjectDepth(obj, options, depth) {
|
|
863
|
-
if (depth >= INPUT.MAX_RECURSION_DEPTH) return obj;
|
|
864
|
-
const result = {};
|
|
865
|
-
for (const key of Object.keys(obj)) {
|
|
866
|
-
if (options.proto !== false && DANGEROUS_PROTO_KEYS.has(key.toLowerCase())) {
|
|
867
|
-
continue;
|
|
868
|
-
}
|
|
869
|
-
if (options.nosql !== false && NOSQL_DANGEROUS_KEYS.has(key)) {
|
|
870
|
-
continue;
|
|
871
|
-
}
|
|
872
|
-
const sanitizedKey = sanitizeString(key, options);
|
|
873
|
-
const value = obj[key];
|
|
874
|
-
if (value === null || value === void 0) {
|
|
875
|
-
result[sanitizedKey] = value;
|
|
876
|
-
} else if (typeof value === "string") {
|
|
877
|
-
result[sanitizedKey] = sanitizeString(value, options);
|
|
878
|
-
} else if (Array.isArray(value)) {
|
|
879
|
-
result[sanitizedKey] = value.map((item) => sanitizeObject(item, options));
|
|
880
|
-
} else if (typeof value === "object") {
|
|
881
|
-
result[sanitizedKey] = sanitizeObjectDepth(value, options, depth + 1);
|
|
882
|
-
} else {
|
|
883
|
-
result[sanitizedKey] = value;
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
return result;
|
|
887
|
-
}
|
|
888
|
-
function createSanitizer(options = {}) {
|
|
889
|
-
return (req, _res, next) => {
|
|
890
|
-
try {
|
|
891
|
-
if (req.body && typeof req.body === "object") {
|
|
892
|
-
req.body = sanitizeObject(req.body, options);
|
|
893
|
-
}
|
|
894
|
-
if (req.query && typeof req.query === "object") {
|
|
895
|
-
const sanitizedQuery = sanitizeObject(req.query, options);
|
|
896
|
-
Object.defineProperty(req, "query", { value: sanitizedQuery, writable: true, configurable: true });
|
|
897
|
-
}
|
|
898
|
-
if (req.params && typeof req.params === "object") {
|
|
899
|
-
const sanitizedParams = sanitizeObject(req.params, options);
|
|
900
|
-
Object.defineProperty(req, "params", { value: sanitizedParams, writable: true, configurable: true });
|
|
901
|
-
}
|
|
902
|
-
next();
|
|
903
|
-
} catch (err) {
|
|
904
|
-
next(err);
|
|
905
|
-
}
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// src/sanitizers/nosql.ts
|
|
910
|
-
function isDangerousNoSqlKey(key) {
|
|
911
|
-
return NOSQL_DANGEROUS_KEYS.has(key);
|
|
912
|
-
}
|
|
913
|
-
function detectNoSqlInjection(obj, maxDepth = 10) {
|
|
914
|
-
if (maxDepth <= 0) return false;
|
|
915
|
-
if (obj === null || typeof obj !== "object") return false;
|
|
916
|
-
if (Array.isArray(obj)) {
|
|
917
|
-
return obj.some((item) => detectNoSqlInjection(item, maxDepth - 1));
|
|
918
|
-
}
|
|
919
|
-
for (const key of Object.keys(obj)) {
|
|
920
|
-
if (isDangerousNoSqlKey(key)) {
|
|
921
|
-
return true;
|
|
922
|
-
}
|
|
923
|
-
const value = obj[key];
|
|
924
|
-
if (typeof value === "object" && value !== null) {
|
|
925
|
-
if (detectNoSqlInjection(value, maxDepth - 1)) {
|
|
926
|
-
return true;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
return false;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// src/sanitizers/prototype.ts
|
|
934
|
-
function isDangerousProtoKey(key) {
|
|
935
|
-
return DANGEROUS_PROTO_KEYS.has(key.toLowerCase());
|
|
936
|
-
}
|
|
937
|
-
function detectPrototypePollution(obj, maxDepth = 10) {
|
|
938
|
-
if (maxDepth <= 0) return false;
|
|
939
|
-
if (obj === null || typeof obj !== "object") return false;
|
|
940
|
-
if (Array.isArray(obj)) {
|
|
941
|
-
return obj.some((item) => detectPrototypePollution(item, maxDepth - 1));
|
|
942
|
-
}
|
|
943
|
-
for (const key of Object.keys(obj)) {
|
|
944
|
-
if (DANGEROUS_PROTO_KEYS.has(key.toLowerCase())) {
|
|
945
|
-
return true;
|
|
946
|
-
}
|
|
947
|
-
const value = obj[key];
|
|
948
|
-
if (typeof value === "object" && value !== null) {
|
|
949
|
-
if (detectPrototypePollution(value, maxDepth - 1)) {
|
|
950
|
-
return true;
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
return false;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
899
|
// src/sanitizers/ssti.ts
|
|
958
900
|
var SSTI_DETECT_PATTERNS = [
|
|
959
901
|
/** Jinja2 / Twig / Nunjucks: {{ ... }} */
|
|
@@ -975,17 +917,19 @@ var SSTI_REMOVE_PATTERNS = [
|
|
|
975
917
|
/** Jinja2 / Twig: {{ ... }} — always strip (not valid in any JS context) */
|
|
976
918
|
/\{\{.*?\}\}/g,
|
|
977
919
|
/**
|
|
978
|
-
* Freemarker / Spring EL: ${...} —
|
|
979
|
-
*
|
|
920
|
+
* Freemarker / Spring EL: ${...} — strip when expression contains operators,
|
|
921
|
+
* method calls, or Python dunder patterns (sandbox escape).
|
|
980
922
|
* Bare ${name} and ${user.name} are left intact (JS template literal syntax).
|
|
981
923
|
*/
|
|
924
|
+
/\$\{[^}]*__\w+__[^}]*\}/g,
|
|
982
925
|
/\$\{[^}]*[?!()*+\-/][^}]*\}/g,
|
|
983
926
|
/** ERB / EJS: <%= ... %> */
|
|
984
927
|
/<%[=\-]?.*?%>/gs,
|
|
985
928
|
/**
|
|
986
|
-
* Pug / Jade: #{...} — same narrowing as ${ above.
|
|
929
|
+
* Pug / Jade: #{...} — same narrowing as ${ above, plus dunder detection.
|
|
987
930
|
* #{name} output expressions are left intact.
|
|
988
931
|
*/
|
|
932
|
+
/#\{[^}]*__\w+__[^}]*\}/g,
|
|
989
933
|
/#\{[^}]*[?!()*+\-/][^}]*\}/g,
|
|
990
934
|
/** Python dunder sandbox escape — always strip */
|
|
991
935
|
/__(?:class|mro|subclasses|globals|builtins|import)__/gi
|
|
@@ -1034,6 +978,8 @@ function detectSsti(input) {
|
|
|
1034
978
|
}
|
|
1035
979
|
|
|
1036
980
|
// src/sanitizers/xxe.ts
|
|
981
|
+
var MAX_XXE_INPUT_BYTES = 1e6;
|
|
982
|
+
var MAX_ENTITY_REFERENCES = 64;
|
|
1037
983
|
var XXE_DETECT_PATTERNS = [
|
|
1038
984
|
/** DOCTYPE declaration */
|
|
1039
985
|
/<!DOCTYPE\b/gi,
|
|
@@ -1063,6 +1009,19 @@ function sanitizeXxe(input, collectThreats = false) {
|
|
|
1063
1009
|
const threats = [];
|
|
1064
1010
|
let value = input;
|
|
1065
1011
|
let wasSanitized = false;
|
|
1012
|
+
if (value.length > MAX_XXE_INPUT_BYTES) {
|
|
1013
|
+
if (collectThreats) {
|
|
1014
|
+
threats.push({ type: "xxe", pattern: "oversize_input", original: `length=${value.length}` });
|
|
1015
|
+
}
|
|
1016
|
+
return collectThreats ? { value: "", wasSanitized: true, threats } : "";
|
|
1017
|
+
}
|
|
1018
|
+
const entityRefs = value.match(/&\w+;/g);
|
|
1019
|
+
if (entityRefs && entityRefs.length > MAX_ENTITY_REFERENCES) {
|
|
1020
|
+
if (collectThreats) {
|
|
1021
|
+
threats.push({ type: "xxe", pattern: "entity_expansion", original: `count=${entityRefs.length}` });
|
|
1022
|
+
}
|
|
1023
|
+
return collectThreats ? { value: "", wasSanitized: true, threats } : "";
|
|
1024
|
+
}
|
|
1066
1025
|
for (const pattern of XXE_REMOVE_PATTERNS) {
|
|
1067
1026
|
pattern.lastIndex = 0;
|
|
1068
1027
|
if (pattern.test(value)) {
|
|
@@ -1099,13 +1058,213 @@ function detectXxe(input) {
|
|
|
1099
1058
|
return false;
|
|
1100
1059
|
}
|
|
1101
1060
|
|
|
1061
|
+
// src/sanitizers/sanitize.ts
|
|
1062
|
+
function sanitizeString(value, options = {}) {
|
|
1063
|
+
if (typeof value !== "string") return value;
|
|
1064
|
+
const maxSize = options.maxSize ?? INPUT.DEFAULT_MAX_SIZE;
|
|
1065
|
+
if (value.length > maxSize) {
|
|
1066
|
+
throw new InputTooLargeError(maxSize, value.length);
|
|
1067
|
+
}
|
|
1068
|
+
const reject = options.mode === "reject";
|
|
1069
|
+
let result = value;
|
|
1070
|
+
if (options.sql !== false) {
|
|
1071
|
+
if (reject) {
|
|
1072
|
+
if (detectSql(result)) {
|
|
1073
|
+
throw new SecurityThreatError("sql_injection", "SQL pattern detected in input");
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
result = sanitizeSql(result);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (options.path !== false) {
|
|
1080
|
+
result = sanitizePath(result);
|
|
1081
|
+
}
|
|
1082
|
+
if (options.command !== false) {
|
|
1083
|
+
if (reject) {
|
|
1084
|
+
if (detectCommandInjection(result)) {
|
|
1085
|
+
throw new SecurityThreatError("command_injection", "Shell metacharacter detected in input");
|
|
1086
|
+
}
|
|
1087
|
+
} else {
|
|
1088
|
+
result = sanitizeCommand(result);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (options.xss !== false) {
|
|
1092
|
+
result = sanitizeXss(result, false, options.htmlEncode ?? false);
|
|
1093
|
+
}
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
function sanitizeObject(obj, options = {}) {
|
|
1097
|
+
if (obj === null || obj === void 0) return obj;
|
|
1098
|
+
if (typeof obj === "string") return sanitizeString(obj, options);
|
|
1099
|
+
if (typeof obj !== "object") return obj;
|
|
1100
|
+
if (Array.isArray(obj)) return obj.map((item) => sanitizeObject(item, options));
|
|
1101
|
+
const result = sanitizeObjectDepth(obj, options, 0);
|
|
1102
|
+
return options.freeze ? Object.freeze(result) : result;
|
|
1103
|
+
}
|
|
1104
|
+
function sanitizeObjectDepth(obj, options, depth) {
|
|
1105
|
+
if (depth >= INPUT.MAX_RECURSION_DEPTH) return obj;
|
|
1106
|
+
const result = {};
|
|
1107
|
+
for (const key of Object.keys(obj)) {
|
|
1108
|
+
if (options.proto !== false && DANGEROUS_PROTO_KEYS.has(key.toLowerCase())) {
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (options.nosql !== false && NOSQL_DANGEROUS_KEYS.has(key)) {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const sanitizedKey = sanitizeString(key, options);
|
|
1115
|
+
const value = obj[key];
|
|
1116
|
+
if (value === null || value === void 0) {
|
|
1117
|
+
result[sanitizedKey] = value;
|
|
1118
|
+
} else if (typeof value === "string") {
|
|
1119
|
+
result[sanitizedKey] = sanitizeString(value, options);
|
|
1120
|
+
} else if (Array.isArray(value)) {
|
|
1121
|
+
result[sanitizedKey] = value.map((item) => sanitizeObject(item, options));
|
|
1122
|
+
} else if (typeof value === "object") {
|
|
1123
|
+
result[sanitizedKey] = sanitizeObjectDepth(value, options, depth + 1);
|
|
1124
|
+
} else {
|
|
1125
|
+
result[sanitizedKey] = value;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return result;
|
|
1129
|
+
}
|
|
1130
|
+
function scanThreats(data, depth = 0) {
|
|
1131
|
+
if (depth > INPUT.MAX_RECURSION_DEPTH) return null;
|
|
1132
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
1133
|
+
for (const key of Object.keys(data)) {
|
|
1134
|
+
const lower = key.toLowerCase();
|
|
1135
|
+
if (DANGEROUS_PROTO_KEYS.has(lower)) {
|
|
1136
|
+
return { vector: "prototype", rule: "prototype/match", matchedPattern: key };
|
|
1137
|
+
}
|
|
1138
|
+
if (NOSQL_DANGEROUS_KEYS.has(key)) {
|
|
1139
|
+
return { vector: "nosql", rule: "nosql/match", matchedPattern: key };
|
|
1140
|
+
}
|
|
1141
|
+
const inner = scanThreats(data[key], depth + 1);
|
|
1142
|
+
if (inner) return inner;
|
|
1143
|
+
}
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
if (Array.isArray(data)) {
|
|
1147
|
+
for (const item of data) {
|
|
1148
|
+
const inner = scanThreats(item, depth + 1);
|
|
1149
|
+
if (inner) return inner;
|
|
1150
|
+
}
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
if (typeof data !== "string") return null;
|
|
1154
|
+
const sample = data.slice(0, 80);
|
|
1155
|
+
if (detectXss(data)) {
|
|
1156
|
+
return { vector: "xss", rule: "xss/match", matchedPattern: sample };
|
|
1157
|
+
}
|
|
1158
|
+
if (detectSsti(data)) {
|
|
1159
|
+
return { vector: "ssti", rule: "ssti/match", matchedPattern: sample };
|
|
1160
|
+
}
|
|
1161
|
+
if (detectXxe(data)) {
|
|
1162
|
+
return { vector: "xxe", rule: "xxe/match", matchedPattern: sample };
|
|
1163
|
+
}
|
|
1164
|
+
if (detectSql(data)) {
|
|
1165
|
+
return { vector: "sql", rule: "sql/match", matchedPattern: sample };
|
|
1166
|
+
}
|
|
1167
|
+
if (detectPathTraversal(data)) {
|
|
1168
|
+
return { vector: "path", rule: "path/match", matchedPattern: sample };
|
|
1169
|
+
}
|
|
1170
|
+
if (detectCommandInjection(data)) {
|
|
1171
|
+
return { vector: "command", rule: "command/match", matchedPattern: sample };
|
|
1172
|
+
}
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
function createSanitizer(options = {}) {
|
|
1176
|
+
return (req, res, next) => {
|
|
1177
|
+
try {
|
|
1178
|
+
if (options.block) {
|
|
1179
|
+
const hit = scanThreats(req.body) || scanThreats(req.query) || scanThreats(req.params) || scanThreats(req.path);
|
|
1180
|
+
if (hit) {
|
|
1181
|
+
req.__arcis = {
|
|
1182
|
+
vector: hit.vector,
|
|
1183
|
+
rule: hit.rule,
|
|
1184
|
+
severity: "high",
|
|
1185
|
+
matchedPattern: hit.matchedPattern,
|
|
1186
|
+
reason: `${hit.vector} pattern detected in request`,
|
|
1187
|
+
decision: "deny"
|
|
1188
|
+
};
|
|
1189
|
+
res.status(403).json({
|
|
1190
|
+
error: "Request blocked for security reasons",
|
|
1191
|
+
code: "SECURITY_THREAT",
|
|
1192
|
+
vector: hit.vector
|
|
1193
|
+
});
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (req.body && typeof req.body === "object") {
|
|
1198
|
+
req.body = sanitizeObject(req.body, options);
|
|
1199
|
+
}
|
|
1200
|
+
if (req.query && typeof req.query === "object") {
|
|
1201
|
+
const sanitizedQuery = sanitizeObject(req.query, options);
|
|
1202
|
+
Object.defineProperty(req, "query", { value: sanitizedQuery, writable: true, configurable: true });
|
|
1203
|
+
}
|
|
1204
|
+
if (req.params && typeof req.params === "object") {
|
|
1205
|
+
const sanitizedParams = sanitizeObject(req.params, options);
|
|
1206
|
+
Object.defineProperty(req, "params", { value: sanitizedParams, writable: true, configurable: true });
|
|
1207
|
+
}
|
|
1208
|
+
next();
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
next(err);
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// src/sanitizers/nosql.ts
|
|
1216
|
+
function isDangerousNoSqlKey(key) {
|
|
1217
|
+
return NOSQL_DANGEROUS_KEYS.has(key);
|
|
1218
|
+
}
|
|
1219
|
+
function detectNoSqlInjection(obj, maxDepth = 10) {
|
|
1220
|
+
if (maxDepth <= 0) return false;
|
|
1221
|
+
if (obj === null || typeof obj !== "object") return false;
|
|
1222
|
+
if (Array.isArray(obj)) {
|
|
1223
|
+
return obj.some((item) => detectNoSqlInjection(item, maxDepth - 1));
|
|
1224
|
+
}
|
|
1225
|
+
for (const key of Object.keys(obj)) {
|
|
1226
|
+
if (isDangerousNoSqlKey(key)) {
|
|
1227
|
+
return true;
|
|
1228
|
+
}
|
|
1229
|
+
const value = obj[key];
|
|
1230
|
+
if (typeof value === "object" && value !== null) {
|
|
1231
|
+
if (detectNoSqlInjection(value, maxDepth - 1)) {
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// src/sanitizers/prototype.ts
|
|
1240
|
+
function isDangerousProtoKey(key) {
|
|
1241
|
+
return DANGEROUS_PROTO_KEYS.has(key.toLowerCase());
|
|
1242
|
+
}
|
|
1243
|
+
function detectPrototypePollution(obj, maxDepth = 10) {
|
|
1244
|
+
if (maxDepth <= 0) return false;
|
|
1245
|
+
if (obj === null || typeof obj !== "object") return false;
|
|
1246
|
+
if (Array.isArray(obj)) {
|
|
1247
|
+
return obj.some((item) => detectPrototypePollution(item, maxDepth - 1));
|
|
1248
|
+
}
|
|
1249
|
+
for (const key of Object.keys(obj)) {
|
|
1250
|
+
if (DANGEROUS_PROTO_KEYS.has(key.toLowerCase())) {
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
1253
|
+
const value = obj[key];
|
|
1254
|
+
if (typeof value === "object" && value !== null) {
|
|
1255
|
+
if (detectPrototypePollution(value, maxDepth - 1)) {
|
|
1256
|
+
return true;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1102
1263
|
// src/sanitizers/jsonp.ts
|
|
1103
|
-
var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.
|
|
1264
|
+
var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.]*$/;
|
|
1104
1265
|
var DANGEROUS_CALLBACK_PATTERNS = [
|
|
1105
|
-
|
|
1266
|
+
/\.\./
|
|
1106
1267
|
// prototype chain traversal
|
|
1107
|
-
/\[\s*\]/
|
|
1108
|
-
// empty bracket access
|
|
1109
1268
|
];
|
|
1110
1269
|
function sanitizeJsonpCallback(callback, maxLength = 128) {
|
|
1111
1270
|
if (typeof callback !== "string" || callback.length === 0) {
|
|
@@ -1190,7 +1349,7 @@ function detectHeaderInjection(input) {
|
|
|
1190
1349
|
|
|
1191
1350
|
// src/sanitizers/pii.ts
|
|
1192
1351
|
var EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z]{2,})+/g;
|
|
1193
|
-
var PHONE_RE = /(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
|
|
1352
|
+
var PHONE_RE = /(?<!\d)(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\d)/g;
|
|
1194
1353
|
var CREDIT_CARD_RE = /\b(?:\d[ -]*?){13,19}\b/g;
|
|
1195
1354
|
var SSN_RE = /\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g;
|
|
1196
1355
|
var IPV4_RE = /\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b/g;
|
|
@@ -2314,10 +2473,213 @@ function createRedactor(sensitiveKeys = []) {
|
|
|
2314
2473
|
}
|
|
2315
2474
|
var safeLog = createSafeLogger;
|
|
2316
2475
|
|
|
2476
|
+
// src/telemetry/client.ts
|
|
2477
|
+
var DEFAULT_BATCH_SIZE = 50;
|
|
2478
|
+
var MAX_BATCH_SIZE = 500;
|
|
2479
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
2480
|
+
var MIN_FLUSH_INTERVAL_MS = 500;
|
|
2481
|
+
var FLUSH_TIMEOUT_MS = 1e4;
|
|
2482
|
+
var DEFAULT_MAX_QUEUE_SIZE = 1e4;
|
|
2483
|
+
var TelemetryClient = class {
|
|
2484
|
+
constructor(options) {
|
|
2485
|
+
this.queue = [];
|
|
2486
|
+
this.flushing = false;
|
|
2487
|
+
this.closed = false;
|
|
2488
|
+
// Counts events dropped since the last successful flush. Resets to 0
|
|
2489
|
+
// each flush so onQueueOverflow callbacks see "drops in this window"
|
|
2490
|
+
// rather than a monotonic lifetime counter.
|
|
2491
|
+
this.droppedSinceLastFlush = 0;
|
|
2492
|
+
if (!options.endpoint || typeof options.endpoint !== "string") {
|
|
2493
|
+
throw new TypeError("TelemetryClient: `endpoint` is required");
|
|
2494
|
+
}
|
|
2495
|
+
this.endpoint = options.endpoint;
|
|
2496
|
+
this.apiKey = options.apiKey;
|
|
2497
|
+
this.workspaceId = options.workspaceId;
|
|
2498
|
+
this.batchSize = clamp(
|
|
2499
|
+
options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
2500
|
+
1,
|
|
2501
|
+
MAX_BATCH_SIZE
|
|
2502
|
+
);
|
|
2503
|
+
this.flushIntervalMs = Math.max(
|
|
2504
|
+
options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
2505
|
+
MIN_FLUSH_INTERVAL_MS
|
|
2506
|
+
);
|
|
2507
|
+
this.maxQueueSize = Math.max(
|
|
2508
|
+
options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
|
|
2509
|
+
this.batchSize
|
|
2510
|
+
);
|
|
2511
|
+
this.onError = options.onError ?? (() => {
|
|
2512
|
+
});
|
|
2513
|
+
this.onQueueOverflow = options.onQueueOverflow ?? (() => {
|
|
2514
|
+
});
|
|
2515
|
+
this.startTimer();
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Enqueue an event. Fast, synchronous, cannot throw.
|
|
2519
|
+
* Triggers a flush if the queue has reached `batchSize`.
|
|
2520
|
+
*/
|
|
2521
|
+
record(event) {
|
|
2522
|
+
if (this.closed) return;
|
|
2523
|
+
this.queue.push(event);
|
|
2524
|
+
if (this.queue.length > this.maxQueueSize) {
|
|
2525
|
+
const drop = this.queue.length - this.maxQueueSize;
|
|
2526
|
+
this.queue.splice(0, drop);
|
|
2527
|
+
this.droppedSinceLastFlush += drop;
|
|
2528
|
+
try {
|
|
2529
|
+
this.onQueueOverflow(this.droppedSinceLastFlush);
|
|
2530
|
+
} catch {
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
if (this.queue.length >= this.batchSize) {
|
|
2534
|
+
void this.flush();
|
|
2535
|
+
}
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* Manually flush the queue. Pulls up to `batchSize` events into a batch and
|
|
2539
|
+
* POSTs them. Returns a resolved promise on success OR on handled failure.
|
|
2540
|
+
* Never throws.
|
|
2541
|
+
*/
|
|
2542
|
+
async flush() {
|
|
2543
|
+
if (this.flushing) return;
|
|
2544
|
+
if (this.queue.length === 0) return;
|
|
2545
|
+
this.flushing = true;
|
|
2546
|
+
try {
|
|
2547
|
+
const batch = this.queue.splice(0, this.batchSize);
|
|
2548
|
+
await this.send(batch);
|
|
2549
|
+
this.droppedSinceLastFlush = 0;
|
|
2550
|
+
} catch (err) {
|
|
2551
|
+
this.safeNotify(err);
|
|
2552
|
+
} finally {
|
|
2553
|
+
this.flushing = false;
|
|
2554
|
+
}
|
|
2555
|
+
if (!this.closed && this.queue.length > 0) {
|
|
2556
|
+
void this.flush();
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Shut down: stop the interval timer and attempt one final flush.
|
|
2561
|
+
* Safe to call multiple times.
|
|
2562
|
+
*/
|
|
2563
|
+
async close() {
|
|
2564
|
+
if (this.closed) return;
|
|
2565
|
+
this.closed = true;
|
|
2566
|
+
if (this.timer !== void 0) {
|
|
2567
|
+
clearInterval(this.timer);
|
|
2568
|
+
this.timer = void 0;
|
|
2569
|
+
}
|
|
2570
|
+
if (this.signalHandler !== void 0) {
|
|
2571
|
+
process.off("SIGTERM", this.signalHandler);
|
|
2572
|
+
process.off("SIGINT", this.signalHandler);
|
|
2573
|
+
this.signalHandler = void 0;
|
|
2574
|
+
}
|
|
2575
|
+
try {
|
|
2576
|
+
await this.flush();
|
|
2577
|
+
} catch {
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Register `SIGTERM` / `SIGINT` handlers that call `close()` to drain
|
|
2582
|
+
* the queue on graceful shutdown. Opt-in — libraries should not silently
|
|
2583
|
+
* attach global signal handlers. Safe to call multiple times.
|
|
2584
|
+
*/
|
|
2585
|
+
installShutdownHooks() {
|
|
2586
|
+
if (this.signalHandler !== void 0 || this.closed) return;
|
|
2587
|
+
const handler = () => {
|
|
2588
|
+
void this.close();
|
|
2589
|
+
};
|
|
2590
|
+
this.signalHandler = handler;
|
|
2591
|
+
process.once("SIGTERM", handler);
|
|
2592
|
+
process.once("SIGINT", handler);
|
|
2593
|
+
}
|
|
2594
|
+
/** Count of events currently waiting to be sent. Useful for tests. */
|
|
2595
|
+
get pendingCount() {
|
|
2596
|
+
return this.queue.length;
|
|
2597
|
+
}
|
|
2598
|
+
// ── internals ─────────────────────────────────────────────────────────
|
|
2599
|
+
async send(batch) {
|
|
2600
|
+
const headers = {
|
|
2601
|
+
"content-type": "application/json"
|
|
2602
|
+
};
|
|
2603
|
+
if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
|
|
2604
|
+
if (this.workspaceId) headers["x-workspace-id"] = this.workspaceId;
|
|
2605
|
+
const controller = new AbortController();
|
|
2606
|
+
const abortTimer = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
|
2607
|
+
try {
|
|
2608
|
+
const res = await fetch(this.endpoint, {
|
|
2609
|
+
method: "POST",
|
|
2610
|
+
headers,
|
|
2611
|
+
body: JSON.stringify({ events: batch }),
|
|
2612
|
+
signal: controller.signal
|
|
2613
|
+
});
|
|
2614
|
+
if (!res.ok) {
|
|
2615
|
+
const text = await safeReadBody(res);
|
|
2616
|
+
throw new TelemetryHttpError(res.status, text);
|
|
2617
|
+
}
|
|
2618
|
+
} finally {
|
|
2619
|
+
clearTimeout(abortTimer);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
startTimer() {
|
|
2623
|
+
this.timer = setInterval(() => {
|
|
2624
|
+
void this.flush();
|
|
2625
|
+
}, this.flushIntervalMs);
|
|
2626
|
+
this.timer.unref?.();
|
|
2627
|
+
}
|
|
2628
|
+
safeNotify(err) {
|
|
2629
|
+
try {
|
|
2630
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
2631
|
+
} catch {
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
};
|
|
2635
|
+
var TelemetryHttpError = class extends Error {
|
|
2636
|
+
constructor(status, responseBody) {
|
|
2637
|
+
super(`Telemetry ingest returned HTTP ${status}`);
|
|
2638
|
+
this.status = status;
|
|
2639
|
+
this.responseBody = responseBody;
|
|
2640
|
+
this.name = "TelemetryHttpError";
|
|
2641
|
+
}
|
|
2642
|
+
};
|
|
2643
|
+
function clamp(value, min, max) {
|
|
2644
|
+
if (!Number.isFinite(value)) return min;
|
|
2645
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
2646
|
+
}
|
|
2647
|
+
async function safeReadBody(res) {
|
|
2648
|
+
try {
|
|
2649
|
+
const text = await res.text();
|
|
2650
|
+
return text.slice(0, 500);
|
|
2651
|
+
} catch {
|
|
2652
|
+
return "";
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2317
2656
|
// src/middleware/main.ts
|
|
2657
|
+
function buildTelemetryFromEnv() {
|
|
2658
|
+
const env = typeof process !== "undefined" ? process.env : void 0;
|
|
2659
|
+
const endpoint = env?.ARCIS_ENDPOINT;
|
|
2660
|
+
if (!endpoint) return void 0;
|
|
2661
|
+
const opts = { endpoint };
|
|
2662
|
+
if (env?.ARCIS_WORKSPACE_ID) opts.workspaceId = env.ARCIS_WORKSPACE_ID;
|
|
2663
|
+
if (env?.ARCIS_KEY) opts.apiKey = env.ARCIS_KEY;
|
|
2664
|
+
const batch = env?.ARCIS_BATCH_SIZE ? parseInt(env.ARCIS_BATCH_SIZE, 10) : NaN;
|
|
2665
|
+
if (!Number.isNaN(batch)) opts.batchSize = batch;
|
|
2666
|
+
const flush = env?.ARCIS_FLUSH_INTERVAL_MS ? parseInt(env.ARCIS_FLUSH_INTERVAL_MS, 10) : NaN;
|
|
2667
|
+
if (!Number.isNaN(flush)) opts.flushIntervalMs = flush;
|
|
2668
|
+
return opts;
|
|
2669
|
+
}
|
|
2318
2670
|
function arcis(options = {}) {
|
|
2319
2671
|
const middlewares = [];
|
|
2320
2672
|
const cleanupFns = [];
|
|
2673
|
+
let telemetryClient;
|
|
2674
|
+
const telemetryOpts = options.telemetry?.endpoint ? options.telemetry : buildTelemetryFromEnv();
|
|
2675
|
+
if (telemetryOpts) {
|
|
2676
|
+
const client = new TelemetryClient(telemetryOpts);
|
|
2677
|
+
telemetryClient = client;
|
|
2678
|
+
middlewares.push(createTelemetryEmitter(client));
|
|
2679
|
+
cleanupFns.push(() => {
|
|
2680
|
+
void client.close();
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2321
2683
|
if (options.headers !== false) {
|
|
2322
2684
|
const headerOpts = typeof options.headers === "object" ? options.headers : {};
|
|
2323
2685
|
middlewares.push(createHeaders(headerOpts));
|
|
@@ -2329,8 +2691,12 @@ function arcis(options = {}) {
|
|
|
2329
2691
|
cleanupFns.push(() => rateLimiter.close());
|
|
2330
2692
|
}
|
|
2331
2693
|
if (options.sanitize !== false) {
|
|
2332
|
-
const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
|
|
2333
|
-
|
|
2694
|
+
const sanitizeOpts = typeof options.sanitize === "object" ? { ...options.sanitize } : {};
|
|
2695
|
+
if (options.block && sanitizeOpts.block === void 0) {
|
|
2696
|
+
sanitizeOpts.block = true;
|
|
2697
|
+
}
|
|
2698
|
+
const sanitizer = createSanitizer(sanitizeOpts);
|
|
2699
|
+
middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
|
|
2334
2700
|
}
|
|
2335
2701
|
const result = middlewares;
|
|
2336
2702
|
result.close = () => {
|
|
@@ -2657,6 +3023,16 @@ function secureCookieDefaults(options = {}) {
|
|
|
2657
3023
|
sameSite: options.sameSite ?? "Lax",
|
|
2658
3024
|
path: options.path
|
|
2659
3025
|
};
|
|
3026
|
+
if (resolved.sameSite === "None" && resolved.secure === false) {
|
|
3027
|
+
throw new Error(
|
|
3028
|
+
"[arcis] secureCookieDefaults: sameSite=None requires secure=true (modern browsers reject the cookie otherwise)"
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
if (resolved.httpOnly === false && resolved.secure === false && isProduction) {
|
|
3032
|
+
console.warn(
|
|
3033
|
+
"[arcis] secureCookieDefaults: httpOnly and secure are both disabled in production \u2014 cookies will be readable by JS and sent over HTTP"
|
|
3034
|
+
);
|
|
3035
|
+
}
|
|
2660
3036
|
return (_req, res, next) => {
|
|
2661
3037
|
const originalSetHeader = res.setHeader.bind(res);
|
|
2662
3038
|
res.setHeader = function patchedSetHeader(name, value) {
|
|
@@ -2789,7 +3165,16 @@ function detectBehavioralSignals(req) {
|
|
|
2789
3165
|
}
|
|
2790
3166
|
function detectBot(req) {
|
|
2791
3167
|
const rawUa = req.headers["user-agent"] ?? "";
|
|
2792
|
-
|
|
3168
|
+
if (rawUa.length > 2048) {
|
|
3169
|
+
return {
|
|
3170
|
+
isBot: true,
|
|
3171
|
+
category: "UNKNOWN",
|
|
3172
|
+
name: null,
|
|
3173
|
+
confidence: 0.9,
|
|
3174
|
+
signals: detectBehavioralSignals(req)
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
const ua = rawUa;
|
|
2793
3178
|
const signals = detectBehavioralSignals(req);
|
|
2794
3179
|
if (!ua) {
|
|
2795
3180
|
return {
|
|
@@ -2850,6 +3235,13 @@ function botProtection(options = {}) {
|
|
|
2850
3235
|
return next();
|
|
2851
3236
|
}
|
|
2852
3237
|
if (denySet.has(result.category)) {
|
|
3238
|
+
req.__arcis = {
|
|
3239
|
+
vector: "bot",
|
|
3240
|
+
rule: `bot/${result.category.toLowerCase()}`,
|
|
3241
|
+
severity: "medium",
|
|
3242
|
+
reason: result.name ? `Bot detected: ${result.name}` : "Bot detected",
|
|
3243
|
+
decision: "deny"
|
|
3244
|
+
};
|
|
2853
3245
|
if (onDetected) {
|
|
2854
3246
|
return onDetected(req, res, result);
|
|
2855
3247
|
}
|
|
@@ -2857,6 +3249,13 @@ function botProtection(options = {}) {
|
|
|
2857
3249
|
return;
|
|
2858
3250
|
}
|
|
2859
3251
|
if (defaultAction === "deny") {
|
|
3252
|
+
req.__arcis = {
|
|
3253
|
+
vector: "bot",
|
|
3254
|
+
rule: "bot/uncategorized",
|
|
3255
|
+
severity: "medium",
|
|
3256
|
+
reason: "Uncategorized bot under defaultAction=deny",
|
|
3257
|
+
decision: "deny"
|
|
3258
|
+
};
|
|
2860
3259
|
if (onDetected) {
|
|
2861
3260
|
return onDetected(req, res, result);
|
|
2862
3261
|
}
|
|
@@ -2910,7 +3309,14 @@ function csrfProtection(options = {}) {
|
|
|
2910
3309
|
sameSite: options.cookie?.sameSite ?? "Lax",
|
|
2911
3310
|
domain: options.cookie?.domain
|
|
2912
3311
|
};
|
|
2913
|
-
const defaultOnError = (
|
|
3312
|
+
const defaultOnError = (req, res, _next) => {
|
|
3313
|
+
req.__arcis = {
|
|
3314
|
+
vector: "csrf",
|
|
3315
|
+
rule: "csrf/token-mismatch",
|
|
3316
|
+
severity: "high",
|
|
3317
|
+
reason: "CSRF token missing or invalid",
|
|
3318
|
+
decision: "deny"
|
|
3319
|
+
};
|
|
2914
3320
|
res.status(403).json({
|
|
2915
3321
|
error: "CSRF token validation failed",
|
|
2916
3322
|
message: "Invalid or missing CSRF token. Include the token from the cookie in the X-CSRF-Token header."
|
|
@@ -2953,6 +3359,10 @@ function csrfProtection(options = {}) {
|
|
|
2953
3359
|
if (!validateCsrfToken(cookieToken, requestToken)) {
|
|
2954
3360
|
return onError(req, res, next);
|
|
2955
3361
|
}
|
|
3362
|
+
if (options.rotateOnUse) {
|
|
3363
|
+
const freshToken = generateCsrfToken(tokenLength);
|
|
3364
|
+
setCsrfCookie(res, cookieName, freshToken, cookieOpts);
|
|
3365
|
+
}
|
|
2956
3366
|
next();
|
|
2957
3367
|
};
|
|
2958
3368
|
}
|
|
@@ -2989,7 +3399,7 @@ var createCsrf = csrfProtection;
|
|
|
2989
3399
|
|
|
2990
3400
|
// src/middleware/hpp.ts
|
|
2991
3401
|
function hpp(options = {}) {
|
|
2992
|
-
const whitelist = new Set(options.whitelist ?? []);
|
|
3402
|
+
const whitelist = new Set((options.whitelist ?? []).map((k) => k.toLowerCase()));
|
|
2993
3403
|
const checkQuery = options.checkQuery ?? true;
|
|
2994
3404
|
const checkBody = options.checkBody ?? true;
|
|
2995
3405
|
return (req, _res, next) => {
|
|
@@ -2999,7 +3409,7 @@ function hpp(options = {}) {
|
|
|
2999
3409
|
for (const [key, value] of Object.entries(req.query)) {
|
|
3000
3410
|
if (Array.isArray(value)) {
|
|
3001
3411
|
const strings = value.filter((v) => typeof v === "string");
|
|
3002
|
-
if (whitelist.has(key)) {
|
|
3412
|
+
if (whitelist.has(key.toLowerCase())) {
|
|
3003
3413
|
clean[key] = strings;
|
|
3004
3414
|
} else {
|
|
3005
3415
|
polluted[key] = strings;
|
|
@@ -3017,7 +3427,7 @@ function hpp(options = {}) {
|
|
|
3017
3427
|
const clean = {};
|
|
3018
3428
|
for (const [key, value] of Object.entries(req.body)) {
|
|
3019
3429
|
if (Array.isArray(value)) {
|
|
3020
|
-
if (whitelist.has(key)) {
|
|
3430
|
+
if (whitelist.has(key.toLowerCase())) {
|
|
3021
3431
|
clean[key] = value;
|
|
3022
3432
|
} else {
|
|
3023
3433
|
polluted[key] = value;
|
|
@@ -3035,6 +3445,89 @@ function hpp(options = {}) {
|
|
|
3035
3445
|
}
|
|
3036
3446
|
var createHpp = hpp;
|
|
3037
3447
|
|
|
3448
|
+
// src/middleware/signup-protection.ts
|
|
3449
|
+
function checkSignup(req, options = {}) {
|
|
3450
|
+
const {
|
|
3451
|
+
emailField = "email",
|
|
3452
|
+
checkEmail = true,
|
|
3453
|
+
blockDisposable = true,
|
|
3454
|
+
checkBot = true,
|
|
3455
|
+
allowedBotCategories = [],
|
|
3456
|
+
allowedEmailDomains = [],
|
|
3457
|
+
blockedEmailDomains = []
|
|
3458
|
+
} = options;
|
|
3459
|
+
if (checkBot) {
|
|
3460
|
+
const bot = detectBot(req);
|
|
3461
|
+
if (bot.isBot && !allowedBotCategories.includes(bot.category)) {
|
|
3462
|
+
return {
|
|
3463
|
+
allowed: false,
|
|
3464
|
+
reason: "bot",
|
|
3465
|
+
details: { category: bot.category, name: bot.name, confidence: bot.confidence }
|
|
3466
|
+
};
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
if (checkEmail) {
|
|
3470
|
+
const email = req.body?.[emailField];
|
|
3471
|
+
if (typeof email !== "string" || email.length === 0) {
|
|
3472
|
+
return { allowed: false, reason: "missing_email" };
|
|
3473
|
+
}
|
|
3474
|
+
const result = validateEmail(email, {
|
|
3475
|
+
checkDisposable: blockDisposable,
|
|
3476
|
+
allowedDomains: allowedEmailDomains,
|
|
3477
|
+
blockedDomains: blockedEmailDomains
|
|
3478
|
+
});
|
|
3479
|
+
if (!result.valid) {
|
|
3480
|
+
const reason = result.reason === "disposable" ? "disposable_email" : "invalid_email";
|
|
3481
|
+
return { allowed: false, reason, details: { emailReason: result.reason } };
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
return { allowed: true, reason: "ok" };
|
|
3485
|
+
}
|
|
3486
|
+
function signupProtection(options = {}) {
|
|
3487
|
+
const rateLimitCfg = options.rateLimit;
|
|
3488
|
+
const limiter = rateLimitCfg === false ? null : createRateLimiter({
|
|
3489
|
+
max: rateLimitCfg?.max ?? 5,
|
|
3490
|
+
windowMs: rateLimitCfg?.windowMs ?? 6e4,
|
|
3491
|
+
message: "Too many signup attempts"
|
|
3492
|
+
});
|
|
3493
|
+
const handler = (req, res, next) => {
|
|
3494
|
+
const result = checkSignup(req, options);
|
|
3495
|
+
if (!result.allowed) {
|
|
3496
|
+
options.onBlocked?.(req, result);
|
|
3497
|
+
const status = result.reason === "bot" ? 403 : 400;
|
|
3498
|
+
res.status(status).json({ error: "signup_blocked", reason: result.reason });
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
if (limiter) {
|
|
3502
|
+
let rateLimited = false;
|
|
3503
|
+
const rateLimitNext = (err) => {
|
|
3504
|
+
if (err) return next(err);
|
|
3505
|
+
if (!rateLimited) next();
|
|
3506
|
+
};
|
|
3507
|
+
const patchedRes = new Proxy(res, {
|
|
3508
|
+
get(target, prop) {
|
|
3509
|
+
if (prop === "status") {
|
|
3510
|
+
return (code) => {
|
|
3511
|
+
if (code === 429) {
|
|
3512
|
+
rateLimited = true;
|
|
3513
|
+
options.onBlocked?.(req, { allowed: false, reason: "rate_limited" });
|
|
3514
|
+
}
|
|
3515
|
+
return target.status.call(target, code);
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
return Reflect.get(target, prop);
|
|
3519
|
+
}
|
|
3520
|
+
});
|
|
3521
|
+
limiter(req, patchedRes, rateLimitNext);
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
next();
|
|
3525
|
+
};
|
|
3526
|
+
const middleware = handler;
|
|
3527
|
+
middleware.close = () => limiter?.close();
|
|
3528
|
+
return middleware;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3038
3531
|
// src/utils/ip.ts
|
|
3039
3532
|
var PLATFORM_HEADERS = {
|
|
3040
3533
|
cloudflare: "cf-connecting-ip",
|
|
@@ -3332,10 +3825,13 @@ exports.RateLimitError = RateLimitError;
|
|
|
3332
3825
|
exports.RedisStore = RedisStore;
|
|
3333
3826
|
exports.SanitizationError = SanitizationError;
|
|
3334
3827
|
exports.SecurityThreatError = SecurityThreatError;
|
|
3828
|
+
exports.TelemetryClient = TelemetryClient;
|
|
3829
|
+
exports.TelemetryHttpError = TelemetryHttpError;
|
|
3335
3830
|
exports.VALIDATION = VALIDATION;
|
|
3336
3831
|
exports.arcis = arcis;
|
|
3337
3832
|
exports.arcisFunction = arcisWithMethods;
|
|
3338
3833
|
exports.botProtection = botProtection;
|
|
3834
|
+
exports.checkSignup = checkSignup;
|
|
3339
3835
|
exports.createCors = createCors;
|
|
3340
3836
|
exports.createCsrf = createCsrf;
|
|
3341
3837
|
exports.createErrorHandler = createErrorHandler;
|
|
@@ -3405,6 +3901,7 @@ exports.scanObjectPii = scanObjectPii;
|
|
|
3405
3901
|
exports.scanPii = scanPii;
|
|
3406
3902
|
exports.secureCookieDefaults = secureCookieDefaults;
|
|
3407
3903
|
exports.securityHeaders = securityHeaders;
|
|
3904
|
+
exports.signupProtection = signupProtection;
|
|
3408
3905
|
exports.validate = validate;
|
|
3409
3906
|
exports.validateCsrfToken = validateCsrfToken;
|
|
3410
3907
|
exports.validateEmail = validateEmail;
|