@patchstack/connect 0.5.15 → 0.5.17
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/AGENT-INSTALL.md +42 -14
- package/README.md +5 -5
- package/dist/cli.js +695 -183
- package/dist/cli.js.map +1 -1
- package/dist/protect/templates/demo-rules.json +2 -2
- package/dist/protect.cjs +1388 -748
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.cts +110 -16
- package/dist/protect.d.ts +110 -16
- package/dist/protect.edge.js +1376 -736
- package/dist/protect.edge.js.map +3 -3
- package/dist/protect.js +1376 -736
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
package/dist/protect.js
CHANGED
|
@@ -376,18 +376,124 @@ function appendOwn(target, key, value) {
|
|
|
376
376
|
return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
// src/protect/engine/
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
379
|
+
// src/protect/engine/cookies.js
|
|
380
|
+
function parseCookieHeader(header) {
|
|
381
|
+
const cookies = {};
|
|
382
|
+
if (typeof header !== "string" || header === "") return cookies;
|
|
383
|
+
const repeated = /* @__PURE__ */ new Map();
|
|
384
|
+
for (const pair of header.split(";")) {
|
|
385
|
+
const eq = pair.indexOf("=");
|
|
386
|
+
if (eq === -1) continue;
|
|
387
|
+
const name = pair.slice(0, eq).trim();
|
|
388
|
+
if (name === "") continue;
|
|
389
|
+
let value = pair.slice(eq + 1).trim();
|
|
390
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') value = value.slice(1, -1);
|
|
391
|
+
if (!Object.hasOwn(cookies, name)) {
|
|
392
|
+
setOwn(cookies, name, value);
|
|
393
|
+
} else {
|
|
394
|
+
let values = repeated.get(name);
|
|
395
|
+
if (values === void 0) {
|
|
396
|
+
values = [cookies[name]];
|
|
397
|
+
repeated.set(name, values);
|
|
398
|
+
setOwn(cookies, name, values);
|
|
399
|
+
}
|
|
400
|
+
values.push(value);
|
|
401
|
+
}
|
|
387
402
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
403
|
+
return cookies;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// src/protect/engine/normalizer.js
|
|
407
|
+
var HTML_ENTITIES = {
|
|
408
|
+
"&": "&",
|
|
409
|
+
"<": "<",
|
|
410
|
+
">": ">",
|
|
411
|
+
""": '"',
|
|
412
|
+
"'": "'",
|
|
413
|
+
"'": "'",
|
|
414
|
+
" ": " ",
|
|
415
|
+
"'": "'",
|
|
416
|
+
""": '"',
|
|
417
|
+
"<": "<",
|
|
418
|
+
">": ">",
|
|
419
|
+
"&": "&",
|
|
420
|
+
""": '"',
|
|
421
|
+
"<": "<",
|
|
422
|
+
">": ">",
|
|
423
|
+
"&": "&"
|
|
424
|
+
};
|
|
425
|
+
var MAX_DECODE_ITERATIONS = 5;
|
|
426
|
+
function normalize(value, options = {}) {
|
|
427
|
+
if (typeof value !== "string") {
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
const opts = {
|
|
431
|
+
urlDecode: true,
|
|
432
|
+
htmlDecode: true,
|
|
433
|
+
sqlComments: true,
|
|
434
|
+
nullBytes: true,
|
|
435
|
+
whitespace: true,
|
|
436
|
+
...options
|
|
437
|
+
};
|
|
438
|
+
let result = value;
|
|
439
|
+
if (opts.nullBytes) {
|
|
440
|
+
result = removeNullBytes(result);
|
|
441
|
+
}
|
|
442
|
+
if (opts.urlDecode) {
|
|
443
|
+
result = urlDecode(result);
|
|
444
|
+
}
|
|
445
|
+
if (opts.htmlDecode) {
|
|
446
|
+
result = htmlEntityDecode(result);
|
|
447
|
+
}
|
|
448
|
+
if (opts.sqlComments) {
|
|
449
|
+
result = removeSqlComments(result);
|
|
450
|
+
}
|
|
451
|
+
if (opts.whitespace) {
|
|
452
|
+
result = normalizeWhitespace(result);
|
|
453
|
+
}
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
function urlDecode(value) {
|
|
457
|
+
if (typeof value !== "string") {
|
|
458
|
+
return value;
|
|
459
|
+
}
|
|
460
|
+
let result = value;
|
|
461
|
+
let previous = "";
|
|
462
|
+
let iterations = 0;
|
|
463
|
+
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
|
|
464
|
+
previous = result;
|
|
465
|
+
iterations++;
|
|
466
|
+
result = safeUrlDecode(result);
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
var utf8 = new TextDecoder();
|
|
471
|
+
function safeUrlDecode(value) {
|
|
472
|
+
return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
|
|
473
|
+
try {
|
|
474
|
+
return decodeURIComponent(run);
|
|
475
|
+
} catch {
|
|
476
|
+
const bytes = new Uint8Array(run.length / 3);
|
|
477
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
|
|
478
|
+
return utf8.decode(bytes);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function decodeQueryPlus(target) {
|
|
483
|
+
if (typeof target !== "string") return target;
|
|
484
|
+
const query = target.indexOf("?");
|
|
485
|
+
if (query === -1) return target;
|
|
486
|
+
return target.slice(0, query + 1) + target.slice(query + 1).replace(/\+/g, " ");
|
|
487
|
+
}
|
|
488
|
+
function htmlEntityDecode(value) {
|
|
489
|
+
if (typeof value !== "string") {
|
|
490
|
+
return value;
|
|
491
|
+
}
|
|
492
|
+
let result = value;
|
|
493
|
+
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
494
|
+
result = result.split(entity).join(char);
|
|
495
|
+
}
|
|
496
|
+
return decodeHtmlEntities(result);
|
|
391
497
|
}
|
|
392
498
|
var NAMED_ENTITIES = {
|
|
393
499
|
lt: "<",
|
|
@@ -395,7 +501,6 @@ var NAMED_ENTITIES = {
|
|
|
395
501
|
amp: "&",
|
|
396
502
|
quot: '"',
|
|
397
503
|
apos: "'",
|
|
398
|
-
"#39": "'",
|
|
399
504
|
nbsp: "\xA0",
|
|
400
505
|
sol: "/",
|
|
401
506
|
bsol: "\\",
|
|
@@ -414,7 +519,7 @@ var NAMED_ENTITIES = {
|
|
|
414
519
|
ast: "*"
|
|
415
520
|
};
|
|
416
521
|
function decodeHtmlEntities(input) {
|
|
417
|
-
return input.replace(/&(#[xX]
|
|
522
|
+
return input.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
|
|
418
523
|
if (body[0] === "#") {
|
|
419
524
|
const hex = body[1] === "x" || body[1] === "X";
|
|
420
525
|
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
@@ -428,74 +533,359 @@ function decodeHtmlEntities(input) {
|
|
|
428
533
|
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
|
429
534
|
});
|
|
430
535
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
constructor(req) {
|
|
435
|
-
this.#req = req;
|
|
436
|
-
this.#cookies = null;
|
|
536
|
+
function removeSqlComments(value) {
|
|
537
|
+
if (typeof value !== "string") {
|
|
538
|
+
return value;
|
|
437
539
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
540
|
+
let result = value;
|
|
541
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
542
|
+
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
543
|
+
return result;
|
|
544
|
+
}
|
|
545
|
+
function removeNullBytes(value) {
|
|
546
|
+
if (typeof value !== "string") {
|
|
547
|
+
return value;
|
|
548
|
+
}
|
|
549
|
+
let result = value.replace(/\x00/g, "");
|
|
550
|
+
result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
551
|
+
return result;
|
|
552
|
+
}
|
|
553
|
+
function normalizeWhitespace(value) {
|
|
554
|
+
if (typeof value !== "string") {
|
|
555
|
+
return value;
|
|
556
|
+
}
|
|
557
|
+
let result = value.replace(/[\t\r\n\f\v]+/g, " ");
|
|
558
|
+
result = result.replace(/ {2,}/g, " ");
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
561
|
+
var MAX_RAW_CHARS = 1024 * 1024;
|
|
562
|
+
var MAX_RAW_NODES = 25e3;
|
|
563
|
+
var MAX_RAW_DEPTH = 200;
|
|
564
|
+
var MAX_RAW_ENTRIES = 2e3;
|
|
565
|
+
function serializeForRawDetection(body) {
|
|
566
|
+
if (body === null || body === void 0) return "";
|
|
567
|
+
if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
|
|
568
|
+
const visited = /* @__PURE__ */ new Set();
|
|
569
|
+
const output = [];
|
|
570
|
+
const stack = [{ kind: "value", value: body, depth: 0, root: true }];
|
|
571
|
+
let chars = 0;
|
|
572
|
+
let nodes = 0;
|
|
573
|
+
const append = (text) => {
|
|
574
|
+
if (chars >= MAX_RAW_CHARS) return;
|
|
575
|
+
const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
|
|
576
|
+
output.push(piece);
|
|
577
|
+
chars += piece.length;
|
|
578
|
+
};
|
|
579
|
+
while (stack.length > 0 && chars < MAX_RAW_CHARS) {
|
|
580
|
+
const task = stack.pop();
|
|
581
|
+
if (task.kind === "text") {
|
|
582
|
+
append(task.text);
|
|
583
|
+
continue;
|
|
441
584
|
}
|
|
442
|
-
if (
|
|
443
|
-
|
|
585
|
+
if (task.kind === "key") {
|
|
586
|
+
append(boundedJsonString(task.value));
|
|
587
|
+
append(":");
|
|
588
|
+
continue;
|
|
444
589
|
}
|
|
445
|
-
|
|
446
|
-
|
|
590
|
+
nodes++;
|
|
591
|
+
if (nodes > MAX_RAW_NODES) {
|
|
592
|
+
append('"[NodeLimit]"');
|
|
593
|
+
continue;
|
|
447
594
|
}
|
|
448
|
-
|
|
449
|
-
|
|
595
|
+
const value = task.value;
|
|
596
|
+
if (value === null || value === void 0) {
|
|
597
|
+
append(task.root ? "" : "null");
|
|
598
|
+
continue;
|
|
450
599
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
600
|
+
if (typeof value === "string") {
|
|
601
|
+
append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
|
|
602
|
+
continue;
|
|
454
603
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
case "get":
|
|
459
|
-
return this.#resolveGet(key);
|
|
460
|
-
case "post":
|
|
461
|
-
return this.#resolvePost(key);
|
|
462
|
-
case "request":
|
|
463
|
-
return this.#resolveRequest(key);
|
|
464
|
-
case "cookie":
|
|
465
|
-
return this.#resolveCookie(key);
|
|
466
|
-
case "server":
|
|
467
|
-
return this.#resolveServer(key);
|
|
468
|
-
case "files":
|
|
469
|
-
return this.#resolveFiles(key);
|
|
470
|
-
case "response":
|
|
471
|
-
return this.#resolveResponse(key);
|
|
472
|
-
case "egress":
|
|
473
|
-
return this.#resolveEgress(key);
|
|
474
|
-
default:
|
|
475
|
-
return [];
|
|
604
|
+
if (typeof value !== "object") {
|
|
605
|
+
append(String(value).slice(0, MAX_RAW_CHARS));
|
|
606
|
+
continue;
|
|
476
607
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
#resolveResponse(key) {
|
|
481
|
-
const resp = this.#req._response;
|
|
482
|
-
if (!resp) {
|
|
483
|
-
return [];
|
|
608
|
+
if (visited.has(value)) {
|
|
609
|
+
append("[Circular]");
|
|
610
|
+
continue;
|
|
484
611
|
}
|
|
485
|
-
if (
|
|
486
|
-
|
|
612
|
+
if (task.depth >= MAX_RAW_DEPTH) {
|
|
613
|
+
append('"[DepthLimit]"');
|
|
614
|
+
continue;
|
|
487
615
|
}
|
|
488
|
-
|
|
489
|
-
|
|
616
|
+
visited.add(value);
|
|
617
|
+
if (Array.isArray(value)) {
|
|
618
|
+
const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
|
|
619
|
+
const items = [];
|
|
620
|
+
for (let i = 0; i < length2; i++) {
|
|
621
|
+
if (i > 0) items.push({ kind: "text", text: "," });
|
|
622
|
+
items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
|
|
623
|
+
}
|
|
624
|
+
if (value.length > length2) {
|
|
625
|
+
if (length2 > 0) items.push({ kind: "text", text: "," });
|
|
626
|
+
items.push({ kind: "text", text: '"[EntryLimit]"' });
|
|
627
|
+
}
|
|
628
|
+
stack.push({ kind: "text", text: "]" });
|
|
629
|
+
for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
|
|
630
|
+
stack.push({ kind: "text", text: "[" });
|
|
631
|
+
continue;
|
|
490
632
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
633
|
+
let keys;
|
|
634
|
+
try {
|
|
635
|
+
keys = Object.getOwnPropertyNames(value);
|
|
636
|
+
} catch {
|
|
637
|
+
append('"[Unreadable]"');
|
|
638
|
+
continue;
|
|
494
639
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
640
|
+
const length = Math.min(keys.length, MAX_RAW_ENTRIES);
|
|
641
|
+
const entries = [];
|
|
642
|
+
for (let i = 0; i < length; i++) {
|
|
643
|
+
const key = keys[i];
|
|
644
|
+
if (i > 0) entries.push({ kind: "text", text: "," });
|
|
645
|
+
entries.push({ kind: "key", value: key });
|
|
646
|
+
entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
|
|
647
|
+
}
|
|
648
|
+
if (keys.length > length) {
|
|
649
|
+
if (length > 0) entries.push({ kind: "text", text: "," });
|
|
650
|
+
entries.push({ kind: "text", text: '"[EntryLimit]":null' });
|
|
651
|
+
}
|
|
652
|
+
stack.push({ kind: "text", text: "}" });
|
|
653
|
+
for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
|
|
654
|
+
stack.push({ kind: "text", text: "{" });
|
|
655
|
+
}
|
|
656
|
+
return output.join("");
|
|
657
|
+
}
|
|
658
|
+
function boundedJsonString(value) {
|
|
659
|
+
return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
|
|
660
|
+
}
|
|
661
|
+
function readOwnValue(value, key) {
|
|
662
|
+
try {
|
|
663
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
664
|
+
return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
|
|
665
|
+
} catch {
|
|
666
|
+
return "[Unreadable]";
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
|
|
670
|
+
function requestField(req, key) {
|
|
671
|
+
if (req === null || typeof req !== "object") return void 0;
|
|
672
|
+
if (Object.hasOwn(req, key)) return req[key];
|
|
673
|
+
if (!INHERITED_ACCESSORS.has(key)) return void 0;
|
|
674
|
+
let holder = Object.getPrototypeOf(req);
|
|
675
|
+
while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
|
|
676
|
+
if (holder === null || holder === Object.prototype) return void 0;
|
|
677
|
+
const descriptor = Object.getOwnPropertyDescriptor(holder, key);
|
|
678
|
+
if (typeof descriptor?.get !== "function") return void 0;
|
|
679
|
+
return req[key];
|
|
680
|
+
}
|
|
681
|
+
function normalizeRequest(req, options = {}) {
|
|
682
|
+
const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
|
|
683
|
+
const body = requestField(req, "body");
|
|
684
|
+
const url = requestField(req, "url");
|
|
685
|
+
const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
|
|
686
|
+
const headers = requestField(req, "headers") || {};
|
|
687
|
+
return {
|
|
688
|
+
query: normalizeObject(requestField(req, "query") || {}, options),
|
|
689
|
+
body: normalizeObject(body || {}, options),
|
|
690
|
+
headers: normalizeObject(headers, options),
|
|
691
|
+
// Cookies are split into name/value pairs BEFORE their values are normalised, and normalised the
|
|
692
|
+
// same way whether a framework parsed them or they come from the header — so a rule on a cookie
|
|
693
|
+
// sees one value, independent of which cookie parser (if any) ran first.
|
|
694
|
+
cookies: normalizeObject(cookieValues(requestField(req, "cookies"), headers), options),
|
|
695
|
+
url: normalize(decodeQueryPlus(url || ""), options),
|
|
696
|
+
originalUrl: normalize(decodeQueryPlus(requestField(req, "originalUrl") || url || ""), options),
|
|
697
|
+
_rawBody: rawBody
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function cookieValues(parsed, headers) {
|
|
701
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
702
|
+
return parseCookieHeader(headers?.cookie);
|
|
703
|
+
}
|
|
704
|
+
var MAX_NORMALIZE_DEPTH = 1e3;
|
|
705
|
+
function normalizeObject(value, options = {}, depth = 0) {
|
|
706
|
+
if (typeof value === "string") {
|
|
707
|
+
return normalize(value, options);
|
|
708
|
+
}
|
|
709
|
+
if (value === null || typeof value !== "object") {
|
|
710
|
+
return value;
|
|
711
|
+
}
|
|
712
|
+
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
713
|
+
options.onLimit?.();
|
|
714
|
+
return value;
|
|
715
|
+
}
|
|
716
|
+
const copies = /* @__PURE__ */ new Map();
|
|
717
|
+
const copyOf = (node) => {
|
|
718
|
+
const copy = Array.isArray(node) ? new Array(node.length) : {};
|
|
719
|
+
copies.set(node, copy);
|
|
720
|
+
return copy;
|
|
721
|
+
};
|
|
722
|
+
const top = copyOf(value);
|
|
723
|
+
const stack = [[value, top, depth]];
|
|
724
|
+
while (stack.length > 0) {
|
|
725
|
+
const [node, copy, level] = stack.pop();
|
|
726
|
+
for (const key of Object.keys(node)) {
|
|
727
|
+
const child = node[key];
|
|
728
|
+
if (typeof child === "string") {
|
|
729
|
+
setOwn(copy, key, normalize(child, options));
|
|
730
|
+
} else if (child === null || typeof child !== "object") {
|
|
731
|
+
setOwn(copy, key, child);
|
|
732
|
+
} else if (copies.has(child)) {
|
|
733
|
+
setOwn(copy, key, copies.get(child));
|
|
734
|
+
} else if (level + 1 >= MAX_NORMALIZE_DEPTH) {
|
|
735
|
+
setOwn(copy, key, child);
|
|
736
|
+
options.onLimit?.();
|
|
737
|
+
} else {
|
|
738
|
+
const childCopy = copyOf(child);
|
|
739
|
+
setOwn(copy, key, childCopy);
|
|
740
|
+
stack.push([child, childCopy, level + 1]);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return top;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/protect/engine/request.js
|
|
748
|
+
var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
|
|
749
|
+
var ownValue = (obj, key) => obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
|
|
750
|
+
function fieldSpellings(key) {
|
|
751
|
+
const spellings = [{ key, nested: true }];
|
|
752
|
+
if (key.includes("[")) {
|
|
753
|
+
const dotted = key.replace(/\[\]$/, "").replace(/\[([^\][]*)\]/g, ".$1");
|
|
754
|
+
if (dotted !== key && dotted !== "" && !dotted.includes("[") && !dotted.includes("]")) {
|
|
755
|
+
spellings.push({ key: dotted, nested: true });
|
|
756
|
+
}
|
|
757
|
+
return spellings;
|
|
758
|
+
}
|
|
759
|
+
const [head, ...rest] = key.split(".");
|
|
760
|
+
const bracketed = head + rest.map((part) => `[${part}]`).join("");
|
|
761
|
+
if (bracketed !== key) spellings.push({ key: bracketed, nested: false });
|
|
762
|
+
spellings.push({ key: `${bracketed}[]`, nested: false });
|
|
763
|
+
return spellings;
|
|
764
|
+
}
|
|
765
|
+
var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
|
|
766
|
+
var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
|
|
767
|
+
function base64DecodeUtf8(value) {
|
|
768
|
+
const str = String(value);
|
|
769
|
+
if (typeof Buffer !== "undefined") {
|
|
770
|
+
return Buffer.from(str, "base64").toString("utf-8");
|
|
771
|
+
}
|
|
772
|
+
const binary = atob(str);
|
|
773
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
774
|
+
return new TextDecoder().decode(bytes);
|
|
775
|
+
}
|
|
776
|
+
var TEXT_MUTATIONS = /* @__PURE__ */ new Set(["base64_decode", "urldecode", "htmlentitydecode"]);
|
|
777
|
+
var MAX_MAPPED_NODES = 2e4;
|
|
778
|
+
var MAX_MAPPED_DEPTH = 1e3;
|
|
779
|
+
function mapStringLeaves(root, fn, onLimit) {
|
|
780
|
+
const copies = /* @__PURE__ */ new Map();
|
|
781
|
+
const copyOf = (node) => {
|
|
782
|
+
const copy = Array.isArray(node) ? [] : {};
|
|
783
|
+
copies.set(node, copy);
|
|
784
|
+
return copy;
|
|
785
|
+
};
|
|
786
|
+
const top = copyOf(root);
|
|
787
|
+
const stack = [[root, top, 0]];
|
|
788
|
+
let visited = 0;
|
|
789
|
+
while (stack.length) {
|
|
790
|
+
const [node, copy, depth] = stack.pop();
|
|
791
|
+
visited++;
|
|
792
|
+
for (const key of Object.keys(node)) {
|
|
793
|
+
const child = node[key];
|
|
794
|
+
if (typeof child === "string") {
|
|
795
|
+
setOwn(copy, key, fn(child));
|
|
796
|
+
} else if (child === null || typeof child !== "object") {
|
|
797
|
+
setOwn(copy, key, child);
|
|
798
|
+
} else if (copies.has(child)) {
|
|
799
|
+
setOwn(copy, key, copies.get(child));
|
|
800
|
+
} else if (depth + 1 >= MAX_MAPPED_DEPTH || visited + stack.length >= MAX_MAPPED_NODES) {
|
|
801
|
+
setOwn(copy, key, child);
|
|
802
|
+
onLimit();
|
|
803
|
+
} else {
|
|
804
|
+
const childCopy = copyOf(child);
|
|
805
|
+
setOwn(copy, key, childCopy);
|
|
806
|
+
stack.push([child, childCopy, depth + 1]);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return top;
|
|
811
|
+
}
|
|
812
|
+
var RequestResolver = class {
|
|
813
|
+
#req;
|
|
814
|
+
#cookies;
|
|
815
|
+
#skips = /* @__PURE__ */ new Set();
|
|
816
|
+
constructor(req) {
|
|
817
|
+
this.#req = req;
|
|
818
|
+
this.#cookies = null;
|
|
819
|
+
}
|
|
820
|
+
/** Record an inspection limit this evaluation reached (reported once per reason). */
|
|
821
|
+
noteSkip(reason) {
|
|
822
|
+
this.#skips.add(reason);
|
|
823
|
+
}
|
|
824
|
+
/** The inspection limits recorded so far, in the order first reached. */
|
|
825
|
+
get skips() {
|
|
826
|
+
return [...this.#skips];
|
|
827
|
+
}
|
|
828
|
+
resolve(parameter) {
|
|
829
|
+
if (!parameter || parameter === "false") {
|
|
830
|
+
return [null];
|
|
831
|
+
}
|
|
832
|
+
if (parameter === "rules") {
|
|
833
|
+
return [null];
|
|
834
|
+
}
|
|
835
|
+
if (parameter === "raw") {
|
|
836
|
+
return this.#resolveRaw();
|
|
837
|
+
}
|
|
838
|
+
if (parameter === "all") {
|
|
839
|
+
return this.#resolveAll();
|
|
840
|
+
}
|
|
841
|
+
const dotIndex = parameter.indexOf(".");
|
|
842
|
+
if (dotIndex === -1) {
|
|
843
|
+
return [];
|
|
844
|
+
}
|
|
845
|
+
const source = parameter.substring(0, dotIndex);
|
|
846
|
+
const key = parameter.substring(dotIndex + 1);
|
|
847
|
+
switch (source) {
|
|
848
|
+
case "get":
|
|
849
|
+
return this.#resolveGet(key);
|
|
850
|
+
case "post":
|
|
851
|
+
return this.#resolvePost(key);
|
|
852
|
+
case "request":
|
|
853
|
+
return this.#resolveRequest(key);
|
|
854
|
+
case "cookie":
|
|
855
|
+
return this.#resolveCookie(key);
|
|
856
|
+
case "server":
|
|
857
|
+
return this.#resolveServer(key);
|
|
858
|
+
case "files":
|
|
859
|
+
return this.#resolveFiles(key);
|
|
860
|
+
case "response":
|
|
861
|
+
return this.#resolveResponse(key);
|
|
862
|
+
case "egress":
|
|
863
|
+
return this.#resolveEgress(key);
|
|
864
|
+
default:
|
|
865
|
+
return [];
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
// Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
|
|
869
|
+
// what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
|
|
870
|
+
#resolveResponse(key) {
|
|
871
|
+
const resp = this.#req._response;
|
|
872
|
+
if (!resp) {
|
|
873
|
+
return [];
|
|
874
|
+
}
|
|
875
|
+
if (key === "status") {
|
|
876
|
+
return resp.status !== void 0 ? [String(resp.status)] : [];
|
|
877
|
+
}
|
|
878
|
+
if (key === "body") {
|
|
879
|
+
return resp.body != null && resp.body !== "" ? [resp.body] : [];
|
|
880
|
+
}
|
|
881
|
+
if (key === "headers") {
|
|
882
|
+
const headers = resp.headers ?? {};
|
|
883
|
+
return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
|
|
884
|
+
}
|
|
885
|
+
if (key.startsWith("header.")) {
|
|
886
|
+
const name = key.slice("header.".length).toLowerCase();
|
|
887
|
+
const headers = resp.headers ?? {};
|
|
888
|
+
return Object.hasOwn(headers, name) ? [headers[name]] : [];
|
|
499
889
|
}
|
|
500
890
|
return [];
|
|
501
891
|
}
|
|
@@ -525,6 +915,9 @@ var RequestResolver = class {
|
|
|
525
915
|
if (value === null || value === void 0) {
|
|
526
916
|
return value;
|
|
527
917
|
}
|
|
918
|
+
if (typeof value === "object" && TEXT_MUTATIONS.has(mutation)) {
|
|
919
|
+
return mapStringLeaves(value, (leaf) => this.#applyMutation(mutation, leaf), () => this.noteSkip("container-cap"));
|
|
920
|
+
}
|
|
528
921
|
switch (mutation) {
|
|
529
922
|
case "base64_decode":
|
|
530
923
|
try {
|
|
@@ -545,11 +938,7 @@ var RequestResolver = class {
|
|
|
545
938
|
return value;
|
|
546
939
|
}
|
|
547
940
|
case "urldecode":
|
|
548
|
-
|
|
549
|
-
return decodeURIComponent(String(value));
|
|
550
|
-
} catch {
|
|
551
|
-
return value;
|
|
552
|
-
}
|
|
941
|
+
return safeUrlDecode(String(value).replace(/\+/g, " "));
|
|
553
942
|
case "htmlentitydecode":
|
|
554
943
|
return decodeHtmlEntities(String(value));
|
|
555
944
|
case "intval":
|
|
@@ -568,16 +957,30 @@ var RequestResolver = class {
|
|
|
568
957
|
if (key.endsWith("*")) {
|
|
569
958
|
return this.#resolveWildcard(query, key);
|
|
570
959
|
}
|
|
571
|
-
|
|
572
|
-
return value !== void 0 ? [value] : [];
|
|
960
|
+
return this.#lookup(query, key);
|
|
573
961
|
}
|
|
574
962
|
#resolvePost(key) {
|
|
575
963
|
const body = this.#req.body ?? {};
|
|
576
964
|
if (key.endsWith("*")) {
|
|
577
965
|
return this.#resolveWildcard(body, key);
|
|
578
966
|
}
|
|
579
|
-
|
|
580
|
-
|
|
967
|
+
return this.#lookup(body, key);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Every value a field path names in `obj`, in either spelling a form or query field can take.
|
|
971
|
+
*
|
|
972
|
+
* A parser that expands brackets (`qs`, as Express uses) turns `user[name]=x` into `{ user: { name } }`
|
|
973
|
+
* and `id[]=x` into `{ id: [x] }`; a flat parser (`URLSearchParams`, as the Fetch and Node adapters use)
|
|
974
|
+
* keeps `user[name]` and `id[]` as literal keys. The rule names the field once, so both shapes answer
|
|
975
|
+
* to both spellings: `user.name` and `user[name]`, `id` and `id[]`.
|
|
976
|
+
*/
|
|
977
|
+
#lookup(obj, key) {
|
|
978
|
+
const values = [];
|
|
979
|
+
for (const candidate of fieldSpellings(key)) {
|
|
980
|
+
const value = candidate.nested ? this.#getNestedValue(obj, candidate.key) : ownValue(obj, candidate.key);
|
|
981
|
+
if (value !== void 0) values.push(value);
|
|
982
|
+
}
|
|
983
|
+
return values;
|
|
581
984
|
}
|
|
582
985
|
#resolveRequest(key) {
|
|
583
986
|
const query = this.#req.query ?? {};
|
|
@@ -590,8 +993,11 @@ var RequestResolver = class {
|
|
|
590
993
|
...this.#resolveWildcard(cookies, key)
|
|
591
994
|
];
|
|
592
995
|
}
|
|
593
|
-
const
|
|
594
|
-
|
|
996
|
+
const fromQuery = this.#lookup(query, key);
|
|
997
|
+
if (fromQuery.length > 0) return fromQuery;
|
|
998
|
+
const fromBody = this.#lookup(body, key);
|
|
999
|
+
if (fromBody.length > 0) return fromBody;
|
|
1000
|
+
return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
|
|
595
1001
|
}
|
|
596
1002
|
#resolveCookie(key) {
|
|
597
1003
|
const cookies = this.#parseCookies();
|
|
@@ -761,329 +1167,51 @@ var RequestResolver = class {
|
|
|
761
1167
|
this.#cookies = this.#req.cookies;
|
|
762
1168
|
return this.#cookies;
|
|
763
1169
|
}
|
|
764
|
-
|
|
765
|
-
if (!header) {
|
|
766
|
-
this.#cookies = {};
|
|
767
|
-
return this.#cookies;
|
|
768
|
-
}
|
|
769
|
-
const cookies = {};
|
|
770
|
-
for (const pair of header.split(";")) {
|
|
771
|
-
const eqIndex = pair.indexOf("=");
|
|
772
|
-
if (eqIndex === -1) {
|
|
773
|
-
continue;
|
|
774
|
-
}
|
|
775
|
-
const name = pair.substring(0, eqIndex).trim();
|
|
776
|
-
const value = pair.substring(eqIndex + 1).trim();
|
|
777
|
-
setOwn(cookies, name, value);
|
|
778
|
-
}
|
|
779
|
-
this.#cookies = cookies;
|
|
1170
|
+
this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
|
|
780
1171
|
return this.#cookies;
|
|
781
1172
|
}
|
|
782
1173
|
};
|
|
783
1174
|
|
|
784
1175
|
// src/protect/notify.js
|
|
785
|
-
var reported = /* @__PURE__ */ new
|
|
1176
|
+
var reported = /* @__PURE__ */ new WeakMap();
|
|
1177
|
+
var warnings = 0;
|
|
1178
|
+
var MAX_WARNINGS = 20;
|
|
786
1179
|
function notify(fn, arg, label) {
|
|
787
1180
|
if (typeof fn !== "function") return false;
|
|
788
1181
|
try {
|
|
789
1182
|
const result = fn(arg);
|
|
790
1183
|
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
791
1184
|
try {
|
|
792
|
-
result.then(void 0, (err) => warnOnce(label, err));
|
|
1185
|
+
result.then(void 0, (err) => warnOnce(fn, label, err));
|
|
793
1186
|
} catch {
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
return true;
|
|
797
|
-
} catch (err) {
|
|
798
|
-
warnOnce(label, err);
|
|
799
|
-
return false;
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
function warnOnce(label, err) {
|
|
803
|
-
if (reported.has(label)) return;
|
|
804
|
-
reported.add(label);
|
|
805
|
-
try {
|
|
806
|
-
console.warn(
|
|
807
|
-
`Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
|
|
808
|
-
);
|
|
809
|
-
} catch {
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
// src/protect/engine/normalizer.js
|
|
814
|
-
var HTML_ENTITIES = {
|
|
815
|
-
"&": "&",
|
|
816
|
-
"<": "<",
|
|
817
|
-
">": ">",
|
|
818
|
-
""": '"',
|
|
819
|
-
"'": "'",
|
|
820
|
-
"'": "'",
|
|
821
|
-
" ": " ",
|
|
822
|
-
"'": "'",
|
|
823
|
-
""": '"',
|
|
824
|
-
"<": "<",
|
|
825
|
-
">": ">",
|
|
826
|
-
"&": "&",
|
|
827
|
-
""": '"',
|
|
828
|
-
"<": "<",
|
|
829
|
-
">": ">",
|
|
830
|
-
"&": "&"
|
|
831
|
-
};
|
|
832
|
-
var MAX_DECODE_ITERATIONS = 5;
|
|
833
|
-
function normalize(value, options = {}) {
|
|
834
|
-
if (typeof value !== "string") {
|
|
835
|
-
return value;
|
|
836
|
-
}
|
|
837
|
-
const opts = {
|
|
838
|
-
urlDecode: true,
|
|
839
|
-
htmlDecode: true,
|
|
840
|
-
sqlComments: true,
|
|
841
|
-
nullBytes: true,
|
|
842
|
-
whitespace: true,
|
|
843
|
-
...options
|
|
844
|
-
};
|
|
845
|
-
let result = value;
|
|
846
|
-
if (opts.nullBytes) {
|
|
847
|
-
result = removeNullBytes(result);
|
|
848
|
-
}
|
|
849
|
-
if (opts.urlDecode) {
|
|
850
|
-
result = urlDecode(result);
|
|
851
|
-
}
|
|
852
|
-
if (opts.htmlDecode) {
|
|
853
|
-
result = htmlEntityDecode(result);
|
|
854
|
-
}
|
|
855
|
-
if (opts.sqlComments) {
|
|
856
|
-
result = removeSqlComments(result);
|
|
857
|
-
}
|
|
858
|
-
if (opts.whitespace) {
|
|
859
|
-
result = normalizeWhitespace(result);
|
|
860
|
-
}
|
|
861
|
-
return result;
|
|
862
|
-
}
|
|
863
|
-
function urlDecode(value) {
|
|
864
|
-
if (typeof value !== "string") {
|
|
865
|
-
return value;
|
|
866
|
-
}
|
|
867
|
-
let result = value;
|
|
868
|
-
let previous = "";
|
|
869
|
-
let iterations = 0;
|
|
870
|
-
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
|
|
871
|
-
previous = result;
|
|
872
|
-
iterations++;
|
|
873
|
-
try {
|
|
874
|
-
result = decodeURIComponent(result);
|
|
875
|
-
} catch {
|
|
876
|
-
result = safeUrlDecode(result);
|
|
877
|
-
break;
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
return result;
|
|
881
|
-
}
|
|
882
|
-
function safeUrlDecode(value) {
|
|
883
|
-
return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
|
|
884
|
-
try {
|
|
885
|
-
return String.fromCharCode(parseInt(hex, 16));
|
|
886
|
-
} catch {
|
|
887
|
-
return match;
|
|
888
|
-
}
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
function htmlEntityDecode(value) {
|
|
892
|
-
if (typeof value !== "string") {
|
|
893
|
-
return value;
|
|
894
|
-
}
|
|
895
|
-
let result = value;
|
|
896
|
-
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
897
|
-
result = result.split(entity).join(char);
|
|
898
|
-
}
|
|
899
|
-
result = result.replace(/&#(\d+);/g, (match, code) => {
|
|
900
|
-
const num = parseInt(code, 10);
|
|
901
|
-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
902
|
-
});
|
|
903
|
-
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
|
|
904
|
-
const num = parseInt(hex, 16);
|
|
905
|
-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
906
|
-
});
|
|
907
|
-
return result;
|
|
908
|
-
}
|
|
909
|
-
function removeSqlComments(value) {
|
|
910
|
-
if (typeof value !== "string") {
|
|
911
|
-
return value;
|
|
912
|
-
}
|
|
913
|
-
let result = value;
|
|
914
|
-
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
915
|
-
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
916
|
-
return result;
|
|
917
|
-
}
|
|
918
|
-
function removeNullBytes(value) {
|
|
919
|
-
if (typeof value !== "string") {
|
|
920
|
-
return value;
|
|
921
|
-
}
|
|
922
|
-
let result = value.replace(/\x00/g, "");
|
|
923
|
-
result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
924
|
-
return result;
|
|
925
|
-
}
|
|
926
|
-
function normalizeWhitespace(value) {
|
|
927
|
-
if (typeof value !== "string") {
|
|
928
|
-
return value;
|
|
929
|
-
}
|
|
930
|
-
let result = value.replace(/[\t\r\n\f\v]+/g, " ");
|
|
931
|
-
result = result.replace(/ {2,}/g, " ");
|
|
932
|
-
return result;
|
|
933
|
-
}
|
|
934
|
-
var MAX_RAW_CHARS = 1024 * 1024;
|
|
935
|
-
var MAX_RAW_NODES = 25e3;
|
|
936
|
-
var MAX_RAW_DEPTH = 200;
|
|
937
|
-
var MAX_RAW_ENTRIES = 2e3;
|
|
938
|
-
function serializeForRawDetection(body) {
|
|
939
|
-
if (body === null || body === void 0) return "";
|
|
940
|
-
if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
|
|
941
|
-
const visited = /* @__PURE__ */ new Set();
|
|
942
|
-
const output = [];
|
|
943
|
-
const stack = [{ kind: "value", value: body, depth: 0, root: true }];
|
|
944
|
-
let chars = 0;
|
|
945
|
-
let nodes = 0;
|
|
946
|
-
const append = (text) => {
|
|
947
|
-
if (chars >= MAX_RAW_CHARS) return;
|
|
948
|
-
const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
|
|
949
|
-
output.push(piece);
|
|
950
|
-
chars += piece.length;
|
|
951
|
-
};
|
|
952
|
-
while (stack.length > 0 && chars < MAX_RAW_CHARS) {
|
|
953
|
-
const task = stack.pop();
|
|
954
|
-
if (task.kind === "text") {
|
|
955
|
-
append(task.text);
|
|
956
|
-
continue;
|
|
957
|
-
}
|
|
958
|
-
if (task.kind === "key") {
|
|
959
|
-
append(boundedJsonString(task.value));
|
|
960
|
-
append(":");
|
|
961
|
-
continue;
|
|
962
|
-
}
|
|
963
|
-
nodes++;
|
|
964
|
-
if (nodes > MAX_RAW_NODES) {
|
|
965
|
-
append('"[NodeLimit]"');
|
|
966
|
-
continue;
|
|
967
|
-
}
|
|
968
|
-
const value = task.value;
|
|
969
|
-
if (value === null || value === void 0) {
|
|
970
|
-
append(task.root ? "" : "null");
|
|
971
|
-
continue;
|
|
972
|
-
}
|
|
973
|
-
if (typeof value === "string") {
|
|
974
|
-
append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
|
|
975
|
-
continue;
|
|
976
|
-
}
|
|
977
|
-
if (typeof value !== "object") {
|
|
978
|
-
append(String(value).slice(0, MAX_RAW_CHARS));
|
|
979
|
-
continue;
|
|
980
|
-
}
|
|
981
|
-
if (visited.has(value)) {
|
|
982
|
-
append("[Circular]");
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
985
|
-
if (task.depth >= MAX_RAW_DEPTH) {
|
|
986
|
-
append('"[DepthLimit]"');
|
|
987
|
-
continue;
|
|
988
|
-
}
|
|
989
|
-
visited.add(value);
|
|
990
|
-
if (Array.isArray(value)) {
|
|
991
|
-
const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
|
|
992
|
-
const items = [];
|
|
993
|
-
for (let i = 0; i < length2; i++) {
|
|
994
|
-
if (i > 0) items.push({ kind: "text", text: "," });
|
|
995
|
-
items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
|
|
996
|
-
}
|
|
997
|
-
if (value.length > length2) {
|
|
998
|
-
if (length2 > 0) items.push({ kind: "text", text: "," });
|
|
999
|
-
items.push({ kind: "text", text: '"[EntryLimit]"' });
|
|
1000
|
-
}
|
|
1001
|
-
stack.push({ kind: "text", text: "]" });
|
|
1002
|
-
for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
|
|
1003
|
-
stack.push({ kind: "text", text: "[" });
|
|
1004
|
-
continue;
|
|
1005
|
-
}
|
|
1006
|
-
let keys;
|
|
1007
|
-
try {
|
|
1008
|
-
keys = Object.getOwnPropertyNames(value);
|
|
1009
|
-
} catch {
|
|
1010
|
-
append('"[Unreadable]"');
|
|
1011
|
-
continue;
|
|
1012
|
-
}
|
|
1013
|
-
const length = Math.min(keys.length, MAX_RAW_ENTRIES);
|
|
1014
|
-
const entries = [];
|
|
1015
|
-
for (let i = 0; i < length; i++) {
|
|
1016
|
-
const key = keys[i];
|
|
1017
|
-
if (i > 0) entries.push({ kind: "text", text: "," });
|
|
1018
|
-
entries.push({ kind: "key", value: key });
|
|
1019
|
-
entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
|
|
1020
|
-
}
|
|
1021
|
-
if (keys.length > length) {
|
|
1022
|
-
if (length > 0) entries.push({ kind: "text", text: "," });
|
|
1023
|
-
entries.push({ kind: "text", text: '"[EntryLimit]":null' });
|
|
1024
|
-
}
|
|
1025
|
-
stack.push({ kind: "text", text: "}" });
|
|
1026
|
-
for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
|
|
1027
|
-
stack.push({ kind: "text", text: "{" });
|
|
1028
|
-
}
|
|
1029
|
-
return output.join("");
|
|
1030
|
-
}
|
|
1031
|
-
function boundedJsonString(value) {
|
|
1032
|
-
return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
|
|
1033
|
-
}
|
|
1034
|
-
function readOwnValue(value, key) {
|
|
1035
|
-
try {
|
|
1036
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1037
|
-
return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
|
|
1038
|
-
} catch {
|
|
1039
|
-
return "[Unreadable]";
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
|
|
1043
|
-
function requestField(req, key) {
|
|
1044
|
-
if (req === null || typeof req !== "object") return void 0;
|
|
1045
|
-
if (Object.hasOwn(req, key)) return req[key];
|
|
1046
|
-
if (!INHERITED_ACCESSORS.has(key)) return void 0;
|
|
1047
|
-
let holder = Object.getPrototypeOf(req);
|
|
1048
|
-
while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
|
|
1049
|
-
if (holder === null || holder === Object.prototype) return void 0;
|
|
1050
|
-
const descriptor = Object.getOwnPropertyDescriptor(holder, key);
|
|
1051
|
-
if (typeof descriptor?.get !== "function") return void 0;
|
|
1052
|
-
return req[key];
|
|
1053
|
-
}
|
|
1054
|
-
function normalizeRequest(req, options = {}) {
|
|
1055
|
-
const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
|
|
1056
|
-
const body = requestField(req, "body");
|
|
1057
|
-
const url = requestField(req, "url");
|
|
1058
|
-
const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
|
|
1059
|
-
return {
|
|
1060
|
-
query: normalizeObject(requestField(req, "query") || {}, options),
|
|
1061
|
-
body: normalizeObject(body || {}, options),
|
|
1062
|
-
headers: normalizeObject(requestField(req, "headers") || {}, options),
|
|
1063
|
-
url: normalize(url || "", options),
|
|
1064
|
-
originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
|
|
1065
|
-
_rawBody: rawBody
|
|
1066
|
-
};
|
|
1067
|
-
}
|
|
1068
|
-
var MAX_NORMALIZE_DEPTH = 200;
|
|
1069
|
-
function normalizeObject(value, options = {}, depth = 0) {
|
|
1070
|
-
if (typeof value === "string") {
|
|
1071
|
-
return normalize(value, options);
|
|
1072
|
-
}
|
|
1073
|
-
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
1074
|
-
return value;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return true;
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
warnOnce(fn, label, err);
|
|
1192
|
+
return false;
|
|
1075
1193
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1194
|
+
}
|
|
1195
|
+
function warnOnce(fn, label, err) {
|
|
1196
|
+
let labels = reported.get(fn);
|
|
1197
|
+
if (labels?.has(label)) return;
|
|
1198
|
+
if (!labels) {
|
|
1199
|
+
labels = /* @__PURE__ */ new Set();
|
|
1200
|
+
reported.set(fn, labels);
|
|
1078
1201
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1202
|
+
labels.add(label);
|
|
1203
|
+
if (warnings > MAX_WARNINGS) return;
|
|
1204
|
+
warnings++;
|
|
1205
|
+
try {
|
|
1206
|
+
if (warnings > MAX_WARNINGS) {
|
|
1207
|
+
console.warn("Patchstack: further failing callbacks passed to createProtection are not reported in this process.");
|
|
1208
|
+
return;
|
|
1083
1209
|
}
|
|
1084
|
-
|
|
1210
|
+
console.warn(
|
|
1211
|
+
`Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this callback's failures are reported once. Cause: ${err && err.message ? err.message : String(err)}`
|
|
1212
|
+
);
|
|
1213
|
+
} catch {
|
|
1085
1214
|
}
|
|
1086
|
-
return value;
|
|
1087
1215
|
}
|
|
1088
1216
|
|
|
1089
1217
|
// src/protect/engine/engine.js
|
|
@@ -1471,17 +1599,21 @@ function arrayKeyValue(value, matchObj) {
|
|
|
1471
1599
|
}
|
|
1472
1600
|
var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
|
|
1473
1601
|
function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
1474
|
-
const
|
|
1602
|
+
const leaves = [];
|
|
1475
1603
|
const stack = [[root, 0]];
|
|
1476
1604
|
let visited = 0;
|
|
1605
|
+
let truncated = false;
|
|
1477
1606
|
while (stack.length) {
|
|
1478
1607
|
const [node, depth] = stack.pop();
|
|
1479
1608
|
if (node === null || node === void 0) continue;
|
|
1480
1609
|
if (typeof node !== "object") {
|
|
1481
|
-
|
|
1610
|
+
leaves.push(node);
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
if (depth >= maxDepth || visited >= nodeCap) {
|
|
1614
|
+
truncated = true;
|
|
1482
1615
|
continue;
|
|
1483
1616
|
}
|
|
1484
|
-
if (depth >= maxDepth || visited >= nodeCap) continue;
|
|
1485
1617
|
visited++;
|
|
1486
1618
|
if (Array.isArray(node)) {
|
|
1487
1619
|
for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
|
|
@@ -1489,7 +1621,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
|
1489
1621
|
for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
|
|
1490
1622
|
}
|
|
1491
1623
|
}
|
|
1492
|
-
return
|
|
1624
|
+
return { leaves, truncated };
|
|
1625
|
+
}
|
|
1626
|
+
function skipsOf(resolver) {
|
|
1627
|
+
const skips = resolver?.skips;
|
|
1628
|
+
return skips && skips.length > 0 ? { skips } : {};
|
|
1629
|
+
}
|
|
1630
|
+
function serialisedValue(value) {
|
|
1631
|
+
try {
|
|
1632
|
+
return JSON.stringify(value);
|
|
1633
|
+
} catch {
|
|
1634
|
+
return void 0;
|
|
1635
|
+
}
|
|
1493
1636
|
}
|
|
1494
1637
|
var warnedKeys = /* @__PURE__ */ new Set();
|
|
1495
1638
|
function warnOnce2(key, message) {
|
|
@@ -1507,12 +1650,22 @@ function warnUnsupportedMatchType(type) {
|
|
|
1507
1650
|
`[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
|
|
1508
1651
|
);
|
|
1509
1652
|
}
|
|
1653
|
+
var SPECIAL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ws", "wss", "ftp", "file"]);
|
|
1654
|
+
function trimControls(text) {
|
|
1655
|
+
let start = 0;
|
|
1656
|
+
let end = text.length;
|
|
1657
|
+
while (start < end && text.charCodeAt(start) <= 32) start++;
|
|
1658
|
+
while (end > start && text.charCodeAt(end - 1) <= 32) end--;
|
|
1659
|
+
return text.slice(start, end);
|
|
1660
|
+
}
|
|
1510
1661
|
function hostFromValue(value) {
|
|
1511
|
-
const raw = String(value ?? "").trim();
|
|
1662
|
+
const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
|
|
1512
1663
|
if (raw === "") return "";
|
|
1513
|
-
|
|
1664
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(raw)?.[1]?.toLowerCase();
|
|
1665
|
+
const relative = /^[\\/]{2}/.test(raw);
|
|
1666
|
+
if (relative || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || scheme !== void 0 && SPECIAL_SCHEMES.has(scheme)) {
|
|
1514
1667
|
try {
|
|
1515
|
-
return new URL(
|
|
1668
|
+
return new URL(relative ? `http:${raw}` : raw).hostname;
|
|
1516
1669
|
} catch {
|
|
1517
1670
|
return raw;
|
|
1518
1671
|
}
|
|
@@ -1540,10 +1693,18 @@ function isInternalHost(hostname) {
|
|
|
1540
1693
|
const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
1541
1694
|
if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
|
|
1542
1695
|
if ((g[0] & 65472) === 65152) return true;
|
|
1696
|
+
if ((g[0] & 65472) === 65216) return true;
|
|
1543
1697
|
if ((g[0] & 65024) === 64512) return true;
|
|
1698
|
+
if ((g[0] & 65280) === 65280) return true;
|
|
1699
|
+
const low32 = (g[6] << 16 >>> 0 | g[7]) >>> 0;
|
|
1544
1700
|
if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
|
|
1545
|
-
return isPrivateV4Int(
|
|
1701
|
+
return isPrivateV4Int(low32);
|
|
1702
|
+
}
|
|
1703
|
+
if (g[0] === 100 && g[1] === 65435 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
|
|
1704
|
+
return isPrivateV4Int(low32);
|
|
1546
1705
|
}
|
|
1706
|
+
if (g[0] === 100 && g[1] === 65435 && g[2] === 1) return true;
|
|
1707
|
+
if (g[0] === 8194) return isPrivateV4Int((g[1] << 16 >>> 0 | g[2]) >>> 0);
|
|
1547
1708
|
return false;
|
|
1548
1709
|
}
|
|
1549
1710
|
const v4 = parseIPv4ToInt(host);
|
|
@@ -1553,6 +1714,10 @@ function isInternalHost(hostname) {
|
|
|
1553
1714
|
function isPrivateV4Int(n) {
|
|
1554
1715
|
const a = n >>> 24 & 255;
|
|
1555
1716
|
const b = n >>> 16 & 255;
|
|
1717
|
+
const c = n >>> 8 & 255;
|
|
1718
|
+
if (a >= 224) return true;
|
|
1719
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
1720
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
1556
1721
|
if (a === 127 || a === 10 || a === 0) return true;
|
|
1557
1722
|
if (a === 169 && b === 254) return true;
|
|
1558
1723
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
@@ -1901,8 +2066,12 @@ var RuleEngine = class {
|
|
|
1901
2066
|
let normalizedReq;
|
|
1902
2067
|
let resolver;
|
|
1903
2068
|
try {
|
|
1904
|
-
|
|
2069
|
+
let limited = false;
|
|
2070
|
+
normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
|
|
2071
|
+
limited = true;
|
|
2072
|
+
} }) };
|
|
1905
2073
|
resolver = new RequestResolver(normalizedReq);
|
|
2074
|
+
if (limited) resolver.noteSkip("container-cap");
|
|
1906
2075
|
} catch (err) {
|
|
1907
2076
|
this.#reportError(err);
|
|
1908
2077
|
return { blocked: false, rule: null, message: null };
|
|
@@ -1928,14 +2097,15 @@ var RuleEngine = class {
|
|
|
1928
2097
|
// that re-read the request instead would be reading it a second time: a getter, a stream or
|
|
1929
2098
|
// anything else that answers once can give a different value, and evidence that disagrees
|
|
1930
2099
|
// with the match it belongs to is worse than none.
|
|
1931
|
-
resolver
|
|
2100
|
+
resolver,
|
|
2101
|
+
...skipsOf(resolver)
|
|
1932
2102
|
};
|
|
1933
2103
|
}
|
|
1934
2104
|
} catch (err) {
|
|
1935
2105
|
this.#reportError(err);
|
|
1936
2106
|
}
|
|
1937
2107
|
}
|
|
1938
|
-
return { blocked: false, rule: null, message: null };
|
|
2108
|
+
return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
|
|
1939
2109
|
}
|
|
1940
2110
|
#evaluateRule(conditions, resolver) {
|
|
1941
2111
|
const inclusiveConditions = conditions.filter((c) => c.inclusive);
|
|
@@ -1991,9 +2161,15 @@ var RuleEngine = class {
|
|
|
1991
2161
|
if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
|
|
1992
2162
|
if (matchValue(match.type, value, match.value, match)) return true;
|
|
1993
2163
|
} else {
|
|
1994
|
-
|
|
2164
|
+
const { leaves, truncated } = collectLeafValues(value);
|
|
2165
|
+
for (const leaf of leaves) {
|
|
1995
2166
|
if (matchValue(match.type, leaf, match.value, match)) return true;
|
|
1996
2167
|
}
|
|
2168
|
+
if (truncated) {
|
|
2169
|
+
resolver.noteSkip("container-cap");
|
|
2170
|
+
const text = serialisedValue(value);
|
|
2171
|
+
if (text !== void 0 && matchValue(match.type, text, match.value, match)) return true;
|
|
2172
|
+
}
|
|
1997
2173
|
}
|
|
1998
2174
|
continue;
|
|
1999
2175
|
}
|
|
@@ -2380,6 +2556,9 @@ function conditionShapeProblem(condition) {
|
|
|
2380
2556
|
if (nested && !named) {
|
|
2381
2557
|
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
2382
2558
|
}
|
|
2559
|
+
if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
|
|
2560
|
+
return "inclusive must be true or false";
|
|
2561
|
+
}
|
|
2383
2562
|
if (named && nested) {
|
|
2384
2563
|
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
2385
2564
|
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
@@ -3086,7 +3265,7 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
3086
3265
|
// that guessed differently would attribute one request to two addresses.
|
|
3087
3266
|
ip: client.ip ?? "",
|
|
3088
3267
|
_clientIp: client,
|
|
3089
|
-
cookies:
|
|
3268
|
+
cookies: parseCookieHeader(headers.cookie),
|
|
3090
3269
|
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
|
|
3091
3270
|
// drops, so prototype-pollution rules on `raw` are robust.
|
|
3092
3271
|
_rawBody: rawBody,
|
|
@@ -3240,22 +3419,53 @@ function decodeExtendedValue(value) {
|
|
|
3240
3419
|
return match[2];
|
|
3241
3420
|
}
|
|
3242
3421
|
}
|
|
3243
|
-
function parseCookies(header) {
|
|
3244
|
-
const cookies = {};
|
|
3245
|
-
if (!header) {
|
|
3246
|
-
return cookies;
|
|
3247
|
-
}
|
|
3248
|
-
for (const pair of header.split(";")) {
|
|
3249
|
-
const idx = pair.indexOf("=");
|
|
3250
|
-
if (idx === -1) {
|
|
3251
|
-
continue;
|
|
3252
|
-
}
|
|
3253
|
-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
3254
|
-
}
|
|
3255
|
-
return cookies;
|
|
3256
|
-
}
|
|
3257
3422
|
|
|
3258
3423
|
// src/protect/engine/node.js
|
|
3424
|
+
function readBodyPrefix(req, maxBytes, done) {
|
|
3425
|
+
const chunks = [];
|
|
3426
|
+
let retained = 0;
|
|
3427
|
+
let size = 0;
|
|
3428
|
+
let failed = false;
|
|
3429
|
+
let finished = false;
|
|
3430
|
+
const finish = (error, read) => {
|
|
3431
|
+
if (finished) return;
|
|
3432
|
+
finished = true;
|
|
3433
|
+
done(error, read);
|
|
3434
|
+
};
|
|
3435
|
+
req.on("data", (chunk) => {
|
|
3436
|
+
if (failed) return;
|
|
3437
|
+
try {
|
|
3438
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, req.readableEncoding || "utf8") : chunk;
|
|
3439
|
+
size += bytes.length;
|
|
3440
|
+
const take = Math.min(bytes.length, maxBytes - retained);
|
|
3441
|
+
if (take <= 0) return;
|
|
3442
|
+
chunks.push(take === bytes.length ? bytes : bytes.subarray(0, take));
|
|
3443
|
+
retained += take;
|
|
3444
|
+
} catch {
|
|
3445
|
+
failed = true;
|
|
3446
|
+
chunks.length = 0;
|
|
3447
|
+
}
|
|
3448
|
+
});
|
|
3449
|
+
req.on("error", (error) => finish(error));
|
|
3450
|
+
req.on("end", () => {
|
|
3451
|
+
if (failed) {
|
|
3452
|
+
finish(null, { text: "", overflow: false, size, failed: true });
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
const prefix = Buffer.concat(chunks);
|
|
3456
|
+
const overflow = size > maxBytes;
|
|
3457
|
+
const text = (overflow ? prefix.subarray(0, completeUtf8Length(prefix)) : prefix).toString("utf8");
|
|
3458
|
+
finish(null, { text, overflow, size, failed: false });
|
|
3459
|
+
});
|
|
3460
|
+
}
|
|
3461
|
+
function completeUtf8Length(bytes) {
|
|
3462
|
+
let start = bytes.length - 1;
|
|
3463
|
+
while (start >= 0 && bytes.length - start <= 3 && (bytes[start] & 192) === 128) start--;
|
|
3464
|
+
if (start < 0) return bytes.length;
|
|
3465
|
+
const lead = bytes[start];
|
|
3466
|
+
const needed = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
|
|
3467
|
+
return bytes.length - start < needed ? start : bytes.length;
|
|
3468
|
+
}
|
|
3259
3469
|
function fromNodeRequest(req, rawBody = "", options = {}) {
|
|
3260
3470
|
const method = (req.method || "GET").toUpperCase();
|
|
3261
3471
|
const headers = {};
|
|
@@ -3301,29 +3511,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
|
|
|
3301
3511
|
headers,
|
|
3302
3512
|
ip: client.ip ?? "",
|
|
3303
3513
|
_clientIp: client,
|
|
3304
|
-
cookies:
|
|
3514
|
+
cookies: parseCookieHeader(headers.cookie),
|
|
3305
3515
|
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
|
|
3306
3516
|
_rawBody: rawBody
|
|
3307
3517
|
};
|
|
3308
3518
|
}
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3519
|
+
|
|
3520
|
+
// src/protect/egress.js
|
|
3521
|
+
var REGISTRY = /* @__PURE__ */ Symbol.for("patchstack.connect.egress-guard");
|
|
3522
|
+
function egressRegistry() {
|
|
3523
|
+
const existing = globalThis[REGISTRY];
|
|
3524
|
+
if (existing && existing.screens instanceof Set && existing.surfaces instanceof Map) return existing;
|
|
3525
|
+
const created = { screens: /* @__PURE__ */ new Set(), surfaces: /* @__PURE__ */ new Map() };
|
|
3526
|
+
Object.defineProperty(globalThis, REGISTRY, { value: created, configurable: true, writable: true });
|
|
3527
|
+
return created;
|
|
3528
|
+
}
|
|
3529
|
+
var refusal = (host) => new Error(`Patchstack blocked an outbound request to a disallowed address: ${host}`);
|
|
3530
|
+
function readableDestination(input, init) {
|
|
3531
|
+
try {
|
|
3532
|
+
const raw = typeof input === "string" || input instanceof URL ? String(input) : input?.url ?? input?.href;
|
|
3533
|
+
if (typeof raw !== "string" && !(raw instanceof URL)) return null;
|
|
3534
|
+
const url = new URL(String(raw)).href;
|
|
3535
|
+
const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
|
|
3536
|
+
return { url, method };
|
|
3537
|
+
} catch {
|
|
3538
|
+
return null;
|
|
3313
3539
|
}
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
3540
|
+
}
|
|
3541
|
+
function anyRefuses(screens, url, host, method) {
|
|
3542
|
+
let refused = false;
|
|
3543
|
+
for (const screen of screens) {
|
|
3544
|
+
if (screen.block(url, host, method)) refused = true;
|
|
3320
3545
|
}
|
|
3321
|
-
return
|
|
3546
|
+
return refused;
|
|
3322
3547
|
}
|
|
3323
|
-
|
|
3324
|
-
// src/protect/egress.js
|
|
3325
3548
|
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
3326
|
-
const restores = [];
|
|
3327
3549
|
if (typeof shouldBlock !== "function") return () => {
|
|
3328
3550
|
};
|
|
3329
3551
|
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
@@ -3368,6 +3590,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3368
3590
|
resolve(false);
|
|
3369
3591
|
}
|
|
3370
3592
|
});
|
|
3593
|
+
const registry = egressRegistry();
|
|
3594
|
+
const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
|
|
3595
|
+
registry.screens.add(own);
|
|
3371
3596
|
const originalFetch = globalThis.fetch;
|
|
3372
3597
|
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
3373
3598
|
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
@@ -3440,15 +3665,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3440
3665
|
} catch {
|
|
3441
3666
|
host = null;
|
|
3442
3667
|
}
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3668
|
+
const screens = [...registry.screens];
|
|
3669
|
+
if (anyRefuses(screens, u, host, method)) throw refusal(host ?? u);
|
|
3670
|
+
const resolved = await Promise.all(screens.map((each) => each.prescreen(u, host, method)));
|
|
3671
|
+
if (resolved.includes(true)) throw refusal(host ?? u);
|
|
3446
3672
|
};
|
|
3447
3673
|
const guarded = async (input, init) => {
|
|
3448
3674
|
let cur;
|
|
3449
3675
|
try {
|
|
3450
3676
|
cur = new Request(input, { ...init || {}, redirect: "manual" });
|
|
3451
3677
|
} catch {
|
|
3678
|
+
const destination = readableDestination(input, init);
|
|
3679
|
+
if (destination) await screenUrl(destination.url, destination.method);
|
|
3680
|
+
else for (const each of registry.screens) each.skip("unrecognised-request", {});
|
|
3452
3681
|
return originalFetch(input, init);
|
|
3453
3682
|
}
|
|
3454
3683
|
const callerRedirect = init && init.redirect || input && input.redirect || "follow";
|
|
@@ -3459,13 +3688,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3459
3688
|
const headers = new Headers(cur.headers);
|
|
3460
3689
|
const signal = cur.signal;
|
|
3461
3690
|
const replay = captureReplayBody(cur);
|
|
3691
|
+
const { body: _callerBody, ...callerOptions } = init || {};
|
|
3462
3692
|
let body;
|
|
3463
3693
|
for (let hop = 0; ; hop++) {
|
|
3464
3694
|
let resp;
|
|
3465
3695
|
try {
|
|
3466
|
-
resp = await originalFetch(
|
|
3467
|
-
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
3468
|
-
);
|
|
3696
|
+
resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
|
|
3469
3697
|
} catch (error) {
|
|
3470
3698
|
replay.cancel();
|
|
3471
3699
|
throw error;
|
|
@@ -3473,7 +3701,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3473
3701
|
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
3474
3702
|
if (!location) {
|
|
3475
3703
|
replay.cancel();
|
|
3476
|
-
return resp;
|
|
3704
|
+
return hop === 0 ? resp : markRedirected(resp);
|
|
3477
3705
|
}
|
|
3478
3706
|
await discardResponseBody(resp);
|
|
3479
3707
|
if (hop >= MAX_REDIRECTS) {
|
|
@@ -3506,8 +3734,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3506
3734
|
};
|
|
3507
3735
|
guarded.__patchstackGuarded = true;
|
|
3508
3736
|
globalThis.fetch = guarded;
|
|
3509
|
-
|
|
3510
|
-
|
|
3737
|
+
registry.surfaces.set(guarded, {
|
|
3738
|
+
release() {
|
|
3739
|
+
if (globalThis.fetch !== guarded) return false;
|
|
3740
|
+
globalThis.fetch = originalFetch;
|
|
3741
|
+
return true;
|
|
3742
|
+
}
|
|
3511
3743
|
});
|
|
3512
3744
|
}
|
|
3513
3745
|
let syncBuiltins = () => {
|
|
@@ -3521,12 +3753,16 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3521
3753
|
for (const moduleName of ["node:http", "node:https"]) {
|
|
3522
3754
|
try {
|
|
3523
3755
|
const mod = await import(moduleName);
|
|
3524
|
-
|
|
3525
|
-
|
|
3756
|
+
if (registry.surfaces.has(moduleName)) continue;
|
|
3757
|
+
const release = patchHttpModule(mod.default ?? mod, registry);
|
|
3758
|
+
if (release) {
|
|
3526
3759
|
patchedAny = true;
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3760
|
+
registry.surfaces.set(moduleName, {
|
|
3761
|
+
release() {
|
|
3762
|
+
if (!release()) return false;
|
|
3763
|
+
syncBuiltins();
|
|
3764
|
+
return true;
|
|
3765
|
+
}
|
|
3530
3766
|
});
|
|
3531
3767
|
}
|
|
3532
3768
|
} catch {
|
|
@@ -3534,27 +3770,41 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3534
3770
|
}
|
|
3535
3771
|
if (patchedAny) syncBuiltins();
|
|
3536
3772
|
return () => {
|
|
3537
|
-
|
|
3773
|
+
registry.screens.delete(own);
|
|
3774
|
+
if (registry.screens.size > 0) return;
|
|
3775
|
+
for (const [name, surface] of registry.surfaces) {
|
|
3538
3776
|
try {
|
|
3539
|
-
|
|
3777
|
+
if (surface.release()) registry.surfaces.delete(name);
|
|
3540
3778
|
} catch {
|
|
3541
3779
|
}
|
|
3542
3780
|
}
|
|
3543
3781
|
};
|
|
3544
3782
|
}
|
|
3545
|
-
function
|
|
3783
|
+
function markRedirected(response) {
|
|
3784
|
+
try {
|
|
3785
|
+
Object.defineProperty(response, "redirected", { value: true, configurable: true });
|
|
3786
|
+
} catch {
|
|
3787
|
+
}
|
|
3788
|
+
return response;
|
|
3789
|
+
}
|
|
3790
|
+
function patchHttpModule(http, registry) {
|
|
3546
3791
|
if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
|
|
3547
3792
|
const originalRequest = http.request;
|
|
3548
3793
|
const originalGet = http.get;
|
|
3549
3794
|
const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
|
|
3550
3795
|
const guardArgs = (args) => {
|
|
3551
3796
|
const target = extractHttpTarget(args);
|
|
3552
|
-
if (target
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3797
|
+
if (!target) return args;
|
|
3798
|
+
const screens = [...registry.screens];
|
|
3799
|
+
if (anyRefuses(screens, target.url, target.host, target.method)) throw refusal(target.host ?? target.url);
|
|
3800
|
+
const resolving = target.host ? screens.filter((each) => each.dns && each.dns.isIP(target.host) === 0 && !each.dns.isExempt(target.host)) : [];
|
|
3801
|
+
if (resolving.length > 0) {
|
|
3802
|
+
const block = (url, host, method) => anyRefuses(resolving, url, host, method);
|
|
3803
|
+
const skip = (reason, detail) => {
|
|
3804
|
+
for (const each of resolving) each.skip(reason, detail);
|
|
3805
|
+
};
|
|
3556
3806
|
try {
|
|
3557
|
-
return withScreeningLookup(args, target, block,
|
|
3807
|
+
return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
|
|
3558
3808
|
} catch {
|
|
3559
3809
|
}
|
|
3560
3810
|
}
|
|
@@ -3582,10 +3832,14 @@ function patchHttpModule(http, block, screen, skip) {
|
|
|
3582
3832
|
}
|
|
3583
3833
|
http.__patchstackGuarded = true;
|
|
3584
3834
|
return () => {
|
|
3585
|
-
if (http.request
|
|
3586
|
-
if (guardedGet && http.get
|
|
3587
|
-
if (GuardedClientRequest && http.ClientRequest
|
|
3835
|
+
if (http.request !== guardedRequest) return false;
|
|
3836
|
+
if (guardedGet && http.get !== guardedGet) return false;
|
|
3837
|
+
if (GuardedClientRequest && http.ClientRequest !== GuardedClientRequest) return false;
|
|
3838
|
+
http.request = originalRequest;
|
|
3839
|
+
if (guardedGet) http.get = originalGet;
|
|
3840
|
+
if (GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
|
|
3588
3841
|
delete http.__patchstackGuarded;
|
|
3842
|
+
return true;
|
|
3589
3843
|
};
|
|
3590
3844
|
}
|
|
3591
3845
|
function extractHttpTarget(args) {
|
|
@@ -3854,7 +4108,10 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
3854
4108
|
// the constraint name, the column and the offending value discloses the schema anyway.
|
|
3855
4109
|
action: "block",
|
|
3856
4110
|
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
3857
|
-
|
|
4111
|
+
// Case-sensitive, because every signature is emitted in one fixed case. `ER_` is the one that needs
|
|
4112
|
+
// it: MySQL error codes are whole upper-case words (`ER_DUP_ENTRY`), and matched without case or a
|
|
4113
|
+
// word boundary it finds the end of ordinary identifiers such as a `cover__background` class name.
|
|
4114
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|\\bER_[A-Z][A-Z0-9_]*|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/" } }]
|
|
3858
4115
|
},
|
|
3859
4116
|
{
|
|
3860
4117
|
id: "resp-exception-trace",
|
|
@@ -4484,7 +4741,7 @@ function makeRefreshHandler(tick, secret) {
|
|
|
4484
4741
|
return async (request) => {
|
|
4485
4742
|
if (!secret) return new Response("not found", { status: 404 });
|
|
4486
4743
|
const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
|
|
4487
|
-
if (provided
|
|
4744
|
+
if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
|
|
4488
4745
|
let refreshed = true;
|
|
4489
4746
|
try {
|
|
4490
4747
|
const status = await runOnce();
|
|
@@ -4495,6 +4752,33 @@ function makeRefreshHandler(tick, secret) {
|
|
|
4495
4752
|
return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
|
|
4496
4753
|
};
|
|
4497
4754
|
}
|
|
4755
|
+
async function sameSecret(provided, secret) {
|
|
4756
|
+
if (typeof provided !== "string" || typeof secret !== "string") return false;
|
|
4757
|
+
const subtle = globalThis.crypto?.subtle;
|
|
4758
|
+
if (subtle && typeof TextEncoder === "function") {
|
|
4759
|
+
try {
|
|
4760
|
+
const encoder2 = new TextEncoder();
|
|
4761
|
+
const [a, b] = await Promise.all([
|
|
4762
|
+
subtle.digest("SHA-256", encoder2.encode(provided)),
|
|
4763
|
+
subtle.digest("SHA-256", encoder2.encode(secret))
|
|
4764
|
+
]);
|
|
4765
|
+
return equalBytes(new Uint8Array(a), new Uint8Array(b));
|
|
4766
|
+
} catch {
|
|
4767
|
+
}
|
|
4768
|
+
}
|
|
4769
|
+
let diff = provided.length ^ secret.length;
|
|
4770
|
+
const length = Math.max(provided.length, secret.length);
|
|
4771
|
+
for (let i = 0; i < length; i++) {
|
|
4772
|
+
diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
|
|
4773
|
+
}
|
|
4774
|
+
return diff === 0;
|
|
4775
|
+
}
|
|
4776
|
+
function equalBytes(a, b) {
|
|
4777
|
+
if (a.length !== b.length) return false;
|
|
4778
|
+
let diff = 0;
|
|
4779
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
4780
|
+
return diff === 0;
|
|
4781
|
+
}
|
|
4498
4782
|
|
|
4499
4783
|
// src/protect/detections.js
|
|
4500
4784
|
var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
|
|
@@ -5228,9 +5512,180 @@ function hardensWithoutBody(rule) {
|
|
|
5228
5512
|
if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
|
|
5229
5513
|
const { parameters, complete } = readRuleParameters(rule);
|
|
5230
5514
|
if (!complete) return false;
|
|
5231
|
-
return parameters.every(
|
|
5232
|
-
|
|
5515
|
+
return parameters.every(bodyIndependent);
|
|
5516
|
+
}
|
|
5517
|
+
function readsOnlyResponseHeaders(rule) {
|
|
5518
|
+
if (!rule) return false;
|
|
5519
|
+
const { parameters, complete } = readRuleParameters(rule);
|
|
5520
|
+
if (!complete || !parameters.some(isHeaderParameter)) return false;
|
|
5521
|
+
return parameters.every(bodyIndependent);
|
|
5522
|
+
}
|
|
5523
|
+
function isHeaderParameter(parameter) {
|
|
5524
|
+
return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
|
|
5525
|
+
}
|
|
5526
|
+
function bodyIndependent(parameter) {
|
|
5527
|
+
return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
|
|
5528
|
+
}
|
|
5529
|
+
|
|
5530
|
+
// src/protect/supabase-guard.js
|
|
5531
|
+
var GUARD_PATH = "/_patchstack/guard";
|
|
5532
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5533
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
5534
|
+
var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5535
|
+
"connection",
|
|
5536
|
+
"cookie",
|
|
5537
|
+
"cookie2",
|
|
5538
|
+
"forwarded",
|
|
5539
|
+
"host",
|
|
5540
|
+
"keep-alive",
|
|
5541
|
+
"proxy-authenticate",
|
|
5542
|
+
"proxy-authorization",
|
|
5543
|
+
"te",
|
|
5544
|
+
"trailer",
|
|
5545
|
+
"transfer-encoding",
|
|
5546
|
+
"upgrade",
|
|
5547
|
+
"x-forwarded-for",
|
|
5548
|
+
"x-forwarded-host",
|
|
5549
|
+
"x-forwarded-port",
|
|
5550
|
+
"x-forwarded-proto",
|
|
5551
|
+
"x-ps-target",
|
|
5552
|
+
"x-real-ip"
|
|
5553
|
+
]);
|
|
5554
|
+
var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5555
|
+
"connection",
|
|
5556
|
+
"content-encoding",
|
|
5557
|
+
"content-length",
|
|
5558
|
+
"keep-alive",
|
|
5559
|
+
"proxy-authenticate",
|
|
5560
|
+
"proxy-authorization",
|
|
5561
|
+
"set-cookie",
|
|
5562
|
+
"set-cookie2",
|
|
5563
|
+
"te",
|
|
5564
|
+
"trailer",
|
|
5565
|
+
"transfer-encoding",
|
|
5566
|
+
"upgrade"
|
|
5567
|
+
]);
|
|
5568
|
+
var BodyTooLargeError = class extends Error {
|
|
5569
|
+
};
|
|
5570
|
+
async function boundedBody(request, maxBytes) {
|
|
5571
|
+
const stated = Number(request.headers.get("content-length"));
|
|
5572
|
+
if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
|
|
5573
|
+
if (!request.body) return void 0;
|
|
5574
|
+
const reader = request.body.getReader();
|
|
5575
|
+
const chunks = [];
|
|
5576
|
+
let total = 0;
|
|
5577
|
+
try {
|
|
5578
|
+
for (; ; ) {
|
|
5579
|
+
const { done, value } = await reader.read();
|
|
5580
|
+
if (done) break;
|
|
5581
|
+
total += value.byteLength;
|
|
5582
|
+
if (total > maxBytes) {
|
|
5583
|
+
void reader.cancel().catch(() => {
|
|
5584
|
+
});
|
|
5585
|
+
throw new BodyTooLargeError();
|
|
5586
|
+
}
|
|
5587
|
+
chunks.push(value);
|
|
5588
|
+
}
|
|
5589
|
+
} finally {
|
|
5590
|
+
reader.releaseLock();
|
|
5591
|
+
}
|
|
5592
|
+
const body = new Uint8Array(total);
|
|
5593
|
+
let offset = 0;
|
|
5594
|
+
for (const chunk of chunks) {
|
|
5595
|
+
body.set(chunk, offset);
|
|
5596
|
+
offset += chunk.byteLength;
|
|
5597
|
+
}
|
|
5598
|
+
return body;
|
|
5599
|
+
}
|
|
5600
|
+
function requestHeaders(input) {
|
|
5601
|
+
const output = new Headers();
|
|
5602
|
+
const connectionTokens = new Set(
|
|
5603
|
+
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5233
5604
|
);
|
|
5605
|
+
input.forEach((value, key) => {
|
|
5606
|
+
const lower = key.toLowerCase();
|
|
5607
|
+
if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5608
|
+
});
|
|
5609
|
+
return output;
|
|
5610
|
+
}
|
|
5611
|
+
function responseHeaders(input) {
|
|
5612
|
+
const output = new Headers();
|
|
5613
|
+
const connectionTokens = new Set(
|
|
5614
|
+
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5615
|
+
);
|
|
5616
|
+
input.forEach((value, key) => {
|
|
5617
|
+
const lower = key.toLowerCase();
|
|
5618
|
+
if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5619
|
+
});
|
|
5620
|
+
return output;
|
|
5621
|
+
}
|
|
5622
|
+
var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
|
|
5623
|
+
function createSupabaseGuard({
|
|
5624
|
+
protection,
|
|
5625
|
+
supabaseUrl,
|
|
5626
|
+
fetchImpl = fetch,
|
|
5627
|
+
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
5628
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
5629
|
+
}) {
|
|
5630
|
+
const guard = protection.fetchGuard();
|
|
5631
|
+
const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
|
|
5632
|
+
const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
|
|
5633
|
+
const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
5634
|
+
return async function handleGuardRequest(request, ...hostArgs) {
|
|
5635
|
+
const target = request.headers.get("x-ps-target");
|
|
5636
|
+
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
5637
|
+
let targetUrl;
|
|
5638
|
+
try {
|
|
5639
|
+
targetUrl = new URL(target);
|
|
5640
|
+
} catch {
|
|
5641
|
+
return new Response("patchstack: invalid target", { status: 400 });
|
|
5642
|
+
}
|
|
5643
|
+
if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
|
|
5644
|
+
return new Response("patchstack: target not allowed", { status: 403 });
|
|
5645
|
+
}
|
|
5646
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
5647
|
+
let body;
|
|
5648
|
+
try {
|
|
5649
|
+
body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
|
|
5650
|
+
} catch (error) {
|
|
5651
|
+
if (error instanceof BodyTooLargeError) {
|
|
5652
|
+
return new Response("patchstack: request body too large", { status: 413 });
|
|
5653
|
+
}
|
|
5654
|
+
throw error;
|
|
5655
|
+
}
|
|
5656
|
+
const forwardHeaders = requestHeaders(request.headers);
|
|
5657
|
+
const evalReq = new Request(targetUrl.toString(), {
|
|
5658
|
+
method: request.method,
|
|
5659
|
+
// Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
|
|
5660
|
+
// sanitized at the cross-origin boundary below.
|
|
5661
|
+
headers: request.headers,
|
|
5662
|
+
body
|
|
5663
|
+
});
|
|
5664
|
+
Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
|
|
5665
|
+
const blocked = await guard(evalReq, ...hostArgs);
|
|
5666
|
+
if (blocked) return blocked;
|
|
5667
|
+
let upstream;
|
|
5668
|
+
try {
|
|
5669
|
+
upstream = await fetchImpl(targetUrl.toString(), {
|
|
5670
|
+
method: request.method,
|
|
5671
|
+
headers: forwardHeaders,
|
|
5672
|
+
body,
|
|
5673
|
+
redirect: "manual",
|
|
5674
|
+
signal: AbortSignal.timeout(upstreamTimeout)
|
|
5675
|
+
});
|
|
5676
|
+
} catch (error) {
|
|
5677
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
5678
|
+
return new Response("patchstack: upstream timed out", { status: 504 });
|
|
5679
|
+
}
|
|
5680
|
+
throw error;
|
|
5681
|
+
}
|
|
5682
|
+
const forwarded = new Response(upstream.body, {
|
|
5683
|
+
status: upstream.status,
|
|
5684
|
+
statusText: upstream.statusText,
|
|
5685
|
+
headers: responseHeaders(upstream.headers)
|
|
5686
|
+
});
|
|
5687
|
+
return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
|
|
5688
|
+
};
|
|
5234
5689
|
}
|
|
5235
5690
|
|
|
5236
5691
|
// src/protect/firewall-log.js
|
|
@@ -5279,8 +5734,13 @@ function resolveApiBase(pulseOrManifestUrl) {
|
|
|
5279
5734
|
function createFirewallLogReporter(opts) {
|
|
5280
5735
|
const creds = parseApiKey(opts.apiKey);
|
|
5281
5736
|
if (!creds) {
|
|
5282
|
-
return {
|
|
5283
|
-
|
|
5737
|
+
return {
|
|
5738
|
+
record() {
|
|
5739
|
+
},
|
|
5740
|
+
flush: () => Promise.resolve(),
|
|
5741
|
+
stop: () => Promise.resolve(),
|
|
5742
|
+
health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
|
|
5743
|
+
};
|
|
5284
5744
|
}
|
|
5285
5745
|
const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
|
|
5286
5746
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
@@ -5293,6 +5753,19 @@ function createFirewallLogReporter(opts) {
|
|
|
5293
5753
|
let drainPromise = null;
|
|
5294
5754
|
let activeController = null;
|
|
5295
5755
|
let ended = false;
|
|
5756
|
+
let recorded = 0;
|
|
5757
|
+
let delivered = 0;
|
|
5758
|
+
let failed = 0;
|
|
5759
|
+
let dropped = 0;
|
|
5760
|
+
let activeBatch = null;
|
|
5761
|
+
const settle = (sent, outcome) => {
|
|
5762
|
+
if (sent.counted) return;
|
|
5763
|
+
sent.counted = true;
|
|
5764
|
+
if (outcome === "delivered") delivered += sent.size;
|
|
5765
|
+
else if (outcome === "failed") failed += sent.size;
|
|
5766
|
+
else dropped += sent.size;
|
|
5767
|
+
if (activeBatch === sent) activeBatch = null;
|
|
5768
|
+
};
|
|
5296
5769
|
let cachedToken = null;
|
|
5297
5770
|
let tokenInflight = null;
|
|
5298
5771
|
const fetchAccessToken = async (signal) => {
|
|
@@ -5346,10 +5819,16 @@ function createFirewallLogReporter(opts) {
|
|
|
5346
5819
|
const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
|
|
5347
5820
|
attemptTimer?.unref?.();
|
|
5348
5821
|
let entry;
|
|
5822
|
+
const sent = { size: batch.length, counted: false };
|
|
5823
|
+
activeBatch = sent;
|
|
5349
5824
|
entry = (async () => {
|
|
5350
5825
|
try {
|
|
5351
5826
|
const token = await fetchAccessToken(controller?.signal);
|
|
5352
|
-
if (
|
|
5827
|
+
if (ended) return;
|
|
5828
|
+
if (!token) {
|
|
5829
|
+
settle(sent, "failed");
|
|
5830
|
+
return;
|
|
5831
|
+
}
|
|
5353
5832
|
const body = new URLSearchParams();
|
|
5354
5833
|
body.set("type", "firewall");
|
|
5355
5834
|
body.set("logs", JSON.stringify(batch));
|
|
@@ -5366,10 +5845,11 @@ function createFirewallLogReporter(opts) {
|
|
|
5366
5845
|
// Both phases carry the attempt controller, so slow transports cannot accumulate work.
|
|
5367
5846
|
...controller ? { signal: controller.signal } : {}
|
|
5368
5847
|
});
|
|
5369
|
-
|
|
5370
|
-
|
|
5848
|
+
const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
|
|
5849
|
+
settle(sent, res && res.ok ? "delivered" : "failed");
|
|
5371
5850
|
} catch {
|
|
5372
5851
|
} finally {
|
|
5852
|
+
settle(sent, "failed");
|
|
5373
5853
|
clearTimeout(attemptTimer);
|
|
5374
5854
|
if (activeController === controller) activeController = null;
|
|
5375
5855
|
if (inFlight === entry) inFlight = null;
|
|
@@ -5396,7 +5876,11 @@ function createFirewallLogReporter(opts) {
|
|
|
5396
5876
|
if (stopped) return;
|
|
5397
5877
|
const fid = event?.rule?.id;
|
|
5398
5878
|
if (fid === void 0 || fid === null || fid === "") return;
|
|
5399
|
-
if (queue.length >= MAX_QUEUE2)
|
|
5879
|
+
if (queue.length >= MAX_QUEUE2) {
|
|
5880
|
+
dropped++;
|
|
5881
|
+
return;
|
|
5882
|
+
}
|
|
5883
|
+
recorded++;
|
|
5400
5884
|
queue.push({
|
|
5401
5885
|
fid,
|
|
5402
5886
|
method: event.method ?? null,
|
|
@@ -5412,6 +5896,10 @@ function createFirewallLogReporter(opts) {
|
|
|
5412
5896
|
if (!timer) timer = setTimeout(flush, flushMs);
|
|
5413
5897
|
},
|
|
5414
5898
|
flush,
|
|
5899
|
+
/** Where the records went so far; see the counters above. `queued` is what is waiting now. */
|
|
5900
|
+
health() {
|
|
5901
|
+
return { recorded, delivered, failed, dropped, queued: queue.length };
|
|
5902
|
+
},
|
|
5415
5903
|
/**
|
|
5416
5904
|
* Stop, and hand back a wait for what was outstanding.
|
|
5417
5905
|
*
|
|
@@ -5430,6 +5918,8 @@ function createFirewallLogReporter(opts) {
|
|
|
5430
5918
|
ended = true;
|
|
5431
5919
|
activeController?.abort();
|
|
5432
5920
|
activeController = null;
|
|
5921
|
+
if (activeBatch) settle(activeBatch, "dropped");
|
|
5922
|
+
dropped += queue.length;
|
|
5433
5923
|
queue = [];
|
|
5434
5924
|
inFlight = null;
|
|
5435
5925
|
};
|
|
@@ -5466,165 +5956,6 @@ function telemetryEnabled() {
|
|
|
5466
5956
|
return !/^(0|false|off|no)$/i.test(String(v));
|
|
5467
5957
|
}
|
|
5468
5958
|
|
|
5469
|
-
// src/protect/supabase-guard.js
|
|
5470
|
-
var GUARD_PATH = "/_patchstack/guard";
|
|
5471
|
-
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5472
|
-
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
5473
|
-
var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5474
|
-
"connection",
|
|
5475
|
-
"cookie",
|
|
5476
|
-
"cookie2",
|
|
5477
|
-
"forwarded",
|
|
5478
|
-
"host",
|
|
5479
|
-
"keep-alive",
|
|
5480
|
-
"proxy-authenticate",
|
|
5481
|
-
"proxy-authorization",
|
|
5482
|
-
"te",
|
|
5483
|
-
"trailer",
|
|
5484
|
-
"transfer-encoding",
|
|
5485
|
-
"upgrade",
|
|
5486
|
-
"x-forwarded-for",
|
|
5487
|
-
"x-forwarded-host",
|
|
5488
|
-
"x-forwarded-port",
|
|
5489
|
-
"x-forwarded-proto",
|
|
5490
|
-
"x-ps-target",
|
|
5491
|
-
"x-real-ip"
|
|
5492
|
-
]);
|
|
5493
|
-
var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5494
|
-
"connection",
|
|
5495
|
-
"content-encoding",
|
|
5496
|
-
"content-length",
|
|
5497
|
-
"keep-alive",
|
|
5498
|
-
"proxy-authenticate",
|
|
5499
|
-
"proxy-authorization",
|
|
5500
|
-
"set-cookie",
|
|
5501
|
-
"set-cookie2",
|
|
5502
|
-
"te",
|
|
5503
|
-
"trailer",
|
|
5504
|
-
"transfer-encoding",
|
|
5505
|
-
"upgrade"
|
|
5506
|
-
]);
|
|
5507
|
-
var BodyTooLargeError = class extends Error {
|
|
5508
|
-
};
|
|
5509
|
-
async function boundedBody(request, maxBytes) {
|
|
5510
|
-
const stated = Number(request.headers.get("content-length"));
|
|
5511
|
-
if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
|
|
5512
|
-
if (!request.body) return void 0;
|
|
5513
|
-
const reader = request.body.getReader();
|
|
5514
|
-
const chunks = [];
|
|
5515
|
-
let total = 0;
|
|
5516
|
-
try {
|
|
5517
|
-
for (; ; ) {
|
|
5518
|
-
const { done, value } = await reader.read();
|
|
5519
|
-
if (done) break;
|
|
5520
|
-
total += value.byteLength;
|
|
5521
|
-
if (total > maxBytes) {
|
|
5522
|
-
void reader.cancel().catch(() => {
|
|
5523
|
-
});
|
|
5524
|
-
throw new BodyTooLargeError();
|
|
5525
|
-
}
|
|
5526
|
-
chunks.push(value);
|
|
5527
|
-
}
|
|
5528
|
-
} finally {
|
|
5529
|
-
reader.releaseLock();
|
|
5530
|
-
}
|
|
5531
|
-
const body = new Uint8Array(total);
|
|
5532
|
-
let offset = 0;
|
|
5533
|
-
for (const chunk of chunks) {
|
|
5534
|
-
body.set(chunk, offset);
|
|
5535
|
-
offset += chunk.byteLength;
|
|
5536
|
-
}
|
|
5537
|
-
return body;
|
|
5538
|
-
}
|
|
5539
|
-
function requestHeaders(input) {
|
|
5540
|
-
const output = new Headers();
|
|
5541
|
-
const connectionTokens = new Set(
|
|
5542
|
-
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5543
|
-
);
|
|
5544
|
-
input.forEach((value, key) => {
|
|
5545
|
-
const lower = key.toLowerCase();
|
|
5546
|
-
if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5547
|
-
});
|
|
5548
|
-
return output;
|
|
5549
|
-
}
|
|
5550
|
-
function responseHeaders(input) {
|
|
5551
|
-
const output = new Headers();
|
|
5552
|
-
const connectionTokens = new Set(
|
|
5553
|
-
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5554
|
-
);
|
|
5555
|
-
input.forEach((value, key) => {
|
|
5556
|
-
const lower = key.toLowerCase();
|
|
5557
|
-
if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5558
|
-
});
|
|
5559
|
-
return output;
|
|
5560
|
-
}
|
|
5561
|
-
function createSupabaseGuard({
|
|
5562
|
-
protection,
|
|
5563
|
-
supabaseUrl,
|
|
5564
|
-
fetchImpl = fetch,
|
|
5565
|
-
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
5566
|
-
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
5567
|
-
}) {
|
|
5568
|
-
const guard = protection.fetchGuard();
|
|
5569
|
-
const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
|
|
5570
|
-
const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
|
|
5571
|
-
const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
5572
|
-
return async function handleGuardRequest(request) {
|
|
5573
|
-
const target = request.headers.get("x-ps-target");
|
|
5574
|
-
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
5575
|
-
let targetUrl;
|
|
5576
|
-
try {
|
|
5577
|
-
targetUrl = new URL(target);
|
|
5578
|
-
} catch {
|
|
5579
|
-
return new Response("patchstack: invalid target", { status: 400 });
|
|
5580
|
-
}
|
|
5581
|
-
if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
|
|
5582
|
-
return new Response("patchstack: target not allowed", { status: 403 });
|
|
5583
|
-
}
|
|
5584
|
-
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
5585
|
-
let body;
|
|
5586
|
-
try {
|
|
5587
|
-
body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
|
|
5588
|
-
} catch (error) {
|
|
5589
|
-
if (error instanceof BodyTooLargeError) {
|
|
5590
|
-
return new Response("patchstack: request body too large", { status: 413 });
|
|
5591
|
-
}
|
|
5592
|
-
throw error;
|
|
5593
|
-
}
|
|
5594
|
-
const forwardHeaders = requestHeaders(request.headers);
|
|
5595
|
-
const evalReq = new Request(targetUrl.toString(), {
|
|
5596
|
-
method: request.method,
|
|
5597
|
-
// Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
|
|
5598
|
-
// sanitized at the cross-origin boundary below.
|
|
5599
|
-
headers: request.headers,
|
|
5600
|
-
body
|
|
5601
|
-
});
|
|
5602
|
-
const blocked = await guard(evalReq);
|
|
5603
|
-
if (blocked) return blocked;
|
|
5604
|
-
let upstream;
|
|
5605
|
-
try {
|
|
5606
|
-
upstream = await fetchImpl(targetUrl.toString(), {
|
|
5607
|
-
method: request.method,
|
|
5608
|
-
headers: forwardHeaders,
|
|
5609
|
-
body,
|
|
5610
|
-
redirect: "manual",
|
|
5611
|
-
signal: AbortSignal.timeout(upstreamTimeout)
|
|
5612
|
-
});
|
|
5613
|
-
} catch (error) {
|
|
5614
|
-
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
5615
|
-
return new Response("patchstack: upstream timed out", { status: 504 });
|
|
5616
|
-
}
|
|
5617
|
-
throw error;
|
|
5618
|
-
}
|
|
5619
|
-
const forwarded = new Response(upstream.body, {
|
|
5620
|
-
status: upstream.status,
|
|
5621
|
-
statusText: upstream.statusText,
|
|
5622
|
-
headers: responseHeaders(upstream.headers)
|
|
5623
|
-
});
|
|
5624
|
-
return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
|
|
5625
|
-
};
|
|
5626
|
-
}
|
|
5627
|
-
|
|
5628
5959
|
// src/protect/verify-sentinel.js
|
|
5629
5960
|
var VERIFY_HEADER = "x-patchstack-verify";
|
|
5630
5961
|
var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
|
|
@@ -5811,20 +6142,47 @@ async function createProtection(options = {}) {
|
|
|
5811
6142
|
whitelist_keys: delivered.whitelist_keys,
|
|
5812
6143
|
onError
|
|
5813
6144
|
});
|
|
5814
|
-
responseRuleSet = responseRules.map((rule) =>
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
6145
|
+
responseRuleSet = responseRules.map((rule) => {
|
|
6146
|
+
const unscoped = [];
|
|
6147
|
+
const rewrites = rule.action === "redact" || rule.action === "encode";
|
|
6148
|
+
let derived;
|
|
6149
|
+
try {
|
|
6150
|
+
const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
|
|
6151
|
+
const mutatedSpan = rewrites && hasSpanMutations(rule);
|
|
6152
|
+
derived = {
|
|
6153
|
+
redactors,
|
|
6154
|
+
mutatedSpan,
|
|
6155
|
+
// A redaction that reads response headers only, and masks spans in them, and an explicit block
|
|
6156
|
+
// that reads response headers only, are decided and carried out without the body — so they
|
|
6157
|
+
// still apply to a response whose body was not screened.
|
|
6158
|
+
redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
|
|
6159
|
+
blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
|
|
6160
|
+
};
|
|
6161
|
+
} catch (err) {
|
|
6162
|
+
notify(onError, err, "onError");
|
|
6163
|
+
derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
|
|
6164
|
+
}
|
|
6165
|
+
if (unscoped.length) {
|
|
6166
|
+
notify(
|
|
6167
|
+
onError,
|
|
6168
|
+
new Error(
|
|
6169
|
+
`Patchstack: response rule ${JSON.stringify(rule.id ?? null)} has a condition whose parameter names no place in the response to mask (${unscoped.map((p) => JSON.stringify(p ?? null)).join(", ")}); its matches are not masked`
|
|
6170
|
+
),
|
|
6171
|
+
"onError"
|
|
6172
|
+
);
|
|
6173
|
+
}
|
|
6174
|
+
return {
|
|
6175
|
+
rule,
|
|
6176
|
+
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
6177
|
+
...derived,
|
|
6178
|
+
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
|
|
6179
|
+
// have any chance of matching. Lets screenText skip the full scan on responses with no candidate
|
|
6180
|
+
// — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
|
|
6181
|
+
// Case-insensitive, and checked against the header values as well as the body, since a rule may
|
|
6182
|
+
// read either.
|
|
6183
|
+
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
6184
|
+
};
|
|
6185
|
+
});
|
|
5828
6186
|
egressRuleSet = egressRules.map((rule) => ({
|
|
5829
6187
|
rule,
|
|
5830
6188
|
engine: new RuleEngine({ firewall: [rule], onError })
|
|
@@ -5838,6 +6196,9 @@ async function createProtection(options = {}) {
|
|
|
5838
6196
|
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
5839
6197
|
notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
|
|
5840
6198
|
};
|
|
6199
|
+
const recordEvaluationSkips = (result) => {
|
|
6200
|
+
for (const reason of result?.skips ?? []) recordSkip("request", reason);
|
|
6201
|
+
};
|
|
5841
6202
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
5842
6203
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
5843
6204
|
const planCache = createPlanCache();
|
|
@@ -5880,15 +6241,19 @@ async function createProtection(options = {}) {
|
|
|
5880
6241
|
});
|
|
5881
6242
|
return effectiveMode === "block" ? block() : allow2();
|
|
5882
6243
|
};
|
|
6244
|
+
const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
|
|
6245
|
+
const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
|
|
5883
6246
|
const screenText = (text, meta, reqCtx, only) => {
|
|
5884
6247
|
let blockRule = null;
|
|
5885
6248
|
const redactions = [];
|
|
5886
6249
|
const headerMutations = [];
|
|
5887
6250
|
let lowerText = null;
|
|
5888
|
-
|
|
5889
|
-
|
|
6251
|
+
const responseSkips = /* @__PURE__ */ new Set();
|
|
6252
|
+
for (const entry of responseRuleSet) {
|
|
6253
|
+
const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
|
|
6254
|
+
if (only && !only(rule, entry)) continue;
|
|
5890
6255
|
if (prefilter) {
|
|
5891
|
-
if (lowerText === null) lowerText = text.
|
|
6256
|
+
if (lowerText === null) lowerText = prefilterText(text, meta.headers);
|
|
5892
6257
|
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
5893
6258
|
}
|
|
5894
6259
|
let result;
|
|
@@ -5898,6 +6263,11 @@ async function createProtection(options = {}) {
|
|
|
5898
6263
|
notify(onError, err, "onError");
|
|
5899
6264
|
continue;
|
|
5900
6265
|
}
|
|
6266
|
+
for (const reason of result.skips ?? []) {
|
|
6267
|
+
if (responseSkips.has(reason)) continue;
|
|
6268
|
+
responseSkips.add(reason);
|
|
6269
|
+
recordSkip("response", reason);
|
|
6270
|
+
}
|
|
5901
6271
|
if (!result.blocked) continue;
|
|
5902
6272
|
const responseMode = ruleMode(rule);
|
|
5903
6273
|
onDetect({
|
|
@@ -5927,6 +6297,8 @@ async function createProtection(options = {}) {
|
|
|
5927
6297
|
if (blockRule) return { verdict: "block" };
|
|
5928
6298
|
let body = text;
|
|
5929
6299
|
const headers = { ...meta.headers || {} };
|
|
6300
|
+
let responseIsJson;
|
|
6301
|
+
let structure;
|
|
5930
6302
|
for (const { rule, redactors } of redactions) {
|
|
5931
6303
|
const mask = maskFn(rule.category);
|
|
5932
6304
|
const transform = rule.action === "encode" ? htmlEscape : null;
|
|
@@ -5935,23 +6307,32 @@ async function createProtection(options = {}) {
|
|
|
5935
6307
|
if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
|
|
5936
6308
|
if (!spanRedactors.length) continue;
|
|
5937
6309
|
const beforeSpan = body;
|
|
5938
|
-
|
|
5939
|
-
if (body
|
|
6310
|
+
const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
|
|
6311
|
+
if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
|
|
6312
|
+
if (body !== beforeSpan) {
|
|
6313
|
+
responseIsJson ??= isJson(text);
|
|
6314
|
+
if (responseIsJson) {
|
|
6315
|
+
if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
|
|
6316
|
+
const tokens = lexJson(body);
|
|
6317
|
+
if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
|
|
6318
|
+
structure = { text: body, tokens };
|
|
6319
|
+
}
|
|
6320
|
+
}
|
|
5940
6321
|
if (transform) continue;
|
|
5941
6322
|
for (const name of Object.keys(headers)) {
|
|
5942
6323
|
const value = headers[name];
|
|
6324
|
+
const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
|
|
5943
6325
|
if (typeof value === "string") {
|
|
5944
|
-
setOwn(headers, name, applyRedactors(value,
|
|
6326
|
+
setOwn(headers, name, applyRedactors(value, headerSpans, mask));
|
|
5945
6327
|
} else if (Array.isArray(value)) {
|
|
5946
6328
|
setOwn(
|
|
5947
6329
|
headers,
|
|
5948
6330
|
name,
|
|
5949
|
-
value.map((item) => typeof item === "string" ? applyRedactors(item,
|
|
6331
|
+
value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
|
|
5950
6332
|
);
|
|
5951
6333
|
}
|
|
5952
6334
|
}
|
|
5953
6335
|
}
|
|
5954
|
-
if (body !== text && isJson(text) && !isJson(body)) return { verdict: "block" };
|
|
5955
6336
|
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
5956
6337
|
return { verdict: "redact", body, headers };
|
|
5957
6338
|
};
|
|
@@ -5994,18 +6375,53 @@ async function createProtection(options = {}) {
|
|
|
5994
6375
|
}
|
|
5995
6376
|
};
|
|
5996
6377
|
};
|
|
5997
|
-
const
|
|
6378
|
+
const peerOf = (request, hostArgs) => {
|
|
6379
|
+
if (typeof options.peerAddress !== "function") return void 0;
|
|
6380
|
+
try {
|
|
6381
|
+
return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
|
|
6382
|
+
} catch (err) {
|
|
6383
|
+
notify(onError, err, "onError");
|
|
6384
|
+
return void 0;
|
|
6385
|
+
}
|
|
6386
|
+
};
|
|
6387
|
+
let warnedNoPeer = false;
|
|
6388
|
+
const warnNoPeer = () => {
|
|
6389
|
+
if (warnedNoPeer) return;
|
|
6390
|
+
warnedNoPeer = true;
|
|
6391
|
+
const message = "[patchstack] trustedProxy is set, but this Fetch runtime supplied no peer address, so forwarded headers are not used and client addresses read as unavailable. Pass { peerAddress } to supply one.";
|
|
6392
|
+
if (typeof onError === "function") notify(onError, new Error(message), "onError");
|
|
6393
|
+
else console.warn(message);
|
|
6394
|
+
};
|
|
6395
|
+
const clientByRequest = /* @__PURE__ */ new WeakMap();
|
|
6396
|
+
const clientForResponse = (request, hostArgs) => {
|
|
6397
|
+
const known = clientByRequest.get(request);
|
|
6398
|
+
if (known) return known;
|
|
6399
|
+
const client = resolveClientIp({
|
|
6400
|
+
peer: peerOf(request, hostArgs),
|
|
6401
|
+
headers: headerObject(request.headers),
|
|
6402
|
+
trustedProxy: options.trustedProxy
|
|
6403
|
+
});
|
|
6404
|
+
if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
|
|
6405
|
+
return client;
|
|
6406
|
+
};
|
|
6407
|
+
const screenFetchRequest = async (request, hostArgs = []) => {
|
|
5998
6408
|
let result;
|
|
5999
6409
|
let shaped;
|
|
6000
6410
|
try {
|
|
6001
6411
|
shaped = await fromFetchRequest(request, {
|
|
6412
|
+
peer: peerOf(request, hostArgs),
|
|
6002
6413
|
trustedProxy: options.trustedProxy,
|
|
6003
6414
|
maxBodyBytes: options.maxBodyBytes
|
|
6004
6415
|
});
|
|
6416
|
+
if (shaped?._clientIp) {
|
|
6417
|
+
clientByRequest.set(request, shaped._clientIp);
|
|
6418
|
+
if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
|
|
6419
|
+
}
|
|
6005
6420
|
if (shaped?._bodyInspectionSkip) {
|
|
6006
6421
|
recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
|
|
6007
6422
|
}
|
|
6008
6423
|
result = engine.evaluate(shaped);
|
|
6424
|
+
recordEvaluationSkips(result);
|
|
6009
6425
|
} catch (err) {
|
|
6010
6426
|
notify(onError, err, "onError");
|
|
6011
6427
|
return { blocked: null, client: void 0 };
|
|
@@ -6055,15 +6471,19 @@ async function createProtection(options = {}) {
|
|
|
6055
6471
|
const read = await readTextResponse(response, screenCap);
|
|
6056
6472
|
if (read.skip) {
|
|
6057
6473
|
if (read.skip !== "not-a-response") {
|
|
6058
|
-
recordSkip("response", read.skip, {
|
|
6474
|
+
recordSkip("response", read.skip, {
|
|
6475
|
+
status: response?.status,
|
|
6476
|
+
...read.encoding ? { encoding: read.encoding } : {},
|
|
6477
|
+
...read.charset ? { charset: read.charset } : {}
|
|
6478
|
+
});
|
|
6059
6479
|
}
|
|
6060
6480
|
if (read.skip === "not-a-response") return response;
|
|
6061
6481
|
return hardenHeadersOnly(response, reqCtx);
|
|
6062
6482
|
}
|
|
6063
|
-
const text = read.text;
|
|
6483
|
+
const text = readJsonEscapes(read.text);
|
|
6064
6484
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
6065
|
-
if (r.verdict === "block") return leakResponse();
|
|
6066
|
-
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
6485
|
+
if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
|
|
6486
|
+
if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
|
|
6067
6487
|
return response;
|
|
6068
6488
|
};
|
|
6069
6489
|
const headerValueChanged = (was, value) => {
|
|
@@ -6125,7 +6545,8 @@ async function createProtection(options = {}) {
|
|
|
6125
6545
|
const hardenHeadersOnly = (response, reqCtx) => {
|
|
6126
6546
|
try {
|
|
6127
6547
|
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
6128
|
-
const r = screenText("", meta, reqCtx,
|
|
6548
|
+
const r = screenText("", meta, reqCtx, decidedWithoutBody);
|
|
6549
|
+
if (r.verdict === "block") return leakResponse();
|
|
6129
6550
|
if (r.verdict !== "redact" || !r.headers) return response;
|
|
6130
6551
|
if (!headersChanged(meta.headers, r.headers)) return response;
|
|
6131
6552
|
return rebuildResponse(response, response.body, r.headers);
|
|
@@ -6145,6 +6566,8 @@ async function createProtection(options = {}) {
|
|
|
6145
6566
|
const chunks = [];
|
|
6146
6567
|
let size = 0;
|
|
6147
6568
|
let overflow = false;
|
|
6569
|
+
let withheld = false;
|
|
6570
|
+
let headScreened = false;
|
|
6148
6571
|
const MAX = screenCap;
|
|
6149
6572
|
let hardened = false;
|
|
6150
6573
|
let answeredWithoutBody = false;
|
|
@@ -6176,6 +6599,7 @@ async function createProtection(options = {}) {
|
|
|
6176
6599
|
}
|
|
6177
6600
|
};
|
|
6178
6601
|
const stillToAnswer = (rule) => !hardensWithoutBody(rule);
|
|
6602
|
+
const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
|
|
6179
6603
|
let pendingHead = null;
|
|
6180
6604
|
let sending = false;
|
|
6181
6605
|
const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
|
|
@@ -6237,7 +6661,12 @@ async function createProtection(options = {}) {
|
|
|
6237
6661
|
if (typeof res.flushHeaders === "function") {
|
|
6238
6662
|
const origFlushHeaders = res.flushHeaders.bind(res);
|
|
6239
6663
|
res.flushHeaders = function() {
|
|
6240
|
-
|
|
6664
|
+
const unread = screenUnreadHead();
|
|
6665
|
+
if (unread?.withhold) {
|
|
6666
|
+
sendWithheld();
|
|
6667
|
+
return void 0;
|
|
6668
|
+
}
|
|
6669
|
+
sendHead(unread);
|
|
6241
6670
|
return origFlushHeaders();
|
|
6242
6671
|
};
|
|
6243
6672
|
}
|
|
@@ -6272,6 +6701,32 @@ async function createProtection(options = {}) {
|
|
|
6272
6701
|
headers: at === -1 ? set : { ...set, ...writeHeadHeaderObject(pendingHead[at]) }
|
|
6273
6702
|
};
|
|
6274
6703
|
};
|
|
6704
|
+
const screenUnreadHead = () => {
|
|
6705
|
+
if (headScreened) return void 0;
|
|
6706
|
+
headScreened = true;
|
|
6707
|
+
try {
|
|
6708
|
+
const head = effectiveHead();
|
|
6709
|
+
const r = screenText("", head, reqCtx, decidedByHeaders);
|
|
6710
|
+
if (r.verdict === "block") {
|
|
6711
|
+
if (!reallySent()) return { withhold: true };
|
|
6712
|
+
recordSkip("response", "headers-sent", { action: "block" });
|
|
6713
|
+
return void 0;
|
|
6714
|
+
}
|
|
6715
|
+
if (r.verdict !== "redact" || !r.headers) return void 0;
|
|
6716
|
+
const changed = /* @__PURE__ */ new Map();
|
|
6717
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
6718
|
+
if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
|
|
6719
|
+
}
|
|
6720
|
+
if (changed.size && reallySent()) {
|
|
6721
|
+
recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
|
|
6722
|
+
return void 0;
|
|
6723
|
+
}
|
|
6724
|
+
return changed.size ? { headers: changed } : void 0;
|
|
6725
|
+
} catch (err) {
|
|
6726
|
+
notify(onError, err, "onError");
|
|
6727
|
+
return void 0;
|
|
6728
|
+
}
|
|
6729
|
+
};
|
|
6275
6730
|
const sendHead = (changes) => {
|
|
6276
6731
|
if (sending) return;
|
|
6277
6732
|
const hardened2 = hardenOnce();
|
|
@@ -6293,6 +6748,17 @@ async function createProtection(options = {}) {
|
|
|
6293
6748
|
applyToResponse({ headers: merged });
|
|
6294
6749
|
origWriteHead(...args);
|
|
6295
6750
|
};
|
|
6751
|
+
const sendWithheld = (cb) => {
|
|
6752
|
+
const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
|
|
6753
|
+
const headers = /* @__PURE__ */ new Map();
|
|
6754
|
+
for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
|
|
6755
|
+
headers.set("content-type", "application/json");
|
|
6756
|
+
headers.set("content-length", String(Buffer.byteLength(body)));
|
|
6757
|
+
headers.set("transfer-encoding", null);
|
|
6758
|
+
withheld = true;
|
|
6759
|
+
sendHead({ status: 500, headers });
|
|
6760
|
+
return origEnd(body, cb);
|
|
6761
|
+
};
|
|
6296
6762
|
const applyToResponse = (changes) => {
|
|
6297
6763
|
if (changes.status !== void 0) res.statusCode = changes.status;
|
|
6298
6764
|
for (const [name, value] of changes.headers ?? []) {
|
|
@@ -6308,7 +6774,12 @@ async function createProtection(options = {}) {
|
|
|
6308
6774
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
6309
6775
|
size += buf.length;
|
|
6310
6776
|
if (size > MAX) {
|
|
6311
|
-
|
|
6777
|
+
const unread = screenUnreadHead();
|
|
6778
|
+
if (unread?.withhold) {
|
|
6779
|
+
sendWithheld();
|
|
6780
|
+
return;
|
|
6781
|
+
}
|
|
6782
|
+
sendHead(unread);
|
|
6312
6783
|
for (const c of chunks) origWrite(c);
|
|
6313
6784
|
chunks.length = 0;
|
|
6314
6785
|
origWrite(buf);
|
|
@@ -6319,6 +6790,11 @@ async function createProtection(options = {}) {
|
|
|
6319
6790
|
chunks.push(buf);
|
|
6320
6791
|
};
|
|
6321
6792
|
res.write = function(chunk, enc, cb) {
|
|
6793
|
+
if (withheld) {
|
|
6794
|
+
if (typeof enc === "function") enc();
|
|
6795
|
+
else if (typeof cb === "function") cb();
|
|
6796
|
+
return true;
|
|
6797
|
+
}
|
|
6322
6798
|
if (overflow) return origWrite(chunk, enc, cb);
|
|
6323
6799
|
collect(chunk, enc);
|
|
6324
6800
|
if (typeof enc === "function") enc();
|
|
@@ -6334,15 +6810,24 @@ async function createProtection(options = {}) {
|
|
|
6334
6810
|
cb = enc;
|
|
6335
6811
|
enc = void 0;
|
|
6336
6812
|
}
|
|
6813
|
+
if (withheld) {
|
|
6814
|
+
if (typeof cb === "function") cb();
|
|
6815
|
+
return res;
|
|
6816
|
+
}
|
|
6337
6817
|
if (overflow) {
|
|
6338
6818
|
if (chunk != null) origWrite(chunk, enc);
|
|
6339
6819
|
return origEnd(cb);
|
|
6340
6820
|
}
|
|
6341
6821
|
collect(chunk, enc);
|
|
6822
|
+
if (withheld) {
|
|
6823
|
+
if (typeof cb === "function") cb();
|
|
6824
|
+
return res;
|
|
6825
|
+
}
|
|
6342
6826
|
if (overflow) return origEnd(cb);
|
|
6343
6827
|
hardenOnce();
|
|
6344
|
-
const passThrough = () => {
|
|
6345
|
-
|
|
6828
|
+
const passThrough = (changes) => {
|
|
6829
|
+
if (changes?.withhold) return sendWithheld(cb);
|
|
6830
|
+
sendHead(changes);
|
|
6346
6831
|
for (const c of chunks) origWrite(c);
|
|
6347
6832
|
return origEnd(cb);
|
|
6348
6833
|
};
|
|
@@ -6353,21 +6838,28 @@ async function createProtection(options = {}) {
|
|
|
6353
6838
|
const kind = screenableContentType(ct);
|
|
6354
6839
|
if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
|
|
6355
6840
|
recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
|
|
6356
|
-
return passThrough();
|
|
6841
|
+
return passThrough(screenUnreadHead());
|
|
6357
6842
|
}
|
|
6358
6843
|
const codings = declaredCodings(head.headers["content-encoding"]);
|
|
6359
6844
|
if (codings.length > 0 && stillEncoded(buffer)) {
|
|
6360
6845
|
recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
|
|
6361
|
-
return passThrough();
|
|
6846
|
+
return passThrough(screenUnreadHead());
|
|
6847
|
+
}
|
|
6848
|
+
const reading = charsetReading(ct, buffer);
|
|
6849
|
+
if (reading.skip) {
|
|
6850
|
+
recordSkip("response", "unsupported-charset", { charset: reading.skip });
|
|
6851
|
+
return passThrough(screenUnreadHead());
|
|
6362
6852
|
}
|
|
6363
|
-
const text = buffer.toString("utf8");
|
|
6853
|
+
const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
|
|
6364
6854
|
let r;
|
|
6365
6855
|
try {
|
|
6366
|
-
r = screenText(text, head, reqCtx,
|
|
6856
|
+
r = screenText(text, head, reqCtx, notYetAnswered);
|
|
6367
6857
|
} catch (err) {
|
|
6368
6858
|
notify(onError, err, "onError");
|
|
6369
6859
|
return passThrough();
|
|
6370
6860
|
}
|
|
6861
|
+
if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
|
|
6862
|
+
if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
|
|
6371
6863
|
if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
|
|
6372
6864
|
if (reallySent() && res.chunkedEncoding !== true) {
|
|
6373
6865
|
notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
|
|
@@ -6375,20 +6867,11 @@ async function createProtection(options = {}) {
|
|
|
6375
6867
|
return res;
|
|
6376
6868
|
}
|
|
6377
6869
|
if (r.verdict === "block") {
|
|
6378
|
-
const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
|
|
6379
6870
|
if (reallySent()) {
|
|
6380
6871
|
res.destroy?.();
|
|
6381
6872
|
return res;
|
|
6382
6873
|
}
|
|
6383
|
-
|
|
6384
|
-
status: 500,
|
|
6385
|
-
headers: /* @__PURE__ */ new Map([
|
|
6386
|
-
["content-type", "application/json"],
|
|
6387
|
-
["content-length", String(Buffer.byteLength(body))],
|
|
6388
|
-
["transfer-encoding", null]
|
|
6389
|
-
])
|
|
6390
|
-
});
|
|
6391
|
-
return origEnd(body, cb);
|
|
6874
|
+
return sendWithheld(cb);
|
|
6392
6875
|
}
|
|
6393
6876
|
const changed = /* @__PURE__ */ new Map();
|
|
6394
6877
|
if (r.headers) {
|
|
@@ -6484,21 +6967,15 @@ async function createProtection(options = {}) {
|
|
|
6484
6967
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
6485
6968
|
// A standalone response screen with no request phase of its own — the Supabase guard's forwarded
|
|
6486
6969
|
// upstream response. It resolves once here, which is the only resolution for this call.
|
|
6487
|
-
screenResponse: (response, request) => screenResp(
|
|
6488
|
-
response,
|
|
6489
|
-
request ? reqContextFromFetch(
|
|
6490
|
-
request,
|
|
6491
|
-
resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
|
|
6492
|
-
) : void 0
|
|
6493
|
-
),
|
|
6970
|
+
screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
|
|
6494
6971
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
6495
6972
|
fetchGuard() {
|
|
6496
|
-
return async (request) => (await screenFetchRequest(request)).blocked;
|
|
6973
|
+
return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
|
|
6497
6974
|
},
|
|
6498
6975
|
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
6499
6976
|
fetch(handler) {
|
|
6500
6977
|
return async (request, ...rest) => {
|
|
6501
|
-
const { blocked, client } = await screenFetchRequest(request);
|
|
6978
|
+
const { blocked, client } = await screenFetchRequest(request, rest);
|
|
6502
6979
|
if (blocked) return blocked;
|
|
6503
6980
|
const response = await handler(request, ...rest);
|
|
6504
6981
|
return screenResp(response, reqContextFromFetch(request, client));
|
|
@@ -6512,6 +6989,7 @@ async function createProtection(options = {}) {
|
|
|
6512
6989
|
const { shaped, client } = shapeExpressRequest(req);
|
|
6513
6990
|
try {
|
|
6514
6991
|
result = engine.evaluate(shaped);
|
|
6992
|
+
recordEvaluationSkips(result);
|
|
6515
6993
|
} catch (err) {
|
|
6516
6994
|
notify(onError, err, "onError");
|
|
6517
6995
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
@@ -6546,33 +7024,25 @@ async function createProtection(options = {}) {
|
|
|
6546
7024
|
screenNodeRequest(req, res, next, "", req.body);
|
|
6547
7025
|
return;
|
|
6548
7026
|
}
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
size += chunk.length;
|
|
6554
|
-
if (size > maxBytes) {
|
|
6555
|
-
overflow = true;
|
|
7027
|
+
readBodyPrefix(req, maxBytes, (err, read) => {
|
|
7028
|
+
if (err) {
|
|
7029
|
+
notify(onError, err, "onError");
|
|
7030
|
+
next();
|
|
6556
7031
|
return;
|
|
6557
7032
|
}
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
notify(onError, err, "onError");
|
|
6562
|
-
next();
|
|
6563
|
-
});
|
|
6564
|
-
req.on("end", () => {
|
|
6565
|
-
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
6566
|
-
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
7033
|
+
if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
|
|
7034
|
+
if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
|
|
7035
|
+
screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
|
|
6567
7036
|
});
|
|
6568
7037
|
};
|
|
6569
|
-
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
7038
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
|
|
6570
7039
|
let shaped;
|
|
6571
7040
|
let result;
|
|
6572
7041
|
try {
|
|
6573
7042
|
shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
|
|
6574
7043
|
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
6575
7044
|
result = engine.evaluate(shaped);
|
|
7045
|
+
recordEvaluationSkips(result);
|
|
6576
7046
|
} catch (err) {
|
|
6577
7047
|
notify(onError, err, "onError");
|
|
6578
7048
|
return next();
|
|
@@ -6591,7 +7061,7 @@ async function createProtection(options = {}) {
|
|
|
6591
7061
|
}
|
|
6592
7062
|
},
|
|
6593
7063
|
() => {
|
|
6594
|
-
if (req.body === void 0) req.body = shaped.body;
|
|
7064
|
+
if (req.body === void 0 && !truncated) req.body = shaped.body;
|
|
6595
7065
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
6596
7066
|
next();
|
|
6597
7067
|
},
|
|
@@ -6657,12 +7127,17 @@ async function createProtection(options = {}) {
|
|
|
6657
7127
|
protection.stop = () => {
|
|
6658
7128
|
loop?.stop();
|
|
6659
7129
|
recovery?.stop();
|
|
7130
|
+
protection.uninstallEgress?.();
|
|
6660
7131
|
const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
|
|
6661
7132
|
(wait) => wait && typeof wait.then === "function"
|
|
6662
7133
|
);
|
|
6663
7134
|
return Promise.all(outstanding).then(() => void 0);
|
|
6664
7135
|
};
|
|
6665
|
-
protection.stopRefresh =
|
|
7136
|
+
protection.stopRefresh = () => {
|
|
7137
|
+
loop?.stop();
|
|
7138
|
+
recovery?.stop();
|
|
7139
|
+
return Promise.resolve();
|
|
7140
|
+
};
|
|
6666
7141
|
Object.defineProperty(protection, "ruleSource", {
|
|
6667
7142
|
get: () => ({ ...ruleSource }),
|
|
6668
7143
|
enumerable: true
|
|
@@ -6675,6 +7150,10 @@ async function createProtection(options = {}) {
|
|
|
6675
7150
|
get: () => detections ? () => detections.health() : void 0,
|
|
6676
7151
|
enumerable: true
|
|
6677
7152
|
});
|
|
7153
|
+
Object.defineProperty(protection, "blockLogHealth", {
|
|
7154
|
+
get: () => firewallLog ? () => firewallLog.health() : void 0,
|
|
7155
|
+
enumerable: true
|
|
7156
|
+
});
|
|
6678
7157
|
return protection;
|
|
6679
7158
|
}
|
|
6680
7159
|
function accessorOf(obj, name) {
|
|
@@ -6749,6 +7228,14 @@ function screenableContentType(ct) {
|
|
|
6749
7228
|
if (base === "application/octet-stream") return "sniff";
|
|
6750
7229
|
return "skip";
|
|
6751
7230
|
}
|
|
7231
|
+
function prefilterText(body, headers) {
|
|
7232
|
+
const values = [];
|
|
7233
|
+
for (const value of Object.values(headers ?? {})) {
|
|
7234
|
+
if (Array.isArray(value)) values.push(...value.map(String));
|
|
7235
|
+
else if (value !== void 0 && value !== null) values.push(String(value));
|
|
7236
|
+
}
|
|
7237
|
+
return [body, ...values].join("\n").toLowerCase();
|
|
7238
|
+
}
|
|
6752
7239
|
function looksBinary(bytes) {
|
|
6753
7240
|
const n = Math.min(bytes.length, 512);
|
|
6754
7241
|
let ctrl = 0;
|
|
@@ -6772,6 +7259,52 @@ function stillEncoded(bytes) {
|
|
|
6772
7259
|
return true;
|
|
6773
7260
|
}
|
|
6774
7261
|
}
|
|
7262
|
+
var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
|
|
7263
|
+
var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
|
|
7264
|
+
function declaredCharset(ct) {
|
|
7265
|
+
const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
|
|
7266
|
+
const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
|
|
7267
|
+
return label || null;
|
|
7268
|
+
}
|
|
7269
|
+
function charsetReading(ct, bytes) {
|
|
7270
|
+
if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
|
|
7271
|
+
if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
|
|
7272
|
+
if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
|
|
7273
|
+
const charset = declaredCharset(ct);
|
|
7274
|
+
if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
|
|
7275
|
+
if (WIDE_CHARSET.test(charset)) return { skip: charset };
|
|
7276
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
7277
|
+
const b = bytes[i];
|
|
7278
|
+
if (b >= 128 || b === 0 || b === 27) return { skip: charset };
|
|
7279
|
+
}
|
|
7280
|
+
return { ascii: true };
|
|
7281
|
+
}
|
|
7282
|
+
function decodeForScreening(ct, bytes) {
|
|
7283
|
+
const reading = charsetReading(ct, bytes);
|
|
7284
|
+
if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
|
|
7285
|
+
return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
|
|
7286
|
+
}
|
|
7287
|
+
function withBom(bom, body) {
|
|
7288
|
+
return bom ? "\uFEFF" + body : body;
|
|
7289
|
+
}
|
|
7290
|
+
function outgrowsCharset(ascii, body) {
|
|
7291
|
+
return ascii === true && /[^\x00-\x7f]/.test(body);
|
|
7292
|
+
}
|
|
7293
|
+
var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
|
|
7294
|
+
function readJsonEscapes(text) {
|
|
7295
|
+
if (!text.includes("\\u") && !text.includes("\\/")) return text;
|
|
7296
|
+
try {
|
|
7297
|
+
JSON.parse(text);
|
|
7298
|
+
} catch {
|
|
7299
|
+
return text;
|
|
7300
|
+
}
|
|
7301
|
+
return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
|
|
7302
|
+
if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
|
|
7303
|
+
const code = parseInt(hex, 16);
|
|
7304
|
+
if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
|
|
7305
|
+
return String.fromCharCode(code);
|
|
7306
|
+
});
|
|
7307
|
+
}
|
|
6775
7308
|
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
6776
7309
|
if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
|
|
6777
7310
|
const ct = response.headers?.get?.("content-type") || "";
|
|
@@ -6825,17 +7358,18 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
6825
7358
|
const bytes = concatBytes(chunks, size);
|
|
6826
7359
|
if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
|
|
6827
7360
|
try {
|
|
6828
|
-
return
|
|
7361
|
+
return decodeForScreening(ct, bytes);
|
|
6829
7362
|
} catch {
|
|
6830
7363
|
return { skip: "decode-failed" };
|
|
6831
7364
|
}
|
|
6832
7365
|
}
|
|
6833
7366
|
try {
|
|
6834
|
-
if (codings.length > 0) {
|
|
7367
|
+
if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
|
|
6835
7368
|
const bytes = new Uint8Array(await clone.arrayBuffer());
|
|
6836
7369
|
if (bytes.byteLength > cap) return { skip: "body-cap" };
|
|
6837
|
-
if (stillEncoded(bytes)) return encodedSkip();
|
|
6838
|
-
|
|
7370
|
+
if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
|
|
7371
|
+
if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
|
|
7372
|
+
return decodeForScreening(ct, bytes);
|
|
6839
7373
|
}
|
|
6840
7374
|
const text = await clone.text();
|
|
6841
7375
|
if (text.length > cap) return { skip: "body-cap" };
|
|
@@ -6866,8 +7400,9 @@ function headerObject(headers) {
|
|
|
6866
7400
|
function hasSpanMutations(rule) {
|
|
6867
7401
|
let found = false;
|
|
6868
7402
|
const walk = (conds) => {
|
|
6869
|
-
for (const c of conds
|
|
7403
|
+
for (const c of Array.isArray(conds) ? conds : []) {
|
|
6870
7404
|
if (found) return;
|
|
7405
|
+
if (!c || typeof c !== "object") continue;
|
|
6871
7406
|
if (Array.isArray(c.rules)) walk(c.rules);
|
|
6872
7407
|
const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
|
|
6873
7408
|
if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
|
|
@@ -6876,10 +7411,34 @@ function hasSpanMutations(rule) {
|
|
|
6876
7411
|
walk(rule.rule_v2);
|
|
6877
7412
|
return found;
|
|
6878
7413
|
}
|
|
6879
|
-
function
|
|
7414
|
+
function redactorScope(parameter) {
|
|
7415
|
+
if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
|
|
7416
|
+
const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
|
|
7417
|
+
for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
|
|
7418
|
+
if (typeof name !== "string" || name === "") return null;
|
|
7419
|
+
if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
|
|
7420
|
+
else if (name === "response.headers") scope.allHeaders = true;
|
|
7421
|
+
else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
|
|
7422
|
+
scope.body = true;
|
|
7423
|
+
scope.allHeaders = true;
|
|
7424
|
+
} else return null;
|
|
7425
|
+
}
|
|
7426
|
+
return scope;
|
|
7427
|
+
}
|
|
7428
|
+
var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
|
|
7429
|
+
function masksHeader(redactor, name) {
|
|
7430
|
+
return redactor.scope.allHeaders || redactor.scope.headers.has(name);
|
|
7431
|
+
}
|
|
7432
|
+
function extractRedactors(rule, onUnscoped) {
|
|
6880
7433
|
const out = [];
|
|
7434
|
+
const scoped = (c) => {
|
|
7435
|
+
const scope = redactorScope(c.parameter);
|
|
7436
|
+
if (scope === null) onUnscoped?.(c.parameter);
|
|
7437
|
+
return scope;
|
|
7438
|
+
};
|
|
6881
7439
|
const walk = (conds) => {
|
|
6882
|
-
for (const c of conds
|
|
7440
|
+
for (const c of Array.isArray(conds) ? conds : []) {
|
|
7441
|
+
if (!c || typeof c !== "object") continue;
|
|
6883
7442
|
if (Array.isArray(c.rules)) walk(c.rules);
|
|
6884
7443
|
const m = c.match;
|
|
6885
7444
|
if (!m) continue;
|
|
@@ -6887,16 +7446,21 @@ function extractRedactors(rule) {
|
|
|
6887
7446
|
const safe = safeRegExp(m.value);
|
|
6888
7447
|
if (safe) {
|
|
6889
7448
|
const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
|
|
7449
|
+
const scope = scoped(c);
|
|
6890
7450
|
try {
|
|
6891
|
-
out.push({ re: new RegExp(safe.source, flags) });
|
|
7451
|
+
if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
|
|
6892
7452
|
} catch {
|
|
6893
7453
|
}
|
|
6894
7454
|
}
|
|
6895
7455
|
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
6896
|
-
|
|
7456
|
+
const scope = scoped(c);
|
|
7457
|
+
if (scope) out.push({ literal: String(m.value), scope });
|
|
6897
7458
|
} else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
|
|
6898
|
-
|
|
6899
|
-
|
|
7459
|
+
const scope = scoped(c);
|
|
7460
|
+
if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
|
|
7461
|
+
} else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
|
|
7462
|
+
onUnscoped?.(c.parameter);
|
|
7463
|
+
} else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
|
|
6900
7464
|
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
6901
7465
|
for (const key of keys) {
|
|
6902
7466
|
out.push({ jsonPath: String(key).split("."), condition: m.match });
|
|
@@ -6918,30 +7482,101 @@ function isJson(text) {
|
|
|
6918
7482
|
return false;
|
|
6919
7483
|
}
|
|
6920
7484
|
}
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
|
|
6927
|
-
const left = expected.next();
|
|
6928
|
-
const right = actual.next();
|
|
6929
|
-
if (left.done || right.done) return left.done === right.done;
|
|
6930
|
-
if (left.value !== right.value) return false;
|
|
6931
|
-
}
|
|
7485
|
+
var JSON_STRING = 1;
|
|
7486
|
+
var JSON_NUMBER = 2;
|
|
7487
|
+
var JSON_LITERAL = 3;
|
|
7488
|
+
var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
7489
|
+
function isHexCode(c) {
|
|
7490
|
+
return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
|
|
6932
7491
|
}
|
|
6933
|
-
|
|
6934
|
-
|
|
6935
|
-
|
|
6936
|
-
|
|
6937
|
-
|
|
6938
|
-
|
|
7492
|
+
var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
|
|
7493
|
+
function lexJson(text) {
|
|
7494
|
+
let kinds = new Int32Array(1024);
|
|
7495
|
+
let spans = new Int32Array(2048);
|
|
7496
|
+
let count = 0;
|
|
7497
|
+
const n = text.length;
|
|
7498
|
+
let i = 0;
|
|
7499
|
+
while (i < n) {
|
|
7500
|
+
const c = text.charCodeAt(i);
|
|
7501
|
+
if (c === 32 || c === 9 || c === 10 || c === 13) {
|
|
7502
|
+
i++;
|
|
6939
7503
|
continue;
|
|
6940
7504
|
}
|
|
6941
|
-
|
|
6942
|
-
|
|
6943
|
-
|
|
7505
|
+
const start = i;
|
|
7506
|
+
let kind;
|
|
7507
|
+
if (c === 34) {
|
|
7508
|
+
i++;
|
|
7509
|
+
const close = text.indexOf('"', i);
|
|
7510
|
+
if (close === -1) return null;
|
|
7511
|
+
if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
|
|
7512
|
+
i = close;
|
|
7513
|
+
} else {
|
|
7514
|
+
for (; ; ) {
|
|
7515
|
+
if (i >= n) return null;
|
|
7516
|
+
const d = text.charCodeAt(i);
|
|
7517
|
+
if (d === 34) break;
|
|
7518
|
+
if (d < 32) return null;
|
|
7519
|
+
if (d !== 92) {
|
|
7520
|
+
i++;
|
|
7521
|
+
continue;
|
|
7522
|
+
}
|
|
7523
|
+
const e = text.charCodeAt(i + 1);
|
|
7524
|
+
if (e === 117) {
|
|
7525
|
+
for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
|
|
7526
|
+
i += 6;
|
|
7527
|
+
} else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
|
|
7528
|
+
i += 2;
|
|
7529
|
+
} else {
|
|
7530
|
+
return null;
|
|
7531
|
+
}
|
|
7532
|
+
}
|
|
7533
|
+
}
|
|
7534
|
+
i++;
|
|
7535
|
+
kind = JSON_STRING;
|
|
7536
|
+
} else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
|
|
7537
|
+
i++;
|
|
7538
|
+
kind = c;
|
|
7539
|
+
} else if (text.startsWith("true", i) || text.startsWith("null", i)) {
|
|
7540
|
+
i += 4;
|
|
7541
|
+
kind = JSON_LITERAL;
|
|
7542
|
+
} else if (text.startsWith("false", i)) {
|
|
7543
|
+
i += 5;
|
|
7544
|
+
kind = JSON_LITERAL;
|
|
7545
|
+
} else {
|
|
7546
|
+
JSON_NUMBER_TOKEN.lastIndex = i;
|
|
7547
|
+
if (!JSON_NUMBER_TOKEN.test(text)) return null;
|
|
7548
|
+
i = JSON_NUMBER_TOKEN.lastIndex;
|
|
7549
|
+
kind = JSON_NUMBER;
|
|
7550
|
+
}
|
|
7551
|
+
if (count === kinds.length) {
|
|
7552
|
+
const grownKinds = new Int32Array(count * 2);
|
|
7553
|
+
grownKinds.set(kinds);
|
|
7554
|
+
kinds = grownKinds;
|
|
7555
|
+
const grownSpans = new Int32Array(count * 4);
|
|
7556
|
+
grownSpans.set(spans);
|
|
7557
|
+
spans = grownSpans;
|
|
7558
|
+
}
|
|
7559
|
+
kinds[count] = kind;
|
|
7560
|
+
spans[2 * count] = start;
|
|
7561
|
+
spans[2 * count + 1] = i;
|
|
7562
|
+
count++;
|
|
7563
|
+
}
|
|
7564
|
+
return { kinds, spans, count };
|
|
7565
|
+
}
|
|
7566
|
+
function sameJsonStructure(beforeText, before, afterText, after) {
|
|
7567
|
+
if (!before || !after || before.count !== after.count) return false;
|
|
7568
|
+
const { kinds, spans, count } = before;
|
|
7569
|
+
for (let k = 0; k < count; k++) {
|
|
7570
|
+
const kind = kinds[k];
|
|
7571
|
+
if (after.kinds[k] !== kind) return false;
|
|
7572
|
+
if (kind > JSON_LITERAL) continue;
|
|
7573
|
+
if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
|
|
7574
|
+
const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
|
|
7575
|
+
const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
|
|
7576
|
+
if (was === now) continue;
|
|
7577
|
+
if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
|
|
6944
7578
|
}
|
|
7579
|
+
return true;
|
|
6945
7580
|
}
|
|
6946
7581
|
function applyRedactors(body, redactors, mask, transform) {
|
|
6947
7582
|
let out = body;
|
|
@@ -6958,6 +7593,11 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
6958
7593
|
}
|
|
6959
7594
|
return out;
|
|
6960
7595
|
}
|
|
7596
|
+
function readsBody(parameter) {
|
|
7597
|
+
if (!Array.isArray(parameter)) return isBodyParam(parameter);
|
|
7598
|
+
const names = parameter.every((member) => typeof member === "string" && member !== "");
|
|
7599
|
+
return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
|
|
7600
|
+
}
|
|
6961
7601
|
function isBodyParam(parameter) {
|
|
6962
7602
|
return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
|
|
6963
7603
|
}
|
|
@@ -6989,7 +7629,7 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
|
6989
7629
|
let obj;
|
|
6990
7630
|
try {
|
|
6991
7631
|
JSON.parse(text);
|
|
6992
|
-
preserved =
|
|
7632
|
+
preserved = preserveNumbers(text, mask);
|
|
6993
7633
|
obj = JSON.parse(preserved.text);
|
|
6994
7634
|
} catch {
|
|
6995
7635
|
return text;
|
|
@@ -7008,22 +7648,22 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
|
7008
7648
|
}
|
|
7009
7649
|
});
|
|
7010
7650
|
}
|
|
7011
|
-
return changed ?
|
|
7651
|
+
return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
|
|
7012
7652
|
}
|
|
7013
|
-
function
|
|
7653
|
+
function preserveNumbers(text, mask) {
|
|
7014
7654
|
const occupied = /* @__PURE__ */ new Set([mask]);
|
|
7015
|
-
const
|
|
7655
|
+
const found = [];
|
|
7016
7656
|
const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
|
|
7017
7657
|
for (const match of text.matchAll(tokens)) {
|
|
7018
7658
|
const token = match[0];
|
|
7019
7659
|
if (token[0] === '"') occupied.add(JSON.parse(token));
|
|
7020
|
-
else
|
|
7660
|
+
else found.push({ start: match.index, token });
|
|
7021
7661
|
}
|
|
7022
7662
|
const numbers = /* @__PURE__ */ new Map();
|
|
7023
7663
|
const chunks = [];
|
|
7024
7664
|
let offset = 0;
|
|
7025
7665
|
let index = 0;
|
|
7026
|
-
for (const { start, token } of
|
|
7666
|
+
for (const { start, token } of found) {
|
|
7027
7667
|
let marker;
|
|
7028
7668
|
do {
|
|
7029
7669
|
marker = "__PSNUMBER_" + index++ + "__";
|
|
@@ -7035,7 +7675,7 @@ function preserveBigInts(text, mask) {
|
|
|
7035
7675
|
chunks.push(text.slice(offset));
|
|
7036
7676
|
return { text: chunks.join(""), numbers };
|
|
7037
7677
|
}
|
|
7038
|
-
function
|
|
7678
|
+
function restoreNumbers(text, numbers) {
|
|
7039
7679
|
return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
|
|
7040
7680
|
}
|
|
7041
7681
|
function isHeaderMutation(action) {
|