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