@arcis/node 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/constants.d.ts +1 -1
- package/dist/core/constants.d.ts.map +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs.map +1 -1
- package/dist/core/types.d.ts +6 -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 +405 -20
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +402 -21
- 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 +609 -9
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/index.mjs +608 -10
- 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.js +26 -8
- package/dist/sanitizers/index.js.map +1 -1
- package/dist/sanitizers/index.mjs +26 -8
- package/dist/sanitizers/index.mjs.map +1 -1
- package/dist/sanitizers/pii.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 +60 -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 +59 -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 +7 -1
package/dist/index.js
CHANGED
|
@@ -85,6 +85,9 @@ var XSS_REMOVE_PATTERNS = [
|
|
|
85
85
|
/<script[^>]*>[\s\S]*?<\/script>/gi,
|
|
86
86
|
/** Standalone/unclosed script tags */
|
|
87
87
|
/<script[^>]*>/gi,
|
|
88
|
+
/** style — CSS expression() and behavior: attacks (IE-era but still relevant) */
|
|
89
|
+
/<style[^>]*>[\s\S]*?<\/style>/gi,
|
|
90
|
+
/<style[^>]*/gi,
|
|
88
91
|
/** iframe — full block and partial/unclosed */
|
|
89
92
|
/<iframe[^>]*>[\s\S]*?<\/iframe>/gi,
|
|
90
93
|
/<iframe[^>]*/gi,
|
|
@@ -400,13 +403,13 @@ function createRateLimiter(options = {}) {
|
|
|
400
403
|
statusCode = RATE_LIMIT.DEFAULT_STATUS_CODE,
|
|
401
404
|
keyGenerator = (req) => {
|
|
402
405
|
const ip = req.ip ?? req.socket?.remoteAddress;
|
|
403
|
-
if (
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return
|
|
406
|
+
if (ip) return ip;
|
|
407
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
408
|
+
const lang = req.headers["accept-language"] ?? "";
|
|
409
|
+
const fp = `${ua}|${lang}`;
|
|
410
|
+
let hash = 0;
|
|
411
|
+
for (let i = 0; i < fp.length; i++) hash = (hash << 5) - hash + fp.charCodeAt(i) | 0;
|
|
412
|
+
return `unknown:${hash.toString(36)}`;
|
|
410
413
|
},
|
|
411
414
|
skip,
|
|
412
415
|
store: externalStore
|
|
@@ -618,6 +621,80 @@ var SanitizationError = class extends ArcisError {
|
|
|
618
621
|
}
|
|
619
622
|
};
|
|
620
623
|
|
|
624
|
+
// src/middleware/telemetry.ts
|
|
625
|
+
var THREAT_TO_VECTOR = {
|
|
626
|
+
xss: "xss",
|
|
627
|
+
sql_injection: "sql",
|
|
628
|
+
nosql_injection: "nosql",
|
|
629
|
+
path_traversal: "path",
|
|
630
|
+
command_injection: "command",
|
|
631
|
+
prototype_pollution: "prototype",
|
|
632
|
+
header_injection: "header",
|
|
633
|
+
ssti: "ssti",
|
|
634
|
+
xxe: "xxe"
|
|
635
|
+
};
|
|
636
|
+
function createTelemetryEmitter(client) {
|
|
637
|
+
return (req, res, next) => {
|
|
638
|
+
const start = performance.now();
|
|
639
|
+
res.on("finish", () => {
|
|
640
|
+
try {
|
|
641
|
+
const event = buildEvent(req, res.statusCode, performance.now() - start);
|
|
642
|
+
client.record(event);
|
|
643
|
+
} catch {
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
next();
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function tapSanitizerThreats(handler) {
|
|
650
|
+
return (req, res, next) => {
|
|
651
|
+
handler(req, res, (err) => {
|
|
652
|
+
if (err instanceof SecurityThreatError) {
|
|
653
|
+
const vector = THREAT_TO_VECTOR[err.threatType] ?? err.threatType;
|
|
654
|
+
req.__arcis = {
|
|
655
|
+
vector,
|
|
656
|
+
rule: `${vector}/match`,
|
|
657
|
+
severity: "high",
|
|
658
|
+
matchedPattern: err.pattern,
|
|
659
|
+
reason: err.message,
|
|
660
|
+
decision: "deny"
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
next(err);
|
|
664
|
+
});
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function buildEvent(req, status, latencyMs) {
|
|
668
|
+
const marker = req.__arcis;
|
|
669
|
+
const decision = marker?.decision ?? inferDecision(status);
|
|
670
|
+
return {
|
|
671
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
672
|
+
ip: extractIp(req),
|
|
673
|
+
method: (req.method ?? "GET").toUpperCase(),
|
|
674
|
+
path: req.path ?? req.url ?? "/",
|
|
675
|
+
decision,
|
|
676
|
+
vector: marker?.vector ?? (status === 429 ? "rate-limit" : void 0),
|
|
677
|
+
rule: marker?.rule ?? (status === 429 ? "rate-limit/exceeded" : void 0),
|
|
678
|
+
severity: marker?.severity ?? (status === 429 ? "medium" : void 0),
|
|
679
|
+
userAgent: typeof req.headers?.["user-agent"] === "string" ? req.headers["user-agent"] : "",
|
|
680
|
+
reason: marker?.reason,
|
|
681
|
+
status,
|
|
682
|
+
matchedPattern: marker?.matchedPattern,
|
|
683
|
+
latencyMs: Math.max(0, latencyMs)
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
function inferDecision(status) {
|
|
687
|
+
if (status === 429) return "deny";
|
|
688
|
+
if (status === 400) return "deny";
|
|
689
|
+
if (status === 403) return "deny";
|
|
690
|
+
return "allow";
|
|
691
|
+
}
|
|
692
|
+
function extractIp(req) {
|
|
693
|
+
if (typeof req.ip === "string" && req.ip.length > 0) return req.ip;
|
|
694
|
+
const remote = req.socket?.remoteAddress;
|
|
695
|
+
return typeof remote === "string" ? remote : "0.0.0.0";
|
|
696
|
+
}
|
|
697
|
+
|
|
621
698
|
// src/sanitizers/utils.ts
|
|
622
699
|
function encodeHtmlEntities(str) {
|
|
623
700
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -975,17 +1052,19 @@ var SSTI_REMOVE_PATTERNS = [
|
|
|
975
1052
|
/** Jinja2 / Twig: {{ ... }} — always strip (not valid in any JS context) */
|
|
976
1053
|
/\{\{.*?\}\}/g,
|
|
977
1054
|
/**
|
|
978
|
-
* Freemarker / Spring EL: ${...} —
|
|
979
|
-
*
|
|
1055
|
+
* Freemarker / Spring EL: ${...} — strip when expression contains operators,
|
|
1056
|
+
* method calls, or Python dunder patterns (sandbox escape).
|
|
980
1057
|
* Bare ${name} and ${user.name} are left intact (JS template literal syntax).
|
|
981
1058
|
*/
|
|
1059
|
+
/\$\{[^}]*__\w+__[^}]*\}/g,
|
|
982
1060
|
/\$\{[^}]*[?!()*+\-/][^}]*\}/g,
|
|
983
1061
|
/** ERB / EJS: <%= ... %> */
|
|
984
1062
|
/<%[=\-]?.*?%>/gs,
|
|
985
1063
|
/**
|
|
986
|
-
* Pug / Jade: #{...} — same narrowing as ${ above.
|
|
1064
|
+
* Pug / Jade: #{...} — same narrowing as ${ above, plus dunder detection.
|
|
987
1065
|
* #{name} output expressions are left intact.
|
|
988
1066
|
*/
|
|
1067
|
+
/#\{[^}]*__\w+__[^}]*\}/g,
|
|
989
1068
|
/#\{[^}]*[?!()*+\-/][^}]*\}/g,
|
|
990
1069
|
/** Python dunder sandbox escape — always strip */
|
|
991
1070
|
/__(?:class|mro|subclasses|globals|builtins|import)__/gi
|
|
@@ -1034,6 +1113,8 @@ function detectSsti(input) {
|
|
|
1034
1113
|
}
|
|
1035
1114
|
|
|
1036
1115
|
// src/sanitizers/xxe.ts
|
|
1116
|
+
var MAX_XXE_INPUT_BYTES = 1e6;
|
|
1117
|
+
var MAX_ENTITY_REFERENCES = 64;
|
|
1037
1118
|
var XXE_DETECT_PATTERNS = [
|
|
1038
1119
|
/** DOCTYPE declaration */
|
|
1039
1120
|
/<!DOCTYPE\b/gi,
|
|
@@ -1063,6 +1144,19 @@ function sanitizeXxe(input, collectThreats = false) {
|
|
|
1063
1144
|
const threats = [];
|
|
1064
1145
|
let value = input;
|
|
1065
1146
|
let wasSanitized = false;
|
|
1147
|
+
if (value.length > MAX_XXE_INPUT_BYTES) {
|
|
1148
|
+
if (collectThreats) {
|
|
1149
|
+
threats.push({ type: "xxe", pattern: "oversize_input", original: `length=${value.length}` });
|
|
1150
|
+
}
|
|
1151
|
+
return collectThreats ? { value: "", wasSanitized: true, threats } : "";
|
|
1152
|
+
}
|
|
1153
|
+
const entityRefs = value.match(/&\w+;/g);
|
|
1154
|
+
if (entityRefs && entityRefs.length > MAX_ENTITY_REFERENCES) {
|
|
1155
|
+
if (collectThreats) {
|
|
1156
|
+
threats.push({ type: "xxe", pattern: "entity_expansion", original: `count=${entityRefs.length}` });
|
|
1157
|
+
}
|
|
1158
|
+
return collectThreats ? { value: "", wasSanitized: true, threats } : "";
|
|
1159
|
+
}
|
|
1066
1160
|
for (const pattern of XXE_REMOVE_PATTERNS) {
|
|
1067
1161
|
pattern.lastIndex = 0;
|
|
1068
1162
|
if (pattern.test(value)) {
|
|
@@ -1100,12 +1194,10 @@ function detectXxe(input) {
|
|
|
1100
1194
|
}
|
|
1101
1195
|
|
|
1102
1196
|
// src/sanitizers/jsonp.ts
|
|
1103
|
-
var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.
|
|
1197
|
+
var SAFE_CALLBACK_PATTERN = /^[a-zA-Z_$][a-zA-Z0-9_$.]*$/;
|
|
1104
1198
|
var DANGEROUS_CALLBACK_PATTERNS = [
|
|
1105
|
-
|
|
1199
|
+
/\.\./
|
|
1106
1200
|
// prototype chain traversal
|
|
1107
|
-
/\[\s*\]/
|
|
1108
|
-
// empty bracket access
|
|
1109
1201
|
];
|
|
1110
1202
|
function sanitizeJsonpCallback(callback, maxLength = 128) {
|
|
1111
1203
|
if (typeof callback !== "string" || callback.length === 0) {
|
|
@@ -1190,7 +1282,7 @@ function detectHeaderInjection(input) {
|
|
|
1190
1282
|
|
|
1191
1283
|
// src/sanitizers/pii.ts
|
|
1192
1284
|
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;
|
|
1285
|
+
var PHONE_RE = /(?<!\d)(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\d)/g;
|
|
1194
1286
|
var CREDIT_CARD_RE = /\b(?:\d[ -]*?){13,19}\b/g;
|
|
1195
1287
|
var SSN_RE = /\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g;
|
|
1196
1288
|
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 +2406,192 @@ function createRedactor(sensitiveKeys = []) {
|
|
|
2314
2406
|
}
|
|
2315
2407
|
var safeLog = createSafeLogger;
|
|
2316
2408
|
|
|
2409
|
+
// src/telemetry/client.ts
|
|
2410
|
+
var DEFAULT_BATCH_SIZE = 50;
|
|
2411
|
+
var MAX_BATCH_SIZE = 500;
|
|
2412
|
+
var DEFAULT_FLUSH_INTERVAL_MS = 5e3;
|
|
2413
|
+
var MIN_FLUSH_INTERVAL_MS = 500;
|
|
2414
|
+
var FLUSH_TIMEOUT_MS = 1e4;
|
|
2415
|
+
var TelemetryClient = class {
|
|
2416
|
+
constructor(options) {
|
|
2417
|
+
this.queue = [];
|
|
2418
|
+
this.flushing = false;
|
|
2419
|
+
this.closed = false;
|
|
2420
|
+
if (!options.endpoint || typeof options.endpoint !== "string") {
|
|
2421
|
+
throw new TypeError("TelemetryClient: `endpoint` is required");
|
|
2422
|
+
}
|
|
2423
|
+
this.endpoint = options.endpoint;
|
|
2424
|
+
this.apiKey = options.apiKey;
|
|
2425
|
+
this.workspaceId = options.workspaceId;
|
|
2426
|
+
this.batchSize = clamp(
|
|
2427
|
+
options.batchSize ?? DEFAULT_BATCH_SIZE,
|
|
2428
|
+
1,
|
|
2429
|
+
MAX_BATCH_SIZE
|
|
2430
|
+
);
|
|
2431
|
+
this.flushIntervalMs = Math.max(
|
|
2432
|
+
options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS,
|
|
2433
|
+
MIN_FLUSH_INTERVAL_MS
|
|
2434
|
+
);
|
|
2435
|
+
this.onError = options.onError ?? (() => {
|
|
2436
|
+
});
|
|
2437
|
+
this.startTimer();
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Enqueue an event. Fast, synchronous, cannot throw.
|
|
2441
|
+
* Triggers a flush if the queue has reached `batchSize`.
|
|
2442
|
+
*/
|
|
2443
|
+
record(event) {
|
|
2444
|
+
if (this.closed) return;
|
|
2445
|
+
this.queue.push(event);
|
|
2446
|
+
if (this.queue.length >= this.batchSize) {
|
|
2447
|
+
void this.flush();
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Manually flush the queue. Pulls up to `batchSize` events into a batch and
|
|
2452
|
+
* POSTs them. Returns a resolved promise on success OR on handled failure.
|
|
2453
|
+
* Never throws.
|
|
2454
|
+
*/
|
|
2455
|
+
async flush() {
|
|
2456
|
+
if (this.flushing) return;
|
|
2457
|
+
if (this.queue.length === 0) return;
|
|
2458
|
+
this.flushing = true;
|
|
2459
|
+
try {
|
|
2460
|
+
const batch = this.queue.splice(0, this.batchSize);
|
|
2461
|
+
await this.send(batch);
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
this.safeNotify(err);
|
|
2464
|
+
} finally {
|
|
2465
|
+
this.flushing = false;
|
|
2466
|
+
}
|
|
2467
|
+
if (!this.closed && this.queue.length > 0) {
|
|
2468
|
+
void this.flush();
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Shut down: stop the interval timer and attempt one final flush.
|
|
2473
|
+
* Safe to call multiple times.
|
|
2474
|
+
*/
|
|
2475
|
+
async close() {
|
|
2476
|
+
if (this.closed) return;
|
|
2477
|
+
this.closed = true;
|
|
2478
|
+
if (this.timer !== void 0) {
|
|
2479
|
+
clearInterval(this.timer);
|
|
2480
|
+
this.timer = void 0;
|
|
2481
|
+
}
|
|
2482
|
+
if (this.signalHandler !== void 0) {
|
|
2483
|
+
process.off("SIGTERM", this.signalHandler);
|
|
2484
|
+
process.off("SIGINT", this.signalHandler);
|
|
2485
|
+
this.signalHandler = void 0;
|
|
2486
|
+
}
|
|
2487
|
+
try {
|
|
2488
|
+
await this.flush();
|
|
2489
|
+
} catch {
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* Register `SIGTERM` / `SIGINT` handlers that call `close()` to drain
|
|
2494
|
+
* the queue on graceful shutdown. Opt-in — libraries should not silently
|
|
2495
|
+
* attach global signal handlers. Safe to call multiple times.
|
|
2496
|
+
*/
|
|
2497
|
+
installShutdownHooks() {
|
|
2498
|
+
if (this.signalHandler !== void 0 || this.closed) return;
|
|
2499
|
+
const handler = () => {
|
|
2500
|
+
void this.close();
|
|
2501
|
+
};
|
|
2502
|
+
this.signalHandler = handler;
|
|
2503
|
+
process.once("SIGTERM", handler);
|
|
2504
|
+
process.once("SIGINT", handler);
|
|
2505
|
+
}
|
|
2506
|
+
/** Count of events currently waiting to be sent. Useful for tests. */
|
|
2507
|
+
get pendingCount() {
|
|
2508
|
+
return this.queue.length;
|
|
2509
|
+
}
|
|
2510
|
+
// ── internals ─────────────────────────────────────────────────────────
|
|
2511
|
+
async send(batch) {
|
|
2512
|
+
const headers = {
|
|
2513
|
+
"content-type": "application/json"
|
|
2514
|
+
};
|
|
2515
|
+
if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
|
|
2516
|
+
if (this.workspaceId) headers["x-workspace-id"] = this.workspaceId;
|
|
2517
|
+
const controller = new AbortController();
|
|
2518
|
+
const abortTimer = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
|
2519
|
+
try {
|
|
2520
|
+
const res = await fetch(this.endpoint, {
|
|
2521
|
+
method: "POST",
|
|
2522
|
+
headers,
|
|
2523
|
+
body: JSON.stringify({ events: batch }),
|
|
2524
|
+
signal: controller.signal
|
|
2525
|
+
});
|
|
2526
|
+
if (!res.ok) {
|
|
2527
|
+
const text = await safeReadBody(res);
|
|
2528
|
+
throw new TelemetryHttpError(res.status, text);
|
|
2529
|
+
}
|
|
2530
|
+
} finally {
|
|
2531
|
+
clearTimeout(abortTimer);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
startTimer() {
|
|
2535
|
+
this.timer = setInterval(() => {
|
|
2536
|
+
void this.flush();
|
|
2537
|
+
}, this.flushIntervalMs);
|
|
2538
|
+
this.timer.unref?.();
|
|
2539
|
+
}
|
|
2540
|
+
safeNotify(err) {
|
|
2541
|
+
try {
|
|
2542
|
+
this.onError(err instanceof Error ? err : new Error(String(err)));
|
|
2543
|
+
} catch {
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
var TelemetryHttpError = class extends Error {
|
|
2548
|
+
constructor(status, responseBody) {
|
|
2549
|
+
super(`Telemetry ingest returned HTTP ${status}`);
|
|
2550
|
+
this.status = status;
|
|
2551
|
+
this.responseBody = responseBody;
|
|
2552
|
+
this.name = "TelemetryHttpError";
|
|
2553
|
+
}
|
|
2554
|
+
};
|
|
2555
|
+
function clamp(value, min, max) {
|
|
2556
|
+
if (!Number.isFinite(value)) return min;
|
|
2557
|
+
return Math.max(min, Math.min(max, Math.trunc(value)));
|
|
2558
|
+
}
|
|
2559
|
+
async function safeReadBody(res) {
|
|
2560
|
+
try {
|
|
2561
|
+
const text = await res.text();
|
|
2562
|
+
return text.slice(0, 500);
|
|
2563
|
+
} catch {
|
|
2564
|
+
return "";
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2317
2568
|
// src/middleware/main.ts
|
|
2569
|
+
function buildTelemetryFromEnv() {
|
|
2570
|
+
const env = typeof process !== "undefined" ? process.env : void 0;
|
|
2571
|
+
const endpoint = env?.ARCIS_ENDPOINT;
|
|
2572
|
+
if (!endpoint) return void 0;
|
|
2573
|
+
const opts = { endpoint };
|
|
2574
|
+
if (env?.ARCIS_WORKSPACE_ID) opts.workspaceId = env.ARCIS_WORKSPACE_ID;
|
|
2575
|
+
if (env?.ARCIS_KEY) opts.apiKey = env.ARCIS_KEY;
|
|
2576
|
+
const batch = env?.ARCIS_BATCH_SIZE ? parseInt(env.ARCIS_BATCH_SIZE, 10) : NaN;
|
|
2577
|
+
if (!Number.isNaN(batch)) opts.batchSize = batch;
|
|
2578
|
+
const flush = env?.ARCIS_FLUSH_INTERVAL_MS ? parseInt(env.ARCIS_FLUSH_INTERVAL_MS, 10) : NaN;
|
|
2579
|
+
if (!Number.isNaN(flush)) opts.flushIntervalMs = flush;
|
|
2580
|
+
return opts;
|
|
2581
|
+
}
|
|
2318
2582
|
function arcis(options = {}) {
|
|
2319
2583
|
const middlewares = [];
|
|
2320
2584
|
const cleanupFns = [];
|
|
2585
|
+
let telemetryClient;
|
|
2586
|
+
const telemetryOpts = options.telemetry?.endpoint ? options.telemetry : buildTelemetryFromEnv();
|
|
2587
|
+
if (telemetryOpts) {
|
|
2588
|
+
const client = new TelemetryClient(telemetryOpts);
|
|
2589
|
+
telemetryClient = client;
|
|
2590
|
+
middlewares.push(createTelemetryEmitter(client));
|
|
2591
|
+
cleanupFns.push(() => {
|
|
2592
|
+
void client.close();
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2321
2595
|
if (options.headers !== false) {
|
|
2322
2596
|
const headerOpts = typeof options.headers === "object" ? options.headers : {};
|
|
2323
2597
|
middlewares.push(createHeaders(headerOpts));
|
|
@@ -2330,7 +2604,8 @@ function arcis(options = {}) {
|
|
|
2330
2604
|
}
|
|
2331
2605
|
if (options.sanitize !== false) {
|
|
2332
2606
|
const sanitizeOpts = typeof options.sanitize === "object" ? options.sanitize : {};
|
|
2333
|
-
|
|
2607
|
+
const sanitizer = createSanitizer(sanitizeOpts);
|
|
2608
|
+
middlewares.push(telemetryClient ? tapSanitizerThreats(sanitizer) : sanitizer);
|
|
2334
2609
|
}
|
|
2335
2610
|
const result = middlewares;
|
|
2336
2611
|
result.close = () => {
|
|
@@ -2657,6 +2932,16 @@ function secureCookieDefaults(options = {}) {
|
|
|
2657
2932
|
sameSite: options.sameSite ?? "Lax",
|
|
2658
2933
|
path: options.path
|
|
2659
2934
|
};
|
|
2935
|
+
if (resolved.sameSite === "None" && resolved.secure === false) {
|
|
2936
|
+
throw new Error(
|
|
2937
|
+
"[arcis] secureCookieDefaults: sameSite=None requires secure=true (modern browsers reject the cookie otherwise)"
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
if (resolved.httpOnly === false && resolved.secure === false && isProduction) {
|
|
2941
|
+
console.warn(
|
|
2942
|
+
"[arcis] secureCookieDefaults: httpOnly and secure are both disabled in production \u2014 cookies will be readable by JS and sent over HTTP"
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2660
2945
|
return (_req, res, next) => {
|
|
2661
2946
|
const originalSetHeader = res.setHeader.bind(res);
|
|
2662
2947
|
res.setHeader = function patchedSetHeader(name, value) {
|
|
@@ -2789,7 +3074,16 @@ function detectBehavioralSignals(req) {
|
|
|
2789
3074
|
}
|
|
2790
3075
|
function detectBot(req) {
|
|
2791
3076
|
const rawUa = req.headers["user-agent"] ?? "";
|
|
2792
|
-
|
|
3077
|
+
if (rawUa.length > 2048) {
|
|
3078
|
+
return {
|
|
3079
|
+
isBot: true,
|
|
3080
|
+
category: "UNKNOWN",
|
|
3081
|
+
name: null,
|
|
3082
|
+
confidence: 0.9,
|
|
3083
|
+
signals: detectBehavioralSignals(req)
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
const ua = rawUa;
|
|
2793
3087
|
const signals = detectBehavioralSignals(req);
|
|
2794
3088
|
if (!ua) {
|
|
2795
3089
|
return {
|
|
@@ -2953,6 +3247,10 @@ function csrfProtection(options = {}) {
|
|
|
2953
3247
|
if (!validateCsrfToken(cookieToken, requestToken)) {
|
|
2954
3248
|
return onError(req, res, next);
|
|
2955
3249
|
}
|
|
3250
|
+
if (options.rotateOnUse) {
|
|
3251
|
+
const freshToken = generateCsrfToken(tokenLength);
|
|
3252
|
+
setCsrfCookie(res, cookieName, freshToken, cookieOpts);
|
|
3253
|
+
}
|
|
2956
3254
|
next();
|
|
2957
3255
|
};
|
|
2958
3256
|
}
|
|
@@ -2989,7 +3287,7 @@ var createCsrf = csrfProtection;
|
|
|
2989
3287
|
|
|
2990
3288
|
// src/middleware/hpp.ts
|
|
2991
3289
|
function hpp(options = {}) {
|
|
2992
|
-
const whitelist = new Set(options.whitelist ?? []);
|
|
3290
|
+
const whitelist = new Set((options.whitelist ?? []).map((k) => k.toLowerCase()));
|
|
2993
3291
|
const checkQuery = options.checkQuery ?? true;
|
|
2994
3292
|
const checkBody = options.checkBody ?? true;
|
|
2995
3293
|
return (req, _res, next) => {
|
|
@@ -2999,7 +3297,7 @@ function hpp(options = {}) {
|
|
|
2999
3297
|
for (const [key, value] of Object.entries(req.query)) {
|
|
3000
3298
|
if (Array.isArray(value)) {
|
|
3001
3299
|
const strings = value.filter((v) => typeof v === "string");
|
|
3002
|
-
if (whitelist.has(key)) {
|
|
3300
|
+
if (whitelist.has(key.toLowerCase())) {
|
|
3003
3301
|
clean[key] = strings;
|
|
3004
3302
|
} else {
|
|
3005
3303
|
polluted[key] = strings;
|
|
@@ -3017,7 +3315,7 @@ function hpp(options = {}) {
|
|
|
3017
3315
|
const clean = {};
|
|
3018
3316
|
for (const [key, value] of Object.entries(req.body)) {
|
|
3019
3317
|
if (Array.isArray(value)) {
|
|
3020
|
-
if (whitelist.has(key)) {
|
|
3318
|
+
if (whitelist.has(key.toLowerCase())) {
|
|
3021
3319
|
clean[key] = value;
|
|
3022
3320
|
} else {
|
|
3023
3321
|
polluted[key] = value;
|
|
@@ -3035,6 +3333,89 @@ function hpp(options = {}) {
|
|
|
3035
3333
|
}
|
|
3036
3334
|
var createHpp = hpp;
|
|
3037
3335
|
|
|
3336
|
+
// src/middleware/signup-protection.ts
|
|
3337
|
+
function checkSignup(req, options = {}) {
|
|
3338
|
+
const {
|
|
3339
|
+
emailField = "email",
|
|
3340
|
+
checkEmail = true,
|
|
3341
|
+
blockDisposable = true,
|
|
3342
|
+
checkBot = true,
|
|
3343
|
+
allowedBotCategories = [],
|
|
3344
|
+
allowedEmailDomains = [],
|
|
3345
|
+
blockedEmailDomains = []
|
|
3346
|
+
} = options;
|
|
3347
|
+
if (checkBot) {
|
|
3348
|
+
const bot = detectBot(req);
|
|
3349
|
+
if (bot.isBot && !allowedBotCategories.includes(bot.category)) {
|
|
3350
|
+
return {
|
|
3351
|
+
allowed: false,
|
|
3352
|
+
reason: "bot",
|
|
3353
|
+
details: { category: bot.category, name: bot.name, confidence: bot.confidence }
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
if (checkEmail) {
|
|
3358
|
+
const email = req.body?.[emailField];
|
|
3359
|
+
if (typeof email !== "string" || email.length === 0) {
|
|
3360
|
+
return { allowed: false, reason: "missing_email" };
|
|
3361
|
+
}
|
|
3362
|
+
const result = validateEmail(email, {
|
|
3363
|
+
checkDisposable: blockDisposable,
|
|
3364
|
+
allowedDomains: allowedEmailDomains,
|
|
3365
|
+
blockedDomains: blockedEmailDomains
|
|
3366
|
+
});
|
|
3367
|
+
if (!result.valid) {
|
|
3368
|
+
const reason = result.reason === "disposable" ? "disposable_email" : "invalid_email";
|
|
3369
|
+
return { allowed: false, reason, details: { emailReason: result.reason } };
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3372
|
+
return { allowed: true, reason: "ok" };
|
|
3373
|
+
}
|
|
3374
|
+
function signupProtection(options = {}) {
|
|
3375
|
+
const rateLimitCfg = options.rateLimit;
|
|
3376
|
+
const limiter = rateLimitCfg === false ? null : createRateLimiter({
|
|
3377
|
+
max: rateLimitCfg?.max ?? 5,
|
|
3378
|
+
windowMs: rateLimitCfg?.windowMs ?? 6e4,
|
|
3379
|
+
message: "Too many signup attempts"
|
|
3380
|
+
});
|
|
3381
|
+
const handler = (req, res, next) => {
|
|
3382
|
+
const result = checkSignup(req, options);
|
|
3383
|
+
if (!result.allowed) {
|
|
3384
|
+
options.onBlocked?.(req, result);
|
|
3385
|
+
const status = result.reason === "bot" ? 403 : 400;
|
|
3386
|
+
res.status(status).json({ error: "signup_blocked", reason: result.reason });
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
if (limiter) {
|
|
3390
|
+
let rateLimited = false;
|
|
3391
|
+
const rateLimitNext = (err) => {
|
|
3392
|
+
if (err) return next(err);
|
|
3393
|
+
if (!rateLimited) next();
|
|
3394
|
+
};
|
|
3395
|
+
const patchedRes = new Proxy(res, {
|
|
3396
|
+
get(target, prop) {
|
|
3397
|
+
if (prop === "status") {
|
|
3398
|
+
return (code) => {
|
|
3399
|
+
if (code === 429) {
|
|
3400
|
+
rateLimited = true;
|
|
3401
|
+
options.onBlocked?.(req, { allowed: false, reason: "rate_limited" });
|
|
3402
|
+
}
|
|
3403
|
+
return target.status.call(target, code);
|
|
3404
|
+
};
|
|
3405
|
+
}
|
|
3406
|
+
return Reflect.get(target, prop);
|
|
3407
|
+
}
|
|
3408
|
+
});
|
|
3409
|
+
limiter(req, patchedRes, rateLimitNext);
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
next();
|
|
3413
|
+
};
|
|
3414
|
+
const middleware = handler;
|
|
3415
|
+
middleware.close = () => limiter?.close();
|
|
3416
|
+
return middleware;
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3038
3419
|
// src/utils/ip.ts
|
|
3039
3420
|
var PLATFORM_HEADERS = {
|
|
3040
3421
|
cloudflare: "cf-connecting-ip",
|
|
@@ -3332,10 +3713,13 @@ exports.RateLimitError = RateLimitError;
|
|
|
3332
3713
|
exports.RedisStore = RedisStore;
|
|
3333
3714
|
exports.SanitizationError = SanitizationError;
|
|
3334
3715
|
exports.SecurityThreatError = SecurityThreatError;
|
|
3716
|
+
exports.TelemetryClient = TelemetryClient;
|
|
3717
|
+
exports.TelemetryHttpError = TelemetryHttpError;
|
|
3335
3718
|
exports.VALIDATION = VALIDATION;
|
|
3336
3719
|
exports.arcis = arcis;
|
|
3337
3720
|
exports.arcisFunction = arcisWithMethods;
|
|
3338
3721
|
exports.botProtection = botProtection;
|
|
3722
|
+
exports.checkSignup = checkSignup;
|
|
3339
3723
|
exports.createCors = createCors;
|
|
3340
3724
|
exports.createCsrf = createCsrf;
|
|
3341
3725
|
exports.createErrorHandler = createErrorHandler;
|
|
@@ -3405,6 +3789,7 @@ exports.scanObjectPii = scanObjectPii;
|
|
|
3405
3789
|
exports.scanPii = scanPii;
|
|
3406
3790
|
exports.secureCookieDefaults = secureCookieDefaults;
|
|
3407
3791
|
exports.securityHeaders = securityHeaders;
|
|
3792
|
+
exports.signupProtection = signupProtection;
|
|
3408
3793
|
exports.validate = validate;
|
|
3409
3794
|
exports.validateCsrfToken = validateCsrfToken;
|
|
3410
3795
|
exports.validateEmail = validateEmail;
|