@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.edge.js
CHANGED
|
@@ -426,18 +426,124 @@ function appendOwn(target, key, value) {
|
|
|
426
426
|
return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
|
|
427
427
|
}
|
|
428
428
|
|
|
429
|
-
// src/protect/engine/
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
429
|
+
// src/protect/engine/cookies.js
|
|
430
|
+
function parseCookieHeader(header) {
|
|
431
|
+
const cookies = {};
|
|
432
|
+
if (typeof header !== "string" || header === "") return cookies;
|
|
433
|
+
const repeated = /* @__PURE__ */ new Map();
|
|
434
|
+
for (const pair of header.split(";")) {
|
|
435
|
+
const eq = pair.indexOf("=");
|
|
436
|
+
if (eq === -1) continue;
|
|
437
|
+
const name = pair.slice(0, eq).trim();
|
|
438
|
+
if (name === "") continue;
|
|
439
|
+
let value = pair.slice(eq + 1).trim();
|
|
440
|
+
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') value = value.slice(1, -1);
|
|
441
|
+
if (!Object.hasOwn(cookies, name)) {
|
|
442
|
+
setOwn(cookies, name, value);
|
|
443
|
+
} else {
|
|
444
|
+
let values = repeated.get(name);
|
|
445
|
+
if (values === void 0) {
|
|
446
|
+
values = [cookies[name]];
|
|
447
|
+
repeated.set(name, values);
|
|
448
|
+
setOwn(cookies, name, values);
|
|
449
|
+
}
|
|
450
|
+
values.push(value);
|
|
451
|
+
}
|
|
437
452
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
453
|
+
return cookies;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/protect/engine/normalizer.js
|
|
457
|
+
var HTML_ENTITIES = {
|
|
458
|
+
"&": "&",
|
|
459
|
+
"<": "<",
|
|
460
|
+
">": ">",
|
|
461
|
+
""": '"',
|
|
462
|
+
"'": "'",
|
|
463
|
+
"'": "'",
|
|
464
|
+
" ": " ",
|
|
465
|
+
"'": "'",
|
|
466
|
+
""": '"',
|
|
467
|
+
"<": "<",
|
|
468
|
+
">": ">",
|
|
469
|
+
"&": "&",
|
|
470
|
+
""": '"',
|
|
471
|
+
"<": "<",
|
|
472
|
+
">": ">",
|
|
473
|
+
"&": "&"
|
|
474
|
+
};
|
|
475
|
+
var MAX_DECODE_ITERATIONS = 5;
|
|
476
|
+
function normalize(value, options = {}) {
|
|
477
|
+
if (typeof value !== "string") {
|
|
478
|
+
return value;
|
|
479
|
+
}
|
|
480
|
+
const opts = {
|
|
481
|
+
urlDecode: true,
|
|
482
|
+
htmlDecode: true,
|
|
483
|
+
sqlComments: true,
|
|
484
|
+
nullBytes: true,
|
|
485
|
+
whitespace: true,
|
|
486
|
+
...options
|
|
487
|
+
};
|
|
488
|
+
let result = value;
|
|
489
|
+
if (opts.nullBytes) {
|
|
490
|
+
result = removeNullBytes(result);
|
|
491
|
+
}
|
|
492
|
+
if (opts.urlDecode) {
|
|
493
|
+
result = urlDecode(result);
|
|
494
|
+
}
|
|
495
|
+
if (opts.htmlDecode) {
|
|
496
|
+
result = htmlEntityDecode(result);
|
|
497
|
+
}
|
|
498
|
+
if (opts.sqlComments) {
|
|
499
|
+
result = removeSqlComments(result);
|
|
500
|
+
}
|
|
501
|
+
if (opts.whitespace) {
|
|
502
|
+
result = normalizeWhitespace(result);
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
function urlDecode(value) {
|
|
507
|
+
if (typeof value !== "string") {
|
|
508
|
+
return value;
|
|
509
|
+
}
|
|
510
|
+
let result = value;
|
|
511
|
+
let previous = "";
|
|
512
|
+
let iterations = 0;
|
|
513
|
+
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
|
|
514
|
+
previous = result;
|
|
515
|
+
iterations++;
|
|
516
|
+
result = safeUrlDecode(result);
|
|
517
|
+
}
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
var utf8 = new TextDecoder();
|
|
521
|
+
function safeUrlDecode(value) {
|
|
522
|
+
return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
|
|
523
|
+
try {
|
|
524
|
+
return decodeURIComponent(run);
|
|
525
|
+
} catch {
|
|
526
|
+
const bytes = new Uint8Array(run.length / 3);
|
|
527
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
|
|
528
|
+
return utf8.decode(bytes);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function decodeQueryPlus(target) {
|
|
533
|
+
if (typeof target !== "string") return target;
|
|
534
|
+
const query = target.indexOf("?");
|
|
535
|
+
if (query === -1) return target;
|
|
536
|
+
return target.slice(0, query + 1) + target.slice(query + 1).replace(/\+/g, " ");
|
|
537
|
+
}
|
|
538
|
+
function htmlEntityDecode(value) {
|
|
539
|
+
if (typeof value !== "string") {
|
|
540
|
+
return value;
|
|
541
|
+
}
|
|
542
|
+
let result = value;
|
|
543
|
+
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
544
|
+
result = result.split(entity).join(char);
|
|
545
|
+
}
|
|
546
|
+
return decodeHtmlEntities(result);
|
|
441
547
|
}
|
|
442
548
|
var NAMED_ENTITIES = {
|
|
443
549
|
lt: "<",
|
|
@@ -445,7 +551,6 @@ var NAMED_ENTITIES = {
|
|
|
445
551
|
amp: "&",
|
|
446
552
|
quot: '"',
|
|
447
553
|
apos: "'",
|
|
448
|
-
"#39": "'",
|
|
449
554
|
nbsp: "\xA0",
|
|
450
555
|
sol: "/",
|
|
451
556
|
bsol: "\\",
|
|
@@ -464,7 +569,7 @@ var NAMED_ENTITIES = {
|
|
|
464
569
|
ast: "*"
|
|
465
570
|
};
|
|
466
571
|
function decodeHtmlEntities(input) {
|
|
467
|
-
return input.replace(/&(#[xX]
|
|
572
|
+
return input.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
|
|
468
573
|
if (body[0] === "#") {
|
|
469
574
|
const hex = body[1] === "x" || body[1] === "X";
|
|
470
575
|
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
@@ -478,74 +583,359 @@ function decodeHtmlEntities(input) {
|
|
|
478
583
|
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
|
479
584
|
});
|
|
480
585
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
constructor(req) {
|
|
485
|
-
this.#req = req;
|
|
486
|
-
this.#cookies = null;
|
|
586
|
+
function removeSqlComments(value) {
|
|
587
|
+
if (typeof value !== "string") {
|
|
588
|
+
return value;
|
|
487
589
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
590
|
+
let result = value;
|
|
591
|
+
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
592
|
+
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
593
|
+
return result;
|
|
594
|
+
}
|
|
595
|
+
function removeNullBytes(value) {
|
|
596
|
+
if (typeof value !== "string") {
|
|
597
|
+
return value;
|
|
598
|
+
}
|
|
599
|
+
let result = value.replace(/\x00/g, "");
|
|
600
|
+
result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
601
|
+
return result;
|
|
602
|
+
}
|
|
603
|
+
function normalizeWhitespace(value) {
|
|
604
|
+
if (typeof value !== "string") {
|
|
605
|
+
return value;
|
|
606
|
+
}
|
|
607
|
+
let result = value.replace(/[\t\r\n\f\v]+/g, " ");
|
|
608
|
+
result = result.replace(/ {2,}/g, " ");
|
|
609
|
+
return result;
|
|
610
|
+
}
|
|
611
|
+
var MAX_RAW_CHARS = 1024 * 1024;
|
|
612
|
+
var MAX_RAW_NODES = 25e3;
|
|
613
|
+
var MAX_RAW_DEPTH = 200;
|
|
614
|
+
var MAX_RAW_ENTRIES = 2e3;
|
|
615
|
+
function serializeForRawDetection(body) {
|
|
616
|
+
if (body === null || body === void 0) return "";
|
|
617
|
+
if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
|
|
618
|
+
const visited = /* @__PURE__ */ new Set();
|
|
619
|
+
const output = [];
|
|
620
|
+
const stack = [{ kind: "value", value: body, depth: 0, root: true }];
|
|
621
|
+
let chars = 0;
|
|
622
|
+
let nodes = 0;
|
|
623
|
+
const append = (text) => {
|
|
624
|
+
if (chars >= MAX_RAW_CHARS) return;
|
|
625
|
+
const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
|
|
626
|
+
output.push(piece);
|
|
627
|
+
chars += piece.length;
|
|
628
|
+
};
|
|
629
|
+
while (stack.length > 0 && chars < MAX_RAW_CHARS) {
|
|
630
|
+
const task = stack.pop();
|
|
631
|
+
if (task.kind === "text") {
|
|
632
|
+
append(task.text);
|
|
633
|
+
continue;
|
|
491
634
|
}
|
|
492
|
-
if (
|
|
493
|
-
|
|
635
|
+
if (task.kind === "key") {
|
|
636
|
+
append(boundedJsonString(task.value));
|
|
637
|
+
append(":");
|
|
638
|
+
continue;
|
|
494
639
|
}
|
|
495
|
-
|
|
496
|
-
|
|
640
|
+
nodes++;
|
|
641
|
+
if (nodes > MAX_RAW_NODES) {
|
|
642
|
+
append('"[NodeLimit]"');
|
|
643
|
+
continue;
|
|
497
644
|
}
|
|
498
|
-
|
|
499
|
-
|
|
645
|
+
const value = task.value;
|
|
646
|
+
if (value === null || value === void 0) {
|
|
647
|
+
append(task.root ? "" : "null");
|
|
648
|
+
continue;
|
|
500
649
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
650
|
+
if (typeof value === "string") {
|
|
651
|
+
append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
|
|
652
|
+
continue;
|
|
504
653
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
case "get":
|
|
509
|
-
return this.#resolveGet(key);
|
|
510
|
-
case "post":
|
|
511
|
-
return this.#resolvePost(key);
|
|
512
|
-
case "request":
|
|
513
|
-
return this.#resolveRequest(key);
|
|
514
|
-
case "cookie":
|
|
515
|
-
return this.#resolveCookie(key);
|
|
516
|
-
case "server":
|
|
517
|
-
return this.#resolveServer(key);
|
|
518
|
-
case "files":
|
|
519
|
-
return this.#resolveFiles(key);
|
|
520
|
-
case "response":
|
|
521
|
-
return this.#resolveResponse(key);
|
|
522
|
-
case "egress":
|
|
523
|
-
return this.#resolveEgress(key);
|
|
524
|
-
default:
|
|
525
|
-
return [];
|
|
654
|
+
if (typeof value !== "object") {
|
|
655
|
+
append(String(value).slice(0, MAX_RAW_CHARS));
|
|
656
|
+
continue;
|
|
526
657
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
#resolveResponse(key) {
|
|
531
|
-
const resp = this.#req._response;
|
|
532
|
-
if (!resp) {
|
|
533
|
-
return [];
|
|
658
|
+
if (visited.has(value)) {
|
|
659
|
+
append("[Circular]");
|
|
660
|
+
continue;
|
|
534
661
|
}
|
|
535
|
-
if (
|
|
536
|
-
|
|
662
|
+
if (task.depth >= MAX_RAW_DEPTH) {
|
|
663
|
+
append('"[DepthLimit]"');
|
|
664
|
+
continue;
|
|
537
665
|
}
|
|
538
|
-
|
|
539
|
-
|
|
666
|
+
visited.add(value);
|
|
667
|
+
if (Array.isArray(value)) {
|
|
668
|
+
const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
|
|
669
|
+
const items = [];
|
|
670
|
+
for (let i = 0; i < length2; i++) {
|
|
671
|
+
if (i > 0) items.push({ kind: "text", text: "," });
|
|
672
|
+
items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
|
|
673
|
+
}
|
|
674
|
+
if (value.length > length2) {
|
|
675
|
+
if (length2 > 0) items.push({ kind: "text", text: "," });
|
|
676
|
+
items.push({ kind: "text", text: '"[EntryLimit]"' });
|
|
677
|
+
}
|
|
678
|
+
stack.push({ kind: "text", text: "]" });
|
|
679
|
+
for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
|
|
680
|
+
stack.push({ kind: "text", text: "[" });
|
|
681
|
+
continue;
|
|
540
682
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
683
|
+
let keys;
|
|
684
|
+
try {
|
|
685
|
+
keys = Object.getOwnPropertyNames(value);
|
|
686
|
+
} catch {
|
|
687
|
+
append('"[Unreadable]"');
|
|
688
|
+
continue;
|
|
544
689
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
690
|
+
const length = Math.min(keys.length, MAX_RAW_ENTRIES);
|
|
691
|
+
const entries = [];
|
|
692
|
+
for (let i = 0; i < length; i++) {
|
|
693
|
+
const key = keys[i];
|
|
694
|
+
if (i > 0) entries.push({ kind: "text", text: "," });
|
|
695
|
+
entries.push({ kind: "key", value: key });
|
|
696
|
+
entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
|
|
697
|
+
}
|
|
698
|
+
if (keys.length > length) {
|
|
699
|
+
if (length > 0) entries.push({ kind: "text", text: "," });
|
|
700
|
+
entries.push({ kind: "text", text: '"[EntryLimit]":null' });
|
|
701
|
+
}
|
|
702
|
+
stack.push({ kind: "text", text: "}" });
|
|
703
|
+
for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
|
|
704
|
+
stack.push({ kind: "text", text: "{" });
|
|
705
|
+
}
|
|
706
|
+
return output.join("");
|
|
707
|
+
}
|
|
708
|
+
function boundedJsonString(value) {
|
|
709
|
+
return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
|
|
710
|
+
}
|
|
711
|
+
function readOwnValue(value, key) {
|
|
712
|
+
try {
|
|
713
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
714
|
+
return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
|
|
715
|
+
} catch {
|
|
716
|
+
return "[Unreadable]";
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
|
|
720
|
+
function requestField(req, key) {
|
|
721
|
+
if (req === null || typeof req !== "object") return void 0;
|
|
722
|
+
if (Object.hasOwn(req, key)) return req[key];
|
|
723
|
+
if (!INHERITED_ACCESSORS.has(key)) return void 0;
|
|
724
|
+
let holder = Object.getPrototypeOf(req);
|
|
725
|
+
while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
|
|
726
|
+
if (holder === null || holder === Object.prototype) return void 0;
|
|
727
|
+
const descriptor = Object.getOwnPropertyDescriptor(holder, key);
|
|
728
|
+
if (typeof descriptor?.get !== "function") return void 0;
|
|
729
|
+
return req[key];
|
|
730
|
+
}
|
|
731
|
+
function normalizeRequest(req, options = {}) {
|
|
732
|
+
const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
|
|
733
|
+
const body = requestField(req, "body");
|
|
734
|
+
const url = requestField(req, "url");
|
|
735
|
+
const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
|
|
736
|
+
const headers = requestField(req, "headers") || {};
|
|
737
|
+
return {
|
|
738
|
+
query: normalizeObject(requestField(req, "query") || {}, options),
|
|
739
|
+
body: normalizeObject(body || {}, options),
|
|
740
|
+
headers: normalizeObject(headers, options),
|
|
741
|
+
// Cookies are split into name/value pairs BEFORE their values are normalised, and normalised the
|
|
742
|
+
// same way whether a framework parsed them or they come from the header — so a rule on a cookie
|
|
743
|
+
// sees one value, independent of which cookie parser (if any) ran first.
|
|
744
|
+
cookies: normalizeObject(cookieValues(requestField(req, "cookies"), headers), options),
|
|
745
|
+
url: normalize(decodeQueryPlus(url || ""), options),
|
|
746
|
+
originalUrl: normalize(decodeQueryPlus(requestField(req, "originalUrl") || url || ""), options),
|
|
747
|
+
_rawBody: rawBody
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function cookieValues(parsed, headers) {
|
|
751
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
752
|
+
return parseCookieHeader(headers?.cookie);
|
|
753
|
+
}
|
|
754
|
+
var MAX_NORMALIZE_DEPTH = 1e3;
|
|
755
|
+
function normalizeObject(value, options = {}, depth = 0) {
|
|
756
|
+
if (typeof value === "string") {
|
|
757
|
+
return normalize(value, options);
|
|
758
|
+
}
|
|
759
|
+
if (value === null || typeof value !== "object") {
|
|
760
|
+
return value;
|
|
761
|
+
}
|
|
762
|
+
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
763
|
+
options.onLimit?.();
|
|
764
|
+
return value;
|
|
765
|
+
}
|
|
766
|
+
const copies = /* @__PURE__ */ new Map();
|
|
767
|
+
const copyOf = (node) => {
|
|
768
|
+
const copy = Array.isArray(node) ? new Array(node.length) : {};
|
|
769
|
+
copies.set(node, copy);
|
|
770
|
+
return copy;
|
|
771
|
+
};
|
|
772
|
+
const top = copyOf(value);
|
|
773
|
+
const stack = [[value, top, depth]];
|
|
774
|
+
while (stack.length > 0) {
|
|
775
|
+
const [node, copy, level] = stack.pop();
|
|
776
|
+
for (const key of Object.keys(node)) {
|
|
777
|
+
const child = node[key];
|
|
778
|
+
if (typeof child === "string") {
|
|
779
|
+
setOwn(copy, key, normalize(child, options));
|
|
780
|
+
} else if (child === null || typeof child !== "object") {
|
|
781
|
+
setOwn(copy, key, child);
|
|
782
|
+
} else if (copies.has(child)) {
|
|
783
|
+
setOwn(copy, key, copies.get(child));
|
|
784
|
+
} else if (level + 1 >= MAX_NORMALIZE_DEPTH) {
|
|
785
|
+
setOwn(copy, key, child);
|
|
786
|
+
options.onLimit?.();
|
|
787
|
+
} else {
|
|
788
|
+
const childCopy = copyOf(child);
|
|
789
|
+
setOwn(copy, key, childCopy);
|
|
790
|
+
stack.push([child, childCopy, level + 1]);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return top;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/protect/engine/request.js
|
|
798
|
+
var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
|
|
799
|
+
var ownValue = (obj, key) => obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
|
|
800
|
+
function fieldSpellings(key) {
|
|
801
|
+
const spellings = [{ key, nested: true }];
|
|
802
|
+
if (key.includes("[")) {
|
|
803
|
+
const dotted = key.replace(/\[\]$/, "").replace(/\[([^\][]*)\]/g, ".$1");
|
|
804
|
+
if (dotted !== key && dotted !== "" && !dotted.includes("[") && !dotted.includes("]")) {
|
|
805
|
+
spellings.push({ key: dotted, nested: true });
|
|
806
|
+
}
|
|
807
|
+
return spellings;
|
|
808
|
+
}
|
|
809
|
+
const [head, ...rest] = key.split(".");
|
|
810
|
+
const bracketed = head + rest.map((part) => `[${part}]`).join("");
|
|
811
|
+
if (bracketed !== key) spellings.push({ key: bracketed, nested: false });
|
|
812
|
+
spellings.push({ key: `${bracketed}[]`, nested: false });
|
|
813
|
+
return spellings;
|
|
814
|
+
}
|
|
815
|
+
var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
|
|
816
|
+
var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
|
|
817
|
+
function base64DecodeUtf8(value) {
|
|
818
|
+
const str = String(value);
|
|
819
|
+
if (typeof Buffer !== "undefined") {
|
|
820
|
+
return Buffer.from(str, "base64").toString("utf-8");
|
|
821
|
+
}
|
|
822
|
+
const binary = atob(str);
|
|
823
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
824
|
+
return new TextDecoder().decode(bytes);
|
|
825
|
+
}
|
|
826
|
+
var TEXT_MUTATIONS = /* @__PURE__ */ new Set(["base64_decode", "urldecode", "htmlentitydecode"]);
|
|
827
|
+
var MAX_MAPPED_NODES = 2e4;
|
|
828
|
+
var MAX_MAPPED_DEPTH = 1e3;
|
|
829
|
+
function mapStringLeaves(root, fn, onLimit) {
|
|
830
|
+
const copies = /* @__PURE__ */ new Map();
|
|
831
|
+
const copyOf = (node) => {
|
|
832
|
+
const copy = Array.isArray(node) ? [] : {};
|
|
833
|
+
copies.set(node, copy);
|
|
834
|
+
return copy;
|
|
835
|
+
};
|
|
836
|
+
const top = copyOf(root);
|
|
837
|
+
const stack = [[root, top, 0]];
|
|
838
|
+
let visited = 0;
|
|
839
|
+
while (stack.length) {
|
|
840
|
+
const [node, copy, depth] = stack.pop();
|
|
841
|
+
visited++;
|
|
842
|
+
for (const key of Object.keys(node)) {
|
|
843
|
+
const child = node[key];
|
|
844
|
+
if (typeof child === "string") {
|
|
845
|
+
setOwn(copy, key, fn(child));
|
|
846
|
+
} else if (child === null || typeof child !== "object") {
|
|
847
|
+
setOwn(copy, key, child);
|
|
848
|
+
} else if (copies.has(child)) {
|
|
849
|
+
setOwn(copy, key, copies.get(child));
|
|
850
|
+
} else if (depth + 1 >= MAX_MAPPED_DEPTH || visited + stack.length >= MAX_MAPPED_NODES) {
|
|
851
|
+
setOwn(copy, key, child);
|
|
852
|
+
onLimit();
|
|
853
|
+
} else {
|
|
854
|
+
const childCopy = copyOf(child);
|
|
855
|
+
setOwn(copy, key, childCopy);
|
|
856
|
+
stack.push([child, childCopy, depth + 1]);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return top;
|
|
861
|
+
}
|
|
862
|
+
var RequestResolver = class {
|
|
863
|
+
#req;
|
|
864
|
+
#cookies;
|
|
865
|
+
#skips = /* @__PURE__ */ new Set();
|
|
866
|
+
constructor(req) {
|
|
867
|
+
this.#req = req;
|
|
868
|
+
this.#cookies = null;
|
|
869
|
+
}
|
|
870
|
+
/** Record an inspection limit this evaluation reached (reported once per reason). */
|
|
871
|
+
noteSkip(reason) {
|
|
872
|
+
this.#skips.add(reason);
|
|
873
|
+
}
|
|
874
|
+
/** The inspection limits recorded so far, in the order first reached. */
|
|
875
|
+
get skips() {
|
|
876
|
+
return [...this.#skips];
|
|
877
|
+
}
|
|
878
|
+
resolve(parameter) {
|
|
879
|
+
if (!parameter || parameter === "false") {
|
|
880
|
+
return [null];
|
|
881
|
+
}
|
|
882
|
+
if (parameter === "rules") {
|
|
883
|
+
return [null];
|
|
884
|
+
}
|
|
885
|
+
if (parameter === "raw") {
|
|
886
|
+
return this.#resolveRaw();
|
|
887
|
+
}
|
|
888
|
+
if (parameter === "all") {
|
|
889
|
+
return this.#resolveAll();
|
|
890
|
+
}
|
|
891
|
+
const dotIndex = parameter.indexOf(".");
|
|
892
|
+
if (dotIndex === -1) {
|
|
893
|
+
return [];
|
|
894
|
+
}
|
|
895
|
+
const source = parameter.substring(0, dotIndex);
|
|
896
|
+
const key = parameter.substring(dotIndex + 1);
|
|
897
|
+
switch (source) {
|
|
898
|
+
case "get":
|
|
899
|
+
return this.#resolveGet(key);
|
|
900
|
+
case "post":
|
|
901
|
+
return this.#resolvePost(key);
|
|
902
|
+
case "request":
|
|
903
|
+
return this.#resolveRequest(key);
|
|
904
|
+
case "cookie":
|
|
905
|
+
return this.#resolveCookie(key);
|
|
906
|
+
case "server":
|
|
907
|
+
return this.#resolveServer(key);
|
|
908
|
+
case "files":
|
|
909
|
+
return this.#resolveFiles(key);
|
|
910
|
+
case "response":
|
|
911
|
+
return this.#resolveResponse(key);
|
|
912
|
+
case "egress":
|
|
913
|
+
return this.#resolveEgress(key);
|
|
914
|
+
default:
|
|
915
|
+
return [];
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
|
|
919
|
+
// what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
|
|
920
|
+
#resolveResponse(key) {
|
|
921
|
+
const resp = this.#req._response;
|
|
922
|
+
if (!resp) {
|
|
923
|
+
return [];
|
|
924
|
+
}
|
|
925
|
+
if (key === "status") {
|
|
926
|
+
return resp.status !== void 0 ? [String(resp.status)] : [];
|
|
927
|
+
}
|
|
928
|
+
if (key === "body") {
|
|
929
|
+
return resp.body != null && resp.body !== "" ? [resp.body] : [];
|
|
930
|
+
}
|
|
931
|
+
if (key === "headers") {
|
|
932
|
+
const headers = resp.headers ?? {};
|
|
933
|
+
return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
|
|
934
|
+
}
|
|
935
|
+
if (key.startsWith("header.")) {
|
|
936
|
+
const name = key.slice("header.".length).toLowerCase();
|
|
937
|
+
const headers = resp.headers ?? {};
|
|
938
|
+
return Object.hasOwn(headers, name) ? [headers[name]] : [];
|
|
549
939
|
}
|
|
550
940
|
return [];
|
|
551
941
|
}
|
|
@@ -575,6 +965,9 @@ var RequestResolver = class {
|
|
|
575
965
|
if (value === null || value === void 0) {
|
|
576
966
|
return value;
|
|
577
967
|
}
|
|
968
|
+
if (typeof value === "object" && TEXT_MUTATIONS.has(mutation)) {
|
|
969
|
+
return mapStringLeaves(value, (leaf) => this.#applyMutation(mutation, leaf), () => this.noteSkip("container-cap"));
|
|
970
|
+
}
|
|
578
971
|
switch (mutation) {
|
|
579
972
|
case "base64_decode":
|
|
580
973
|
try {
|
|
@@ -595,11 +988,7 @@ var RequestResolver = class {
|
|
|
595
988
|
return value;
|
|
596
989
|
}
|
|
597
990
|
case "urldecode":
|
|
598
|
-
|
|
599
|
-
return decodeURIComponent(String(value));
|
|
600
|
-
} catch {
|
|
601
|
-
return value;
|
|
602
|
-
}
|
|
991
|
+
return safeUrlDecode(String(value).replace(/\+/g, " "));
|
|
603
992
|
case "htmlentitydecode":
|
|
604
993
|
return decodeHtmlEntities(String(value));
|
|
605
994
|
case "intval":
|
|
@@ -618,16 +1007,30 @@ var RequestResolver = class {
|
|
|
618
1007
|
if (key.endsWith("*")) {
|
|
619
1008
|
return this.#resolveWildcard(query, key);
|
|
620
1009
|
}
|
|
621
|
-
|
|
622
|
-
return value !== void 0 ? [value] : [];
|
|
1010
|
+
return this.#lookup(query, key);
|
|
623
1011
|
}
|
|
624
1012
|
#resolvePost(key) {
|
|
625
1013
|
const body = this.#req.body ?? {};
|
|
626
1014
|
if (key.endsWith("*")) {
|
|
627
1015
|
return this.#resolveWildcard(body, key);
|
|
628
1016
|
}
|
|
629
|
-
|
|
630
|
-
|
|
1017
|
+
return this.#lookup(body, key);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Every value a field path names in `obj`, in either spelling a form or query field can take.
|
|
1021
|
+
*
|
|
1022
|
+
* A parser that expands brackets (`qs`, as Express uses) turns `user[name]=x` into `{ user: { name } }`
|
|
1023
|
+
* and `id[]=x` into `{ id: [x] }`; a flat parser (`URLSearchParams`, as the Fetch and Node adapters use)
|
|
1024
|
+
* keeps `user[name]` and `id[]` as literal keys. The rule names the field once, so both shapes answer
|
|
1025
|
+
* to both spellings: `user.name` and `user[name]`, `id` and `id[]`.
|
|
1026
|
+
*/
|
|
1027
|
+
#lookup(obj, key) {
|
|
1028
|
+
const values = [];
|
|
1029
|
+
for (const candidate of fieldSpellings(key)) {
|
|
1030
|
+
const value = candidate.nested ? this.#getNestedValue(obj, candidate.key) : ownValue(obj, candidate.key);
|
|
1031
|
+
if (value !== void 0) values.push(value);
|
|
1032
|
+
}
|
|
1033
|
+
return values;
|
|
631
1034
|
}
|
|
632
1035
|
#resolveRequest(key) {
|
|
633
1036
|
const query = this.#req.query ?? {};
|
|
@@ -640,8 +1043,11 @@ var RequestResolver = class {
|
|
|
640
1043
|
...this.#resolveWildcard(cookies, key)
|
|
641
1044
|
];
|
|
642
1045
|
}
|
|
643
|
-
const
|
|
644
|
-
|
|
1046
|
+
const fromQuery = this.#lookup(query, key);
|
|
1047
|
+
if (fromQuery.length > 0) return fromQuery;
|
|
1048
|
+
const fromBody = this.#lookup(body, key);
|
|
1049
|
+
if (fromBody.length > 0) return fromBody;
|
|
1050
|
+
return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
|
|
645
1051
|
}
|
|
646
1052
|
#resolveCookie(key) {
|
|
647
1053
|
const cookies = this.#parseCookies();
|
|
@@ -811,329 +1217,51 @@ var RequestResolver = class {
|
|
|
811
1217
|
this.#cookies = this.#req.cookies;
|
|
812
1218
|
return this.#cookies;
|
|
813
1219
|
}
|
|
814
|
-
|
|
815
|
-
if (!header) {
|
|
816
|
-
this.#cookies = {};
|
|
817
|
-
return this.#cookies;
|
|
818
|
-
}
|
|
819
|
-
const cookies = {};
|
|
820
|
-
for (const pair of header.split(";")) {
|
|
821
|
-
const eqIndex = pair.indexOf("=");
|
|
822
|
-
if (eqIndex === -1) {
|
|
823
|
-
continue;
|
|
824
|
-
}
|
|
825
|
-
const name = pair.substring(0, eqIndex).trim();
|
|
826
|
-
const value = pair.substring(eqIndex + 1).trim();
|
|
827
|
-
setOwn(cookies, name, value);
|
|
828
|
-
}
|
|
829
|
-
this.#cookies = cookies;
|
|
1220
|
+
this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
|
|
830
1221
|
return this.#cookies;
|
|
831
1222
|
}
|
|
832
1223
|
};
|
|
833
1224
|
|
|
834
1225
|
// src/protect/notify.js
|
|
835
|
-
var reported = /* @__PURE__ */ new
|
|
1226
|
+
var reported = /* @__PURE__ */ new WeakMap();
|
|
1227
|
+
var warnings = 0;
|
|
1228
|
+
var MAX_WARNINGS = 20;
|
|
836
1229
|
function notify(fn, arg, label) {
|
|
837
1230
|
if (typeof fn !== "function") return false;
|
|
838
1231
|
try {
|
|
839
1232
|
const result = fn(arg);
|
|
840
1233
|
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
841
1234
|
try {
|
|
842
|
-
result.then(void 0, (err) => warnOnce2(label, err));
|
|
1235
|
+
result.then(void 0, (err) => warnOnce2(fn, label, err));
|
|
843
1236
|
} catch {
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
return true;
|
|
847
|
-
} catch (err) {
|
|
848
|
-
warnOnce2(label, err);
|
|
849
|
-
return false;
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
function warnOnce2(label, err) {
|
|
853
|
-
if (reported.has(label)) return;
|
|
854
|
-
reported.add(label);
|
|
855
|
-
try {
|
|
856
|
-
console.warn(
|
|
857
|
-
`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)}`
|
|
858
|
-
);
|
|
859
|
-
} catch {
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// src/protect/engine/normalizer.js
|
|
864
|
-
var HTML_ENTITIES = {
|
|
865
|
-
"&": "&",
|
|
866
|
-
"<": "<",
|
|
867
|
-
">": ">",
|
|
868
|
-
""": '"',
|
|
869
|
-
"'": "'",
|
|
870
|
-
"'": "'",
|
|
871
|
-
" ": " ",
|
|
872
|
-
"'": "'",
|
|
873
|
-
""": '"',
|
|
874
|
-
"<": "<",
|
|
875
|
-
">": ">",
|
|
876
|
-
"&": "&",
|
|
877
|
-
""": '"',
|
|
878
|
-
"<": "<",
|
|
879
|
-
">": ">",
|
|
880
|
-
"&": "&"
|
|
881
|
-
};
|
|
882
|
-
var MAX_DECODE_ITERATIONS = 5;
|
|
883
|
-
function normalize(value, options = {}) {
|
|
884
|
-
if (typeof value !== "string") {
|
|
885
|
-
return value;
|
|
886
|
-
}
|
|
887
|
-
const opts = {
|
|
888
|
-
urlDecode: true,
|
|
889
|
-
htmlDecode: true,
|
|
890
|
-
sqlComments: true,
|
|
891
|
-
nullBytes: true,
|
|
892
|
-
whitespace: true,
|
|
893
|
-
...options
|
|
894
|
-
};
|
|
895
|
-
let result = value;
|
|
896
|
-
if (opts.nullBytes) {
|
|
897
|
-
result = removeNullBytes(result);
|
|
898
|
-
}
|
|
899
|
-
if (opts.urlDecode) {
|
|
900
|
-
result = urlDecode(result);
|
|
901
|
-
}
|
|
902
|
-
if (opts.htmlDecode) {
|
|
903
|
-
result = htmlEntityDecode(result);
|
|
904
|
-
}
|
|
905
|
-
if (opts.sqlComments) {
|
|
906
|
-
result = removeSqlComments(result);
|
|
907
|
-
}
|
|
908
|
-
if (opts.whitespace) {
|
|
909
|
-
result = normalizeWhitespace(result);
|
|
910
|
-
}
|
|
911
|
-
return result;
|
|
912
|
-
}
|
|
913
|
-
function urlDecode(value) {
|
|
914
|
-
if (typeof value !== "string") {
|
|
915
|
-
return value;
|
|
916
|
-
}
|
|
917
|
-
let result = value;
|
|
918
|
-
let previous = "";
|
|
919
|
-
let iterations = 0;
|
|
920
|
-
while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
|
|
921
|
-
previous = result;
|
|
922
|
-
iterations++;
|
|
923
|
-
try {
|
|
924
|
-
result = decodeURIComponent(result);
|
|
925
|
-
} catch {
|
|
926
|
-
result = safeUrlDecode(result);
|
|
927
|
-
break;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
return result;
|
|
931
|
-
}
|
|
932
|
-
function safeUrlDecode(value) {
|
|
933
|
-
return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
|
|
934
|
-
try {
|
|
935
|
-
return String.fromCharCode(parseInt(hex, 16));
|
|
936
|
-
} catch {
|
|
937
|
-
return match;
|
|
938
|
-
}
|
|
939
|
-
});
|
|
940
|
-
}
|
|
941
|
-
function htmlEntityDecode(value) {
|
|
942
|
-
if (typeof value !== "string") {
|
|
943
|
-
return value;
|
|
944
|
-
}
|
|
945
|
-
let result = value;
|
|
946
|
-
for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
|
|
947
|
-
result = result.split(entity).join(char);
|
|
948
|
-
}
|
|
949
|
-
result = result.replace(/&#(\d+);/g, (match, code) => {
|
|
950
|
-
const num = parseInt(code, 10);
|
|
951
|
-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
952
|
-
});
|
|
953
|
-
result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
|
|
954
|
-
const num = parseInt(hex, 16);
|
|
955
|
-
return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
|
|
956
|
-
});
|
|
957
|
-
return result;
|
|
958
|
-
}
|
|
959
|
-
function removeSqlComments(value) {
|
|
960
|
-
if (typeof value !== "string") {
|
|
961
|
-
return value;
|
|
962
|
-
}
|
|
963
|
-
let result = value;
|
|
964
|
-
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
965
|
-
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
966
|
-
return result;
|
|
967
|
-
}
|
|
968
|
-
function removeNullBytes(value) {
|
|
969
|
-
if (typeof value !== "string") {
|
|
970
|
-
return value;
|
|
971
|
-
}
|
|
972
|
-
let result = value.replace(/\x00/g, "");
|
|
973
|
-
result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
|
|
974
|
-
return result;
|
|
975
|
-
}
|
|
976
|
-
function normalizeWhitespace(value) {
|
|
977
|
-
if (typeof value !== "string") {
|
|
978
|
-
return value;
|
|
979
|
-
}
|
|
980
|
-
let result = value.replace(/[\t\r\n\f\v]+/g, " ");
|
|
981
|
-
result = result.replace(/ {2,}/g, " ");
|
|
982
|
-
return result;
|
|
983
|
-
}
|
|
984
|
-
var MAX_RAW_CHARS = 1024 * 1024;
|
|
985
|
-
var MAX_RAW_NODES = 25e3;
|
|
986
|
-
var MAX_RAW_DEPTH = 200;
|
|
987
|
-
var MAX_RAW_ENTRIES = 2e3;
|
|
988
|
-
function serializeForRawDetection(body) {
|
|
989
|
-
if (body === null || body === void 0) return "";
|
|
990
|
-
if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
|
|
991
|
-
const visited = /* @__PURE__ */ new Set();
|
|
992
|
-
const output = [];
|
|
993
|
-
const stack = [{ kind: "value", value: body, depth: 0, root: true }];
|
|
994
|
-
let chars = 0;
|
|
995
|
-
let nodes = 0;
|
|
996
|
-
const append = (text) => {
|
|
997
|
-
if (chars >= MAX_RAW_CHARS) return;
|
|
998
|
-
const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
|
|
999
|
-
output.push(piece);
|
|
1000
|
-
chars += piece.length;
|
|
1001
|
-
};
|
|
1002
|
-
while (stack.length > 0 && chars < MAX_RAW_CHARS) {
|
|
1003
|
-
const task = stack.pop();
|
|
1004
|
-
if (task.kind === "text") {
|
|
1005
|
-
append(task.text);
|
|
1006
|
-
continue;
|
|
1007
|
-
}
|
|
1008
|
-
if (task.kind === "key") {
|
|
1009
|
-
append(boundedJsonString(task.value));
|
|
1010
|
-
append(":");
|
|
1011
|
-
continue;
|
|
1012
|
-
}
|
|
1013
|
-
nodes++;
|
|
1014
|
-
if (nodes > MAX_RAW_NODES) {
|
|
1015
|
-
append('"[NodeLimit]"');
|
|
1016
|
-
continue;
|
|
1017
|
-
}
|
|
1018
|
-
const value = task.value;
|
|
1019
|
-
if (value === null || value === void 0) {
|
|
1020
|
-
append(task.root ? "" : "null");
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
if (typeof value === "string") {
|
|
1024
|
-
append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
|
|
1025
|
-
continue;
|
|
1026
|
-
}
|
|
1027
|
-
if (typeof value !== "object") {
|
|
1028
|
-
append(String(value).slice(0, MAX_RAW_CHARS));
|
|
1029
|
-
continue;
|
|
1030
|
-
}
|
|
1031
|
-
if (visited.has(value)) {
|
|
1032
|
-
append("[Circular]");
|
|
1033
|
-
continue;
|
|
1034
|
-
}
|
|
1035
|
-
if (task.depth >= MAX_RAW_DEPTH) {
|
|
1036
|
-
append('"[DepthLimit]"');
|
|
1037
|
-
continue;
|
|
1038
|
-
}
|
|
1039
|
-
visited.add(value);
|
|
1040
|
-
if (Array.isArray(value)) {
|
|
1041
|
-
const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
|
|
1042
|
-
const items = [];
|
|
1043
|
-
for (let i = 0; i < length2; i++) {
|
|
1044
|
-
if (i > 0) items.push({ kind: "text", text: "," });
|
|
1045
|
-
items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
|
|
1046
|
-
}
|
|
1047
|
-
if (value.length > length2) {
|
|
1048
|
-
if (length2 > 0) items.push({ kind: "text", text: "," });
|
|
1049
|
-
items.push({ kind: "text", text: '"[EntryLimit]"' });
|
|
1050
|
-
}
|
|
1051
|
-
stack.push({ kind: "text", text: "]" });
|
|
1052
|
-
for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
|
|
1053
|
-
stack.push({ kind: "text", text: "[" });
|
|
1054
|
-
continue;
|
|
1055
|
-
}
|
|
1056
|
-
let keys;
|
|
1057
|
-
try {
|
|
1058
|
-
keys = Object.getOwnPropertyNames(value);
|
|
1059
|
-
} catch {
|
|
1060
|
-
append('"[Unreadable]"');
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
const length = Math.min(keys.length, MAX_RAW_ENTRIES);
|
|
1064
|
-
const entries = [];
|
|
1065
|
-
for (let i = 0; i < length; i++) {
|
|
1066
|
-
const key = keys[i];
|
|
1067
|
-
if (i > 0) entries.push({ kind: "text", text: "," });
|
|
1068
|
-
entries.push({ kind: "key", value: key });
|
|
1069
|
-
entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
|
|
1070
|
-
}
|
|
1071
|
-
if (keys.length > length) {
|
|
1072
|
-
if (length > 0) entries.push({ kind: "text", text: "," });
|
|
1073
|
-
entries.push({ kind: "text", text: '"[EntryLimit]":null' });
|
|
1074
|
-
}
|
|
1075
|
-
stack.push({ kind: "text", text: "}" });
|
|
1076
|
-
for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
|
|
1077
|
-
stack.push({ kind: "text", text: "{" });
|
|
1078
|
-
}
|
|
1079
|
-
return output.join("");
|
|
1080
|
-
}
|
|
1081
|
-
function boundedJsonString(value) {
|
|
1082
|
-
return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
|
|
1083
|
-
}
|
|
1084
|
-
function readOwnValue(value, key) {
|
|
1085
|
-
try {
|
|
1086
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1087
|
-
return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
|
|
1088
|
-
} catch {
|
|
1089
|
-
return "[Unreadable]";
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
|
|
1093
|
-
function requestField(req, key) {
|
|
1094
|
-
if (req === null || typeof req !== "object") return void 0;
|
|
1095
|
-
if (Object.hasOwn(req, key)) return req[key];
|
|
1096
|
-
if (!INHERITED_ACCESSORS.has(key)) return void 0;
|
|
1097
|
-
let holder = Object.getPrototypeOf(req);
|
|
1098
|
-
while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
|
|
1099
|
-
if (holder === null || holder === Object.prototype) return void 0;
|
|
1100
|
-
const descriptor = Object.getOwnPropertyDescriptor(holder, key);
|
|
1101
|
-
if (typeof descriptor?.get !== "function") return void 0;
|
|
1102
|
-
return req[key];
|
|
1103
|
-
}
|
|
1104
|
-
function normalizeRequest(req, options = {}) {
|
|
1105
|
-
const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
|
|
1106
|
-
const body = requestField(req, "body");
|
|
1107
|
-
const url = requestField(req, "url");
|
|
1108
|
-
const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
|
|
1109
|
-
return {
|
|
1110
|
-
query: normalizeObject(requestField(req, "query") || {}, options),
|
|
1111
|
-
body: normalizeObject(body || {}, options),
|
|
1112
|
-
headers: normalizeObject(requestField(req, "headers") || {}, options),
|
|
1113
|
-
url: normalize(url || "", options),
|
|
1114
|
-
originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
|
|
1115
|
-
_rawBody: rawBody
|
|
1116
|
-
};
|
|
1117
|
-
}
|
|
1118
|
-
var MAX_NORMALIZE_DEPTH = 200;
|
|
1119
|
-
function normalizeObject(value, options = {}, depth = 0) {
|
|
1120
|
-
if (typeof value === "string") {
|
|
1121
|
-
return normalize(value, options);
|
|
1122
|
-
}
|
|
1123
|
-
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
1124
|
-
return value;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return true;
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
warnOnce2(fn, label, err);
|
|
1242
|
+
return false;
|
|
1125
1243
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1244
|
+
}
|
|
1245
|
+
function warnOnce2(fn, label, err) {
|
|
1246
|
+
let labels = reported.get(fn);
|
|
1247
|
+
if (labels?.has(label)) return;
|
|
1248
|
+
if (!labels) {
|
|
1249
|
+
labels = /* @__PURE__ */ new Set();
|
|
1250
|
+
reported.set(fn, labels);
|
|
1128
1251
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1252
|
+
labels.add(label);
|
|
1253
|
+
if (warnings > MAX_WARNINGS) return;
|
|
1254
|
+
warnings++;
|
|
1255
|
+
try {
|
|
1256
|
+
if (warnings > MAX_WARNINGS) {
|
|
1257
|
+
console.warn("Patchstack: further failing callbacks passed to createProtection are not reported in this process.");
|
|
1258
|
+
return;
|
|
1133
1259
|
}
|
|
1134
|
-
|
|
1260
|
+
console.warn(
|
|
1261
|
+
`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)}`
|
|
1262
|
+
);
|
|
1263
|
+
} catch {
|
|
1135
1264
|
}
|
|
1136
|
-
return value;
|
|
1137
1265
|
}
|
|
1138
1266
|
|
|
1139
1267
|
// src/protect/engine/engine.js
|
|
@@ -1521,17 +1649,21 @@ function arrayKeyValue(value, matchObj) {
|
|
|
1521
1649
|
}
|
|
1522
1650
|
var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
|
|
1523
1651
|
function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
1524
|
-
const
|
|
1652
|
+
const leaves = [];
|
|
1525
1653
|
const stack = [[root, 0]];
|
|
1526
1654
|
let visited = 0;
|
|
1655
|
+
let truncated = false;
|
|
1527
1656
|
while (stack.length) {
|
|
1528
1657
|
const [node, depth] = stack.pop();
|
|
1529
1658
|
if (node === null || node === void 0) continue;
|
|
1530
1659
|
if (typeof node !== "object") {
|
|
1531
|
-
|
|
1660
|
+
leaves.push(node);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (depth >= maxDepth || visited >= nodeCap) {
|
|
1664
|
+
truncated = true;
|
|
1532
1665
|
continue;
|
|
1533
1666
|
}
|
|
1534
|
-
if (depth >= maxDepth || visited >= nodeCap) continue;
|
|
1535
1667
|
visited++;
|
|
1536
1668
|
if (Array.isArray(node)) {
|
|
1537
1669
|
for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
|
|
@@ -1539,7 +1671,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
|
1539
1671
|
for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
|
|
1540
1672
|
}
|
|
1541
1673
|
}
|
|
1542
|
-
return
|
|
1674
|
+
return { leaves, truncated };
|
|
1675
|
+
}
|
|
1676
|
+
function skipsOf(resolver) {
|
|
1677
|
+
const skips = resolver?.skips;
|
|
1678
|
+
return skips && skips.length > 0 ? { skips } : {};
|
|
1679
|
+
}
|
|
1680
|
+
function serialisedValue(value) {
|
|
1681
|
+
try {
|
|
1682
|
+
return JSON.stringify(value);
|
|
1683
|
+
} catch {
|
|
1684
|
+
return void 0;
|
|
1685
|
+
}
|
|
1543
1686
|
}
|
|
1544
1687
|
var warnedKeys = /* @__PURE__ */ new Set();
|
|
1545
1688
|
function warnOnce3(key, message) {
|
|
@@ -1557,12 +1700,22 @@ function warnUnsupportedMatchType(type) {
|
|
|
1557
1700
|
`[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
|
|
1558
1701
|
);
|
|
1559
1702
|
}
|
|
1703
|
+
var SPECIAL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ws", "wss", "ftp", "file"]);
|
|
1704
|
+
function trimControls(text) {
|
|
1705
|
+
let start = 0;
|
|
1706
|
+
let end = text.length;
|
|
1707
|
+
while (start < end && text.charCodeAt(start) <= 32) start++;
|
|
1708
|
+
while (end > start && text.charCodeAt(end - 1) <= 32) end--;
|
|
1709
|
+
return text.slice(start, end);
|
|
1710
|
+
}
|
|
1560
1711
|
function hostFromValue(value) {
|
|
1561
|
-
const raw = String(value ?? "").trim();
|
|
1712
|
+
const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
|
|
1562
1713
|
if (raw === "") return "";
|
|
1563
|
-
|
|
1714
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(raw)?.[1]?.toLowerCase();
|
|
1715
|
+
const relative = /^[\\/]{2}/.test(raw);
|
|
1716
|
+
if (relative || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || scheme !== void 0 && SPECIAL_SCHEMES.has(scheme)) {
|
|
1564
1717
|
try {
|
|
1565
|
-
return new URL(
|
|
1718
|
+
return new URL(relative ? `http:${raw}` : raw).hostname;
|
|
1566
1719
|
} catch {
|
|
1567
1720
|
return raw;
|
|
1568
1721
|
}
|
|
@@ -1590,10 +1743,18 @@ function isInternalHost(hostname) {
|
|
|
1590
1743
|
const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
1591
1744
|
if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
|
|
1592
1745
|
if ((g[0] & 65472) === 65152) return true;
|
|
1746
|
+
if ((g[0] & 65472) === 65216) return true;
|
|
1593
1747
|
if ((g[0] & 65024) === 64512) return true;
|
|
1748
|
+
if ((g[0] & 65280) === 65280) return true;
|
|
1749
|
+
const low32 = (g[6] << 16 >>> 0 | g[7]) >>> 0;
|
|
1594
1750
|
if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
|
|
1595
|
-
return isPrivateV4Int(
|
|
1751
|
+
return isPrivateV4Int(low32);
|
|
1752
|
+
}
|
|
1753
|
+
if (g[0] === 100 && g[1] === 65435 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
|
|
1754
|
+
return isPrivateV4Int(low32);
|
|
1596
1755
|
}
|
|
1756
|
+
if (g[0] === 100 && g[1] === 65435 && g[2] === 1) return true;
|
|
1757
|
+
if (g[0] === 8194) return isPrivateV4Int((g[1] << 16 >>> 0 | g[2]) >>> 0);
|
|
1597
1758
|
return false;
|
|
1598
1759
|
}
|
|
1599
1760
|
const v4 = parseIPv4ToInt(host);
|
|
@@ -1603,6 +1764,10 @@ function isInternalHost(hostname) {
|
|
|
1603
1764
|
function isPrivateV4Int(n) {
|
|
1604
1765
|
const a = n >>> 24 & 255;
|
|
1605
1766
|
const b = n >>> 16 & 255;
|
|
1767
|
+
const c = n >>> 8 & 255;
|
|
1768
|
+
if (a >= 224) return true;
|
|
1769
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
1770
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
1606
1771
|
if (a === 127 || a === 10 || a === 0) return true;
|
|
1607
1772
|
if (a === 169 && b === 254) return true;
|
|
1608
1773
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
@@ -1951,8 +2116,12 @@ var RuleEngine = class {
|
|
|
1951
2116
|
let normalizedReq;
|
|
1952
2117
|
let resolver;
|
|
1953
2118
|
try {
|
|
1954
|
-
|
|
2119
|
+
let limited = false;
|
|
2120
|
+
normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
|
|
2121
|
+
limited = true;
|
|
2122
|
+
} }) };
|
|
1955
2123
|
resolver = new RequestResolver(normalizedReq);
|
|
2124
|
+
if (limited) resolver.noteSkip("container-cap");
|
|
1956
2125
|
} catch (err) {
|
|
1957
2126
|
this.#reportError(err);
|
|
1958
2127
|
return { blocked: false, rule: null, message: null };
|
|
@@ -1978,14 +2147,15 @@ var RuleEngine = class {
|
|
|
1978
2147
|
// that re-read the request instead would be reading it a second time: a getter, a stream or
|
|
1979
2148
|
// anything else that answers once can give a different value, and evidence that disagrees
|
|
1980
2149
|
// with the match it belongs to is worse than none.
|
|
1981
|
-
resolver
|
|
2150
|
+
resolver,
|
|
2151
|
+
...skipsOf(resolver)
|
|
1982
2152
|
};
|
|
1983
2153
|
}
|
|
1984
2154
|
} catch (err) {
|
|
1985
2155
|
this.#reportError(err);
|
|
1986
2156
|
}
|
|
1987
2157
|
}
|
|
1988
|
-
return { blocked: false, rule: null, message: null };
|
|
2158
|
+
return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
|
|
1989
2159
|
}
|
|
1990
2160
|
#evaluateRule(conditions, resolver) {
|
|
1991
2161
|
const inclusiveConditions = conditions.filter((c) => c.inclusive);
|
|
@@ -2041,9 +2211,15 @@ var RuleEngine = class {
|
|
|
2041
2211
|
if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
|
|
2042
2212
|
if (matchValue(match.type, value, match.value, match)) return true;
|
|
2043
2213
|
} else {
|
|
2044
|
-
|
|
2214
|
+
const { leaves, truncated } = collectLeafValues(value);
|
|
2215
|
+
for (const leaf of leaves) {
|
|
2045
2216
|
if (matchValue(match.type, leaf, match.value, match)) return true;
|
|
2046
2217
|
}
|
|
2218
|
+
if (truncated) {
|
|
2219
|
+
resolver.noteSkip("container-cap");
|
|
2220
|
+
const text = serialisedValue(value);
|
|
2221
|
+
if (text !== void 0 && matchValue(match.type, text, match.value, match)) return true;
|
|
2222
|
+
}
|
|
2047
2223
|
}
|
|
2048
2224
|
continue;
|
|
2049
2225
|
}
|
|
@@ -2430,6 +2606,9 @@ function conditionShapeProblem(condition) {
|
|
|
2430
2606
|
if (nested && !named) {
|
|
2431
2607
|
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
2432
2608
|
}
|
|
2609
|
+
if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
|
|
2610
|
+
return "inclusive must be true or false";
|
|
2611
|
+
}
|
|
2433
2612
|
if (named && nested) {
|
|
2434
2613
|
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
2435
2614
|
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
@@ -3372,7 +3551,7 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
3372
3551
|
// that guessed differently would attribute one request to two addresses.
|
|
3373
3552
|
ip: client.ip ?? "",
|
|
3374
3553
|
_clientIp: client,
|
|
3375
|
-
cookies:
|
|
3554
|
+
cookies: parseCookieHeader(headers.cookie),
|
|
3376
3555
|
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
|
|
3377
3556
|
// drops, so prototype-pollution rules on `raw` are robust.
|
|
3378
3557
|
_rawBody: rawBody,
|
|
@@ -3526,22 +3705,53 @@ function decodeExtendedValue(value) {
|
|
|
3526
3705
|
return match[2];
|
|
3527
3706
|
}
|
|
3528
3707
|
}
|
|
3529
|
-
function parseCookies(header) {
|
|
3530
|
-
const cookies = {};
|
|
3531
|
-
if (!header) {
|
|
3532
|
-
return cookies;
|
|
3533
|
-
}
|
|
3534
|
-
for (const pair of header.split(";")) {
|
|
3535
|
-
const idx = pair.indexOf("=");
|
|
3536
|
-
if (idx === -1) {
|
|
3537
|
-
continue;
|
|
3538
|
-
}
|
|
3539
|
-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
3540
|
-
}
|
|
3541
|
-
return cookies;
|
|
3542
|
-
}
|
|
3543
3708
|
|
|
3544
3709
|
// src/protect/engine/node.js
|
|
3710
|
+
function readBodyPrefix(req, maxBytes, done) {
|
|
3711
|
+
const chunks = [];
|
|
3712
|
+
let retained = 0;
|
|
3713
|
+
let size = 0;
|
|
3714
|
+
let failed = false;
|
|
3715
|
+
let finished = false;
|
|
3716
|
+
const finish = (error, read) => {
|
|
3717
|
+
if (finished) return;
|
|
3718
|
+
finished = true;
|
|
3719
|
+
done(error, read);
|
|
3720
|
+
};
|
|
3721
|
+
req.on("data", (chunk) => {
|
|
3722
|
+
if (failed) return;
|
|
3723
|
+
try {
|
|
3724
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, req.readableEncoding || "utf8") : chunk;
|
|
3725
|
+
size += bytes.length;
|
|
3726
|
+
const take = Math.min(bytes.length, maxBytes - retained);
|
|
3727
|
+
if (take <= 0) return;
|
|
3728
|
+
chunks.push(take === bytes.length ? bytes : bytes.subarray(0, take));
|
|
3729
|
+
retained += take;
|
|
3730
|
+
} catch {
|
|
3731
|
+
failed = true;
|
|
3732
|
+
chunks.length = 0;
|
|
3733
|
+
}
|
|
3734
|
+
});
|
|
3735
|
+
req.on("error", (error) => finish(error));
|
|
3736
|
+
req.on("end", () => {
|
|
3737
|
+
if (failed) {
|
|
3738
|
+
finish(null, { text: "", overflow: false, size, failed: true });
|
|
3739
|
+
return;
|
|
3740
|
+
}
|
|
3741
|
+
const prefix = Buffer.concat(chunks);
|
|
3742
|
+
const overflow = size > maxBytes;
|
|
3743
|
+
const text = (overflow ? prefix.subarray(0, completeUtf8Length(prefix)) : prefix).toString("utf8");
|
|
3744
|
+
finish(null, { text, overflow, size, failed: false });
|
|
3745
|
+
});
|
|
3746
|
+
}
|
|
3747
|
+
function completeUtf8Length(bytes) {
|
|
3748
|
+
let start = bytes.length - 1;
|
|
3749
|
+
while (start >= 0 && bytes.length - start <= 3 && (bytes[start] & 192) === 128) start--;
|
|
3750
|
+
if (start < 0) return bytes.length;
|
|
3751
|
+
const lead = bytes[start];
|
|
3752
|
+
const needed = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
|
|
3753
|
+
return bytes.length - start < needed ? start : bytes.length;
|
|
3754
|
+
}
|
|
3545
3755
|
function fromNodeRequest(req, rawBody = "", options = {}) {
|
|
3546
3756
|
const method = (req.method || "GET").toUpperCase();
|
|
3547
3757
|
const headers = {};
|
|
@@ -3587,29 +3797,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
|
|
|
3587
3797
|
headers,
|
|
3588
3798
|
ip: client.ip ?? "",
|
|
3589
3799
|
_clientIp: client,
|
|
3590
|
-
cookies:
|
|
3800
|
+
cookies: parseCookieHeader(headers.cookie),
|
|
3591
3801
|
// Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
|
|
3592
3802
|
_rawBody: rawBody
|
|
3593
3803
|
};
|
|
3594
3804
|
}
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3805
|
+
|
|
3806
|
+
// src/protect/egress.js
|
|
3807
|
+
var REGISTRY = /* @__PURE__ */ Symbol.for("patchstack.connect.egress-guard");
|
|
3808
|
+
function egressRegistry() {
|
|
3809
|
+
const existing = globalThis[REGISTRY];
|
|
3810
|
+
if (existing && existing.screens instanceof Set && existing.surfaces instanceof Map) return existing;
|
|
3811
|
+
const created = { screens: /* @__PURE__ */ new Set(), surfaces: /* @__PURE__ */ new Map() };
|
|
3812
|
+
Object.defineProperty(globalThis, REGISTRY, { value: created, configurable: true, writable: true });
|
|
3813
|
+
return created;
|
|
3814
|
+
}
|
|
3815
|
+
var refusal = (host) => new Error(`Patchstack blocked an outbound request to a disallowed address: ${host}`);
|
|
3816
|
+
function readableDestination(input, init) {
|
|
3817
|
+
try {
|
|
3818
|
+
const raw = typeof input === "string" || input instanceof URL ? String(input) : input?.url ?? input?.href;
|
|
3819
|
+
if (typeof raw !== "string" && !(raw instanceof URL)) return null;
|
|
3820
|
+
const url = new URL(String(raw)).href;
|
|
3821
|
+
const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
|
|
3822
|
+
return { url, method };
|
|
3823
|
+
} catch {
|
|
3824
|
+
return null;
|
|
3599
3825
|
}
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
3826
|
+
}
|
|
3827
|
+
function anyRefuses(screens, url, host, method) {
|
|
3828
|
+
let refused = false;
|
|
3829
|
+
for (const screen of screens) {
|
|
3830
|
+
if (screen.block(url, host, method)) refused = true;
|
|
3606
3831
|
}
|
|
3607
|
-
return
|
|
3832
|
+
return refused;
|
|
3608
3833
|
}
|
|
3609
|
-
|
|
3610
|
-
// src/protect/egress.js
|
|
3611
3834
|
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
3612
|
-
const restores = [];
|
|
3613
3835
|
if (typeof shouldBlock !== "function") return () => {
|
|
3614
3836
|
};
|
|
3615
3837
|
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
@@ -3654,6 +3876,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3654
3876
|
resolve(false);
|
|
3655
3877
|
}
|
|
3656
3878
|
});
|
|
3879
|
+
const registry = egressRegistry();
|
|
3880
|
+
const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
|
|
3881
|
+
registry.screens.add(own);
|
|
3657
3882
|
const originalFetch = globalThis.fetch;
|
|
3658
3883
|
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
3659
3884
|
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
@@ -3726,15 +3951,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3726
3951
|
} catch {
|
|
3727
3952
|
host = null;
|
|
3728
3953
|
}
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3954
|
+
const screens = [...registry.screens];
|
|
3955
|
+
if (anyRefuses(screens, u, host, method)) throw refusal(host ?? u);
|
|
3956
|
+
const resolved = await Promise.all(screens.map((each) => each.prescreen(u, host, method)));
|
|
3957
|
+
if (resolved.includes(true)) throw refusal(host ?? u);
|
|
3732
3958
|
};
|
|
3733
3959
|
const guarded = async (input, init) => {
|
|
3734
3960
|
let cur;
|
|
3735
3961
|
try {
|
|
3736
3962
|
cur = new Request(input, { ...init || {}, redirect: "manual" });
|
|
3737
3963
|
} catch {
|
|
3964
|
+
const destination = readableDestination(input, init);
|
|
3965
|
+
if (destination) await screenUrl(destination.url, destination.method);
|
|
3966
|
+
else for (const each of registry.screens) each.skip("unrecognised-request", {});
|
|
3738
3967
|
return originalFetch(input, init);
|
|
3739
3968
|
}
|
|
3740
3969
|
const callerRedirect = init && init.redirect || input && input.redirect || "follow";
|
|
@@ -3745,13 +3974,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3745
3974
|
const headers = new Headers(cur.headers);
|
|
3746
3975
|
const signal = cur.signal;
|
|
3747
3976
|
const replay = captureReplayBody(cur);
|
|
3977
|
+
const { body: _callerBody, ...callerOptions } = init || {};
|
|
3748
3978
|
let body;
|
|
3749
3979
|
for (let hop = 0; ; hop++) {
|
|
3750
3980
|
let resp;
|
|
3751
3981
|
try {
|
|
3752
|
-
resp = await originalFetch(
|
|
3753
|
-
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
3754
|
-
);
|
|
3982
|
+
resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
|
|
3755
3983
|
} catch (error) {
|
|
3756
3984
|
replay.cancel();
|
|
3757
3985
|
throw error;
|
|
@@ -3759,7 +3987,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3759
3987
|
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
3760
3988
|
if (!location) {
|
|
3761
3989
|
replay.cancel();
|
|
3762
|
-
return resp;
|
|
3990
|
+
return hop === 0 ? resp : markRedirected(resp);
|
|
3763
3991
|
}
|
|
3764
3992
|
await discardResponseBody(resp);
|
|
3765
3993
|
if (hop >= MAX_REDIRECTS) {
|
|
@@ -3792,8 +4020,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3792
4020
|
};
|
|
3793
4021
|
guarded.__patchstackGuarded = true;
|
|
3794
4022
|
globalThis.fetch = guarded;
|
|
3795
|
-
|
|
3796
|
-
|
|
4023
|
+
registry.surfaces.set(guarded, {
|
|
4024
|
+
release() {
|
|
4025
|
+
if (globalThis.fetch !== guarded) return false;
|
|
4026
|
+
globalThis.fetch = originalFetch;
|
|
4027
|
+
return true;
|
|
4028
|
+
}
|
|
3797
4029
|
});
|
|
3798
4030
|
}
|
|
3799
4031
|
let syncBuiltins = () => {
|
|
@@ -3807,12 +4039,16 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3807
4039
|
for (const moduleName of ["node:http", "node:https"]) {
|
|
3808
4040
|
try {
|
|
3809
4041
|
const mod = await import(moduleName);
|
|
3810
|
-
|
|
3811
|
-
|
|
4042
|
+
if (registry.surfaces.has(moduleName)) continue;
|
|
4043
|
+
const release = patchHttpModule(mod.default ?? mod, registry);
|
|
4044
|
+
if (release) {
|
|
3812
4045
|
patchedAny = true;
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
4046
|
+
registry.surfaces.set(moduleName, {
|
|
4047
|
+
release() {
|
|
4048
|
+
if (!release()) return false;
|
|
4049
|
+
syncBuiltins();
|
|
4050
|
+
return true;
|
|
4051
|
+
}
|
|
3816
4052
|
});
|
|
3817
4053
|
}
|
|
3818
4054
|
} catch {
|
|
@@ -3820,27 +4056,41 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
3820
4056
|
}
|
|
3821
4057
|
if (patchedAny) syncBuiltins();
|
|
3822
4058
|
return () => {
|
|
3823
|
-
|
|
4059
|
+
registry.screens.delete(own);
|
|
4060
|
+
if (registry.screens.size > 0) return;
|
|
4061
|
+
for (const [name, surface] of registry.surfaces) {
|
|
3824
4062
|
try {
|
|
3825
|
-
|
|
4063
|
+
if (surface.release()) registry.surfaces.delete(name);
|
|
3826
4064
|
} catch {
|
|
3827
4065
|
}
|
|
3828
4066
|
}
|
|
3829
4067
|
};
|
|
3830
4068
|
}
|
|
3831
|
-
function
|
|
4069
|
+
function markRedirected(response) {
|
|
4070
|
+
try {
|
|
4071
|
+
Object.defineProperty(response, "redirected", { value: true, configurable: true });
|
|
4072
|
+
} catch {
|
|
4073
|
+
}
|
|
4074
|
+
return response;
|
|
4075
|
+
}
|
|
4076
|
+
function patchHttpModule(http, registry) {
|
|
3832
4077
|
if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
|
|
3833
4078
|
const originalRequest = http.request;
|
|
3834
4079
|
const originalGet = http.get;
|
|
3835
4080
|
const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
|
|
3836
4081
|
const guardArgs = (args) => {
|
|
3837
4082
|
const target = extractHttpTarget(args);
|
|
3838
|
-
if (target
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
4083
|
+
if (!target) return args;
|
|
4084
|
+
const screens = [...registry.screens];
|
|
4085
|
+
if (anyRefuses(screens, target.url, target.host, target.method)) throw refusal(target.host ?? target.url);
|
|
4086
|
+
const resolving = target.host ? screens.filter((each) => each.dns && each.dns.isIP(target.host) === 0 && !each.dns.isExempt(target.host)) : [];
|
|
4087
|
+
if (resolving.length > 0) {
|
|
4088
|
+
const block = (url, host, method) => anyRefuses(resolving, url, host, method);
|
|
4089
|
+
const skip = (reason, detail) => {
|
|
4090
|
+
for (const each of resolving) each.skip(reason, detail);
|
|
4091
|
+
};
|
|
3842
4092
|
try {
|
|
3843
|
-
return withScreeningLookup(args, target, block,
|
|
4093
|
+
return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
|
|
3844
4094
|
} catch {
|
|
3845
4095
|
}
|
|
3846
4096
|
}
|
|
@@ -3868,10 +4118,14 @@ function patchHttpModule(http, block, screen, skip) {
|
|
|
3868
4118
|
}
|
|
3869
4119
|
http.__patchstackGuarded = true;
|
|
3870
4120
|
return () => {
|
|
3871
|
-
if (http.request
|
|
3872
|
-
if (guardedGet && http.get
|
|
3873
|
-
if (GuardedClientRequest && http.ClientRequest
|
|
4121
|
+
if (http.request !== guardedRequest) return false;
|
|
4122
|
+
if (guardedGet && http.get !== guardedGet) return false;
|
|
4123
|
+
if (GuardedClientRequest && http.ClientRequest !== GuardedClientRequest) return false;
|
|
4124
|
+
http.request = originalRequest;
|
|
4125
|
+
if (guardedGet) http.get = originalGet;
|
|
4126
|
+
if (GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
|
|
3874
4127
|
delete http.__patchstackGuarded;
|
|
4128
|
+
return true;
|
|
3875
4129
|
};
|
|
3876
4130
|
}
|
|
3877
4131
|
function extractHttpTarget(args) {
|
|
@@ -4140,7 +4394,10 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
4140
4394
|
// the constraint name, the column and the offending value discloses the schema anyway.
|
|
4141
4395
|
action: "block",
|
|
4142
4396
|
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
4143
|
-
|
|
4397
|
+
// Case-sensitive, because every signature is emitted in one fixed case. `ER_` is the one that needs
|
|
4398
|
+
// it: MySQL error codes are whole upper-case words (`ER_DUP_ENTRY`), and matched without case or a
|
|
4399
|
+
// word boundary it finds the end of ordinary identifiers such as a `cover__background` class name.
|
|
4400
|
+
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)/" } }]
|
|
4144
4401
|
},
|
|
4145
4402
|
{
|
|
4146
4403
|
id: "resp-exception-trace",
|
|
@@ -4770,7 +5027,7 @@ function makeRefreshHandler(tick, secret) {
|
|
|
4770
5027
|
return async (request) => {
|
|
4771
5028
|
if (!secret) return new Response("not found", { status: 404 });
|
|
4772
5029
|
const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
|
|
4773
|
-
if (provided
|
|
5030
|
+
if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
|
|
4774
5031
|
let refreshed = true;
|
|
4775
5032
|
try {
|
|
4776
5033
|
const status = await runOnce();
|
|
@@ -4781,6 +5038,33 @@ function makeRefreshHandler(tick, secret) {
|
|
|
4781
5038
|
return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
|
|
4782
5039
|
};
|
|
4783
5040
|
}
|
|
5041
|
+
async function sameSecret(provided, secret) {
|
|
5042
|
+
if (typeof provided !== "string" || typeof secret !== "string") return false;
|
|
5043
|
+
const subtle = globalThis.crypto?.subtle;
|
|
5044
|
+
if (subtle && typeof TextEncoder === "function") {
|
|
5045
|
+
try {
|
|
5046
|
+
const encoder2 = new TextEncoder();
|
|
5047
|
+
const [a, b] = await Promise.all([
|
|
5048
|
+
subtle.digest("SHA-256", encoder2.encode(provided)),
|
|
5049
|
+
subtle.digest("SHA-256", encoder2.encode(secret))
|
|
5050
|
+
]);
|
|
5051
|
+
return equalBytes(new Uint8Array(a), new Uint8Array(b));
|
|
5052
|
+
} catch {
|
|
5053
|
+
}
|
|
5054
|
+
}
|
|
5055
|
+
let diff = provided.length ^ secret.length;
|
|
5056
|
+
const length = Math.max(provided.length, secret.length);
|
|
5057
|
+
for (let i = 0; i < length; i++) {
|
|
5058
|
+
diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
|
|
5059
|
+
}
|
|
5060
|
+
return diff === 0;
|
|
5061
|
+
}
|
|
5062
|
+
function equalBytes(a, b) {
|
|
5063
|
+
if (a.length !== b.length) return false;
|
|
5064
|
+
let diff = 0;
|
|
5065
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
5066
|
+
return diff === 0;
|
|
5067
|
+
}
|
|
4784
5068
|
|
|
4785
5069
|
// src/protect/detections.js
|
|
4786
5070
|
var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
|
|
@@ -5514,9 +5798,180 @@ function hardensWithoutBody(rule) {
|
|
|
5514
5798
|
if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
|
|
5515
5799
|
const { parameters, complete } = readRuleParameters(rule);
|
|
5516
5800
|
if (!complete) return false;
|
|
5517
|
-
return parameters.every(
|
|
5518
|
-
|
|
5801
|
+
return parameters.every(bodyIndependent);
|
|
5802
|
+
}
|
|
5803
|
+
function readsOnlyResponseHeaders(rule) {
|
|
5804
|
+
if (!rule) return false;
|
|
5805
|
+
const { parameters, complete } = readRuleParameters(rule);
|
|
5806
|
+
if (!complete || !parameters.some(isHeaderParameter)) return false;
|
|
5807
|
+
return parameters.every(bodyIndependent);
|
|
5808
|
+
}
|
|
5809
|
+
function isHeaderParameter(parameter) {
|
|
5810
|
+
return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
|
|
5811
|
+
}
|
|
5812
|
+
function bodyIndependent(parameter) {
|
|
5813
|
+
return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
|
|
5814
|
+
}
|
|
5815
|
+
|
|
5816
|
+
// src/protect/supabase-guard.js
|
|
5817
|
+
var GUARD_PATH = "/_patchstack/guard";
|
|
5818
|
+
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5819
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
5820
|
+
var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5821
|
+
"connection",
|
|
5822
|
+
"cookie",
|
|
5823
|
+
"cookie2",
|
|
5824
|
+
"forwarded",
|
|
5825
|
+
"host",
|
|
5826
|
+
"keep-alive",
|
|
5827
|
+
"proxy-authenticate",
|
|
5828
|
+
"proxy-authorization",
|
|
5829
|
+
"te",
|
|
5830
|
+
"trailer",
|
|
5831
|
+
"transfer-encoding",
|
|
5832
|
+
"upgrade",
|
|
5833
|
+
"x-forwarded-for",
|
|
5834
|
+
"x-forwarded-host",
|
|
5835
|
+
"x-forwarded-port",
|
|
5836
|
+
"x-forwarded-proto",
|
|
5837
|
+
"x-ps-target",
|
|
5838
|
+
"x-real-ip"
|
|
5839
|
+
]);
|
|
5840
|
+
var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5841
|
+
"connection",
|
|
5842
|
+
"content-encoding",
|
|
5843
|
+
"content-length",
|
|
5844
|
+
"keep-alive",
|
|
5845
|
+
"proxy-authenticate",
|
|
5846
|
+
"proxy-authorization",
|
|
5847
|
+
"set-cookie",
|
|
5848
|
+
"set-cookie2",
|
|
5849
|
+
"te",
|
|
5850
|
+
"trailer",
|
|
5851
|
+
"transfer-encoding",
|
|
5852
|
+
"upgrade"
|
|
5853
|
+
]);
|
|
5854
|
+
var BodyTooLargeError = class extends Error {
|
|
5855
|
+
};
|
|
5856
|
+
async function boundedBody(request, maxBytes) {
|
|
5857
|
+
const stated = Number(request.headers.get("content-length"));
|
|
5858
|
+
if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
|
|
5859
|
+
if (!request.body) return void 0;
|
|
5860
|
+
const reader = request.body.getReader();
|
|
5861
|
+
const chunks = [];
|
|
5862
|
+
let total = 0;
|
|
5863
|
+
try {
|
|
5864
|
+
for (; ; ) {
|
|
5865
|
+
const { done, value } = await reader.read();
|
|
5866
|
+
if (done) break;
|
|
5867
|
+
total += value.byteLength;
|
|
5868
|
+
if (total > maxBytes) {
|
|
5869
|
+
void reader.cancel().catch(() => {
|
|
5870
|
+
});
|
|
5871
|
+
throw new BodyTooLargeError();
|
|
5872
|
+
}
|
|
5873
|
+
chunks.push(value);
|
|
5874
|
+
}
|
|
5875
|
+
} finally {
|
|
5876
|
+
reader.releaseLock();
|
|
5877
|
+
}
|
|
5878
|
+
const body = new Uint8Array(total);
|
|
5879
|
+
let offset = 0;
|
|
5880
|
+
for (const chunk of chunks) {
|
|
5881
|
+
body.set(chunk, offset);
|
|
5882
|
+
offset += chunk.byteLength;
|
|
5883
|
+
}
|
|
5884
|
+
return body;
|
|
5885
|
+
}
|
|
5886
|
+
function requestHeaders(input) {
|
|
5887
|
+
const output = new Headers();
|
|
5888
|
+
const connectionTokens = new Set(
|
|
5889
|
+
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5519
5890
|
);
|
|
5891
|
+
input.forEach((value, key) => {
|
|
5892
|
+
const lower = key.toLowerCase();
|
|
5893
|
+
if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5894
|
+
});
|
|
5895
|
+
return output;
|
|
5896
|
+
}
|
|
5897
|
+
function responseHeaders(input) {
|
|
5898
|
+
const output = new Headers();
|
|
5899
|
+
const connectionTokens = new Set(
|
|
5900
|
+
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5901
|
+
);
|
|
5902
|
+
input.forEach((value, key) => {
|
|
5903
|
+
const lower = key.toLowerCase();
|
|
5904
|
+
if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5905
|
+
});
|
|
5906
|
+
return output;
|
|
5907
|
+
}
|
|
5908
|
+
var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
|
|
5909
|
+
function createSupabaseGuard({
|
|
5910
|
+
protection,
|
|
5911
|
+
supabaseUrl,
|
|
5912
|
+
fetchImpl = fetch,
|
|
5913
|
+
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
5914
|
+
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
5915
|
+
}) {
|
|
5916
|
+
const guard = protection.fetchGuard();
|
|
5917
|
+
const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
|
|
5918
|
+
const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
|
|
5919
|
+
const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
5920
|
+
return async function handleGuardRequest(request, ...hostArgs) {
|
|
5921
|
+
const target = request.headers.get("x-ps-target");
|
|
5922
|
+
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
5923
|
+
let targetUrl;
|
|
5924
|
+
try {
|
|
5925
|
+
targetUrl = new URL(target);
|
|
5926
|
+
} catch {
|
|
5927
|
+
return new Response("patchstack: invalid target", { status: 400 });
|
|
5928
|
+
}
|
|
5929
|
+
if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
|
|
5930
|
+
return new Response("patchstack: target not allowed", { status: 403 });
|
|
5931
|
+
}
|
|
5932
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
5933
|
+
let body;
|
|
5934
|
+
try {
|
|
5935
|
+
body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
|
|
5936
|
+
} catch (error) {
|
|
5937
|
+
if (error instanceof BodyTooLargeError) {
|
|
5938
|
+
return new Response("patchstack: request body too large", { status: 413 });
|
|
5939
|
+
}
|
|
5940
|
+
throw error;
|
|
5941
|
+
}
|
|
5942
|
+
const forwardHeaders = requestHeaders(request.headers);
|
|
5943
|
+
const evalReq = new Request(targetUrl.toString(), {
|
|
5944
|
+
method: request.method,
|
|
5945
|
+
// Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
|
|
5946
|
+
// sanitized at the cross-origin boundary below.
|
|
5947
|
+
headers: request.headers,
|
|
5948
|
+
body
|
|
5949
|
+
});
|
|
5950
|
+
Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
|
|
5951
|
+
const blocked = await guard(evalReq, ...hostArgs);
|
|
5952
|
+
if (blocked) return blocked;
|
|
5953
|
+
let upstream;
|
|
5954
|
+
try {
|
|
5955
|
+
upstream = await fetchImpl(targetUrl.toString(), {
|
|
5956
|
+
method: request.method,
|
|
5957
|
+
headers: forwardHeaders,
|
|
5958
|
+
body,
|
|
5959
|
+
redirect: "manual",
|
|
5960
|
+
signal: AbortSignal.timeout(upstreamTimeout)
|
|
5961
|
+
});
|
|
5962
|
+
} catch (error) {
|
|
5963
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
5964
|
+
return new Response("patchstack: upstream timed out", { status: 504 });
|
|
5965
|
+
}
|
|
5966
|
+
throw error;
|
|
5967
|
+
}
|
|
5968
|
+
const forwarded = new Response(upstream.body, {
|
|
5969
|
+
status: upstream.status,
|
|
5970
|
+
statusText: upstream.statusText,
|
|
5971
|
+
headers: responseHeaders(upstream.headers)
|
|
5972
|
+
});
|
|
5973
|
+
return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
|
|
5974
|
+
};
|
|
5520
5975
|
}
|
|
5521
5976
|
|
|
5522
5977
|
// src/protect/firewall-log.js
|
|
@@ -5565,8 +6020,13 @@ function resolveApiBase(pulseOrManifestUrl) {
|
|
|
5565
6020
|
function createFirewallLogReporter(opts) {
|
|
5566
6021
|
const creds = parseApiKey(opts.apiKey);
|
|
5567
6022
|
if (!creds) {
|
|
5568
|
-
return {
|
|
5569
|
-
|
|
6023
|
+
return {
|
|
6024
|
+
record() {
|
|
6025
|
+
},
|
|
6026
|
+
flush: () => Promise.resolve(),
|
|
6027
|
+
stop: () => Promise.resolve(),
|
|
6028
|
+
health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
|
|
6029
|
+
};
|
|
5570
6030
|
}
|
|
5571
6031
|
const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
|
|
5572
6032
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
@@ -5579,6 +6039,19 @@ function createFirewallLogReporter(opts) {
|
|
|
5579
6039
|
let drainPromise = null;
|
|
5580
6040
|
let activeController = null;
|
|
5581
6041
|
let ended = false;
|
|
6042
|
+
let recorded = 0;
|
|
6043
|
+
let delivered = 0;
|
|
6044
|
+
let failed = 0;
|
|
6045
|
+
let dropped = 0;
|
|
6046
|
+
let activeBatch = null;
|
|
6047
|
+
const settle = (sent, outcome) => {
|
|
6048
|
+
if (sent.counted) return;
|
|
6049
|
+
sent.counted = true;
|
|
6050
|
+
if (outcome === "delivered") delivered += sent.size;
|
|
6051
|
+
else if (outcome === "failed") failed += sent.size;
|
|
6052
|
+
else dropped += sent.size;
|
|
6053
|
+
if (activeBatch === sent) activeBatch = null;
|
|
6054
|
+
};
|
|
5582
6055
|
let cachedToken = null;
|
|
5583
6056
|
let tokenInflight = null;
|
|
5584
6057
|
const fetchAccessToken = async (signal) => {
|
|
@@ -5632,10 +6105,16 @@ function createFirewallLogReporter(opts) {
|
|
|
5632
6105
|
const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
|
|
5633
6106
|
attemptTimer?.unref?.();
|
|
5634
6107
|
let entry;
|
|
6108
|
+
const sent = { size: batch.length, counted: false };
|
|
6109
|
+
activeBatch = sent;
|
|
5635
6110
|
entry = (async () => {
|
|
5636
6111
|
try {
|
|
5637
6112
|
const token = await fetchAccessToken(controller?.signal);
|
|
5638
|
-
if (
|
|
6113
|
+
if (ended) return;
|
|
6114
|
+
if (!token) {
|
|
6115
|
+
settle(sent, "failed");
|
|
6116
|
+
return;
|
|
6117
|
+
}
|
|
5639
6118
|
const body = new URLSearchParams();
|
|
5640
6119
|
body.set("type", "firewall");
|
|
5641
6120
|
body.set("logs", JSON.stringify(batch));
|
|
@@ -5652,10 +6131,11 @@ function createFirewallLogReporter(opts) {
|
|
|
5652
6131
|
// Both phases carry the attempt controller, so slow transports cannot accumulate work.
|
|
5653
6132
|
...controller ? { signal: controller.signal } : {}
|
|
5654
6133
|
});
|
|
5655
|
-
|
|
5656
|
-
|
|
6134
|
+
const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
|
|
6135
|
+
settle(sent, res && res.ok ? "delivered" : "failed");
|
|
5657
6136
|
} catch {
|
|
5658
6137
|
} finally {
|
|
6138
|
+
settle(sent, "failed");
|
|
5659
6139
|
clearTimeout(attemptTimer);
|
|
5660
6140
|
if (activeController === controller) activeController = null;
|
|
5661
6141
|
if (inFlight === entry) inFlight = null;
|
|
@@ -5682,7 +6162,11 @@ function createFirewallLogReporter(opts) {
|
|
|
5682
6162
|
if (stopped) return;
|
|
5683
6163
|
const fid = event?.rule?.id;
|
|
5684
6164
|
if (fid === void 0 || fid === null || fid === "") return;
|
|
5685
|
-
if (queue.length >= MAX_QUEUE2)
|
|
6165
|
+
if (queue.length >= MAX_QUEUE2) {
|
|
6166
|
+
dropped++;
|
|
6167
|
+
return;
|
|
6168
|
+
}
|
|
6169
|
+
recorded++;
|
|
5686
6170
|
queue.push({
|
|
5687
6171
|
fid,
|
|
5688
6172
|
method: event.method ?? null,
|
|
@@ -5698,6 +6182,10 @@ function createFirewallLogReporter(opts) {
|
|
|
5698
6182
|
if (!timer) timer = setTimeout(flush, flushMs);
|
|
5699
6183
|
},
|
|
5700
6184
|
flush,
|
|
6185
|
+
/** Where the records went so far; see the counters above. `queued` is what is waiting now. */
|
|
6186
|
+
health() {
|
|
6187
|
+
return { recorded, delivered, failed, dropped, queued: queue.length };
|
|
6188
|
+
},
|
|
5701
6189
|
/**
|
|
5702
6190
|
* Stop, and hand back a wait for what was outstanding.
|
|
5703
6191
|
*
|
|
@@ -5716,6 +6204,8 @@ function createFirewallLogReporter(opts) {
|
|
|
5716
6204
|
ended = true;
|
|
5717
6205
|
activeController?.abort();
|
|
5718
6206
|
activeController = null;
|
|
6207
|
+
if (activeBatch) settle(activeBatch, "dropped");
|
|
6208
|
+
dropped += queue.length;
|
|
5719
6209
|
queue = [];
|
|
5720
6210
|
inFlight = null;
|
|
5721
6211
|
};
|
|
@@ -5752,165 +6242,6 @@ function telemetryEnabled() {
|
|
|
5752
6242
|
return !/^(0|false|off|no)$/i.test(String(v));
|
|
5753
6243
|
}
|
|
5754
6244
|
|
|
5755
|
-
// src/protect/supabase-guard.js
|
|
5756
|
-
var GUARD_PATH = "/_patchstack/guard";
|
|
5757
|
-
var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
5758
|
-
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
5759
|
-
var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5760
|
-
"connection",
|
|
5761
|
-
"cookie",
|
|
5762
|
-
"cookie2",
|
|
5763
|
-
"forwarded",
|
|
5764
|
-
"host",
|
|
5765
|
-
"keep-alive",
|
|
5766
|
-
"proxy-authenticate",
|
|
5767
|
-
"proxy-authorization",
|
|
5768
|
-
"te",
|
|
5769
|
-
"trailer",
|
|
5770
|
-
"transfer-encoding",
|
|
5771
|
-
"upgrade",
|
|
5772
|
-
"x-forwarded-for",
|
|
5773
|
-
"x-forwarded-host",
|
|
5774
|
-
"x-forwarded-port",
|
|
5775
|
-
"x-forwarded-proto",
|
|
5776
|
-
"x-ps-target",
|
|
5777
|
-
"x-real-ip"
|
|
5778
|
-
]);
|
|
5779
|
-
var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
|
|
5780
|
-
"connection",
|
|
5781
|
-
"content-encoding",
|
|
5782
|
-
"content-length",
|
|
5783
|
-
"keep-alive",
|
|
5784
|
-
"proxy-authenticate",
|
|
5785
|
-
"proxy-authorization",
|
|
5786
|
-
"set-cookie",
|
|
5787
|
-
"set-cookie2",
|
|
5788
|
-
"te",
|
|
5789
|
-
"trailer",
|
|
5790
|
-
"transfer-encoding",
|
|
5791
|
-
"upgrade"
|
|
5792
|
-
]);
|
|
5793
|
-
var BodyTooLargeError = class extends Error {
|
|
5794
|
-
};
|
|
5795
|
-
async function boundedBody(request, maxBytes) {
|
|
5796
|
-
const stated = Number(request.headers.get("content-length"));
|
|
5797
|
-
if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
|
|
5798
|
-
if (!request.body) return void 0;
|
|
5799
|
-
const reader = request.body.getReader();
|
|
5800
|
-
const chunks = [];
|
|
5801
|
-
let total = 0;
|
|
5802
|
-
try {
|
|
5803
|
-
for (; ; ) {
|
|
5804
|
-
const { done, value } = await reader.read();
|
|
5805
|
-
if (done) break;
|
|
5806
|
-
total += value.byteLength;
|
|
5807
|
-
if (total > maxBytes) {
|
|
5808
|
-
void reader.cancel().catch(() => {
|
|
5809
|
-
});
|
|
5810
|
-
throw new BodyTooLargeError();
|
|
5811
|
-
}
|
|
5812
|
-
chunks.push(value);
|
|
5813
|
-
}
|
|
5814
|
-
} finally {
|
|
5815
|
-
reader.releaseLock();
|
|
5816
|
-
}
|
|
5817
|
-
const body = new Uint8Array(total);
|
|
5818
|
-
let offset = 0;
|
|
5819
|
-
for (const chunk of chunks) {
|
|
5820
|
-
body.set(chunk, offset);
|
|
5821
|
-
offset += chunk.byteLength;
|
|
5822
|
-
}
|
|
5823
|
-
return body;
|
|
5824
|
-
}
|
|
5825
|
-
function requestHeaders(input) {
|
|
5826
|
-
const output = new Headers();
|
|
5827
|
-
const connectionTokens = new Set(
|
|
5828
|
-
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5829
|
-
);
|
|
5830
|
-
input.forEach((value, key) => {
|
|
5831
|
-
const lower = key.toLowerCase();
|
|
5832
|
-
if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5833
|
-
});
|
|
5834
|
-
return output;
|
|
5835
|
-
}
|
|
5836
|
-
function responseHeaders(input) {
|
|
5837
|
-
const output = new Headers();
|
|
5838
|
-
const connectionTokens = new Set(
|
|
5839
|
-
(input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
|
|
5840
|
-
);
|
|
5841
|
-
input.forEach((value, key) => {
|
|
5842
|
-
const lower = key.toLowerCase();
|
|
5843
|
-
if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
|
|
5844
|
-
});
|
|
5845
|
-
return output;
|
|
5846
|
-
}
|
|
5847
|
-
function createSupabaseGuard({
|
|
5848
|
-
protection,
|
|
5849
|
-
supabaseUrl,
|
|
5850
|
-
fetchImpl = fetch,
|
|
5851
|
-
maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
|
|
5852
|
-
timeoutMs = DEFAULT_TIMEOUT_MS
|
|
5853
|
-
}) {
|
|
5854
|
-
const guard = protection.fetchGuard();
|
|
5855
|
-
const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
|
|
5856
|
-
const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
|
|
5857
|
-
const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
5858
|
-
return async function handleGuardRequest(request) {
|
|
5859
|
-
const target = request.headers.get("x-ps-target");
|
|
5860
|
-
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
5861
|
-
let targetUrl;
|
|
5862
|
-
try {
|
|
5863
|
-
targetUrl = new URL(target);
|
|
5864
|
-
} catch {
|
|
5865
|
-
return new Response("patchstack: invalid target", { status: 400 });
|
|
5866
|
-
}
|
|
5867
|
-
if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
|
|
5868
|
-
return new Response("patchstack: target not allowed", { status: 403 });
|
|
5869
|
-
}
|
|
5870
|
-
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
5871
|
-
let body;
|
|
5872
|
-
try {
|
|
5873
|
-
body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
|
|
5874
|
-
} catch (error) {
|
|
5875
|
-
if (error instanceof BodyTooLargeError) {
|
|
5876
|
-
return new Response("patchstack: request body too large", { status: 413 });
|
|
5877
|
-
}
|
|
5878
|
-
throw error;
|
|
5879
|
-
}
|
|
5880
|
-
const forwardHeaders = requestHeaders(request.headers);
|
|
5881
|
-
const evalReq = new Request(targetUrl.toString(), {
|
|
5882
|
-
method: request.method,
|
|
5883
|
-
// Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
|
|
5884
|
-
// sanitized at the cross-origin boundary below.
|
|
5885
|
-
headers: request.headers,
|
|
5886
|
-
body
|
|
5887
|
-
});
|
|
5888
|
-
const blocked = await guard(evalReq);
|
|
5889
|
-
if (blocked) return blocked;
|
|
5890
|
-
let upstream;
|
|
5891
|
-
try {
|
|
5892
|
-
upstream = await fetchImpl(targetUrl.toString(), {
|
|
5893
|
-
method: request.method,
|
|
5894
|
-
headers: forwardHeaders,
|
|
5895
|
-
body,
|
|
5896
|
-
redirect: "manual",
|
|
5897
|
-
signal: AbortSignal.timeout(upstreamTimeout)
|
|
5898
|
-
});
|
|
5899
|
-
} catch (error) {
|
|
5900
|
-
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
5901
|
-
return new Response("patchstack: upstream timed out", { status: 504 });
|
|
5902
|
-
}
|
|
5903
|
-
throw error;
|
|
5904
|
-
}
|
|
5905
|
-
const forwarded = new Response(upstream.body, {
|
|
5906
|
-
status: upstream.status,
|
|
5907
|
-
statusText: upstream.statusText,
|
|
5908
|
-
headers: responseHeaders(upstream.headers)
|
|
5909
|
-
});
|
|
5910
|
-
return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
|
|
5911
|
-
};
|
|
5912
|
-
}
|
|
5913
|
-
|
|
5914
6245
|
// src/protect/verify-sentinel.js
|
|
5915
6246
|
var VERIFY_HEADER = "x-patchstack-verify";
|
|
5916
6247
|
var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
|
|
@@ -6097,20 +6428,47 @@ async function createProtection(options = {}) {
|
|
|
6097
6428
|
whitelist_keys: delivered.whitelist_keys,
|
|
6098
6429
|
onError
|
|
6099
6430
|
});
|
|
6100
|
-
responseRuleSet = responseRules.map((rule) =>
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
6431
|
+
responseRuleSet = responseRules.map((rule) => {
|
|
6432
|
+
const unscoped = [];
|
|
6433
|
+
const rewrites = rule.action === "redact" || rule.action === "encode";
|
|
6434
|
+
let derived;
|
|
6435
|
+
try {
|
|
6436
|
+
const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
|
|
6437
|
+
const mutatedSpan = rewrites && hasSpanMutations(rule);
|
|
6438
|
+
derived = {
|
|
6439
|
+
redactors,
|
|
6440
|
+
mutatedSpan,
|
|
6441
|
+
// A redaction that reads response headers only, and masks spans in them, and an explicit block
|
|
6442
|
+
// that reads response headers only, are decided and carried out without the body — so they
|
|
6443
|
+
// still apply to a response whose body was not screened.
|
|
6444
|
+
redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
|
|
6445
|
+
blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
|
|
6446
|
+
};
|
|
6447
|
+
} catch (err) {
|
|
6448
|
+
notify(onError, err, "onError");
|
|
6449
|
+
derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
|
|
6450
|
+
}
|
|
6451
|
+
if (unscoped.length) {
|
|
6452
|
+
notify(
|
|
6453
|
+
onError,
|
|
6454
|
+
new Error(
|
|
6455
|
+
`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`
|
|
6456
|
+
),
|
|
6457
|
+
"onError"
|
|
6458
|
+
);
|
|
6459
|
+
}
|
|
6460
|
+
return {
|
|
6461
|
+
rule,
|
|
6462
|
+
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
6463
|
+
...derived,
|
|
6464
|
+
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
|
|
6465
|
+
// have any chance of matching. Lets screenText skip the full scan on responses with no candidate
|
|
6466
|
+
// — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
|
|
6467
|
+
// Case-insensitive, and checked against the header values as well as the body, since a rule may
|
|
6468
|
+
// read either.
|
|
6469
|
+
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
6470
|
+
};
|
|
6471
|
+
});
|
|
6114
6472
|
egressRuleSet = egressRules.map((rule) => ({
|
|
6115
6473
|
rule,
|
|
6116
6474
|
engine: new RuleEngine({ firewall: [rule], onError })
|
|
@@ -6124,6 +6482,9 @@ async function createProtection(options = {}) {
|
|
|
6124
6482
|
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
6125
6483
|
notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
|
|
6126
6484
|
};
|
|
6485
|
+
const recordEvaluationSkips = (result) => {
|
|
6486
|
+
for (const reason of result?.skips ?? []) recordSkip("request", reason);
|
|
6487
|
+
};
|
|
6127
6488
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
6128
6489
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
6129
6490
|
const planCache = createPlanCache();
|
|
@@ -6166,15 +6527,19 @@ async function createProtection(options = {}) {
|
|
|
6166
6527
|
});
|
|
6167
6528
|
return effectiveMode === "block" ? block() : allow2();
|
|
6168
6529
|
};
|
|
6530
|
+
const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
|
|
6531
|
+
const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
|
|
6169
6532
|
const screenText = (text, meta, reqCtx, only) => {
|
|
6170
6533
|
let blockRule = null;
|
|
6171
6534
|
const redactions = [];
|
|
6172
6535
|
const headerMutations = [];
|
|
6173
6536
|
let lowerText = null;
|
|
6174
|
-
|
|
6175
|
-
|
|
6537
|
+
const responseSkips = /* @__PURE__ */ new Set();
|
|
6538
|
+
for (const entry of responseRuleSet) {
|
|
6539
|
+
const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
|
|
6540
|
+
if (only && !only(rule, entry)) continue;
|
|
6176
6541
|
if (prefilter) {
|
|
6177
|
-
if (lowerText === null) lowerText = text.
|
|
6542
|
+
if (lowerText === null) lowerText = prefilterText(text, meta.headers);
|
|
6178
6543
|
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
6179
6544
|
}
|
|
6180
6545
|
let result;
|
|
@@ -6184,6 +6549,11 @@ async function createProtection(options = {}) {
|
|
|
6184
6549
|
notify(onError, err, "onError");
|
|
6185
6550
|
continue;
|
|
6186
6551
|
}
|
|
6552
|
+
for (const reason of result.skips ?? []) {
|
|
6553
|
+
if (responseSkips.has(reason)) continue;
|
|
6554
|
+
responseSkips.add(reason);
|
|
6555
|
+
recordSkip("response", reason);
|
|
6556
|
+
}
|
|
6187
6557
|
if (!result.blocked) continue;
|
|
6188
6558
|
const responseMode = ruleMode(rule);
|
|
6189
6559
|
onDetect({
|
|
@@ -6213,6 +6583,8 @@ async function createProtection(options = {}) {
|
|
|
6213
6583
|
if (blockRule) return { verdict: "block" };
|
|
6214
6584
|
let body = text;
|
|
6215
6585
|
const headers = { ...meta.headers || {} };
|
|
6586
|
+
let responseIsJson;
|
|
6587
|
+
let structure;
|
|
6216
6588
|
for (const { rule, redactors } of redactions) {
|
|
6217
6589
|
const mask = maskFn(rule.category);
|
|
6218
6590
|
const transform = rule.action === "encode" ? htmlEscape : null;
|
|
@@ -6221,23 +6593,32 @@ async function createProtection(options = {}) {
|
|
|
6221
6593
|
if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
|
|
6222
6594
|
if (!spanRedactors.length) continue;
|
|
6223
6595
|
const beforeSpan = body;
|
|
6224
|
-
|
|
6225
|
-
if (body
|
|
6596
|
+
const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
|
|
6597
|
+
if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
|
|
6598
|
+
if (body !== beforeSpan) {
|
|
6599
|
+
responseIsJson ??= isJson(text);
|
|
6600
|
+
if (responseIsJson) {
|
|
6601
|
+
if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
|
|
6602
|
+
const tokens = lexJson(body);
|
|
6603
|
+
if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
|
|
6604
|
+
structure = { text: body, tokens };
|
|
6605
|
+
}
|
|
6606
|
+
}
|
|
6226
6607
|
if (transform) continue;
|
|
6227
6608
|
for (const name of Object.keys(headers)) {
|
|
6228
6609
|
const value = headers[name];
|
|
6610
|
+
const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
|
|
6229
6611
|
if (typeof value === "string") {
|
|
6230
|
-
setOwn(headers, name, applyRedactors(value,
|
|
6612
|
+
setOwn(headers, name, applyRedactors(value, headerSpans, mask));
|
|
6231
6613
|
} else if (Array.isArray(value)) {
|
|
6232
6614
|
setOwn(
|
|
6233
6615
|
headers,
|
|
6234
6616
|
name,
|
|
6235
|
-
value.map((item) => typeof item === "string" ? applyRedactors(item,
|
|
6617
|
+
value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
|
|
6236
6618
|
);
|
|
6237
6619
|
}
|
|
6238
6620
|
}
|
|
6239
6621
|
}
|
|
6240
|
-
if (body !== text && isJson(text) && !isJson(body)) return { verdict: "block" };
|
|
6241
6622
|
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
6242
6623
|
return { verdict: "redact", body, headers };
|
|
6243
6624
|
};
|
|
@@ -6280,18 +6661,53 @@ async function createProtection(options = {}) {
|
|
|
6280
6661
|
}
|
|
6281
6662
|
};
|
|
6282
6663
|
};
|
|
6283
|
-
const
|
|
6664
|
+
const peerOf = (request, hostArgs) => {
|
|
6665
|
+
if (typeof options.peerAddress !== "function") return void 0;
|
|
6666
|
+
try {
|
|
6667
|
+
return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
|
|
6668
|
+
} catch (err) {
|
|
6669
|
+
notify(onError, err, "onError");
|
|
6670
|
+
return void 0;
|
|
6671
|
+
}
|
|
6672
|
+
};
|
|
6673
|
+
let warnedNoPeer = false;
|
|
6674
|
+
const warnNoPeer = () => {
|
|
6675
|
+
if (warnedNoPeer) return;
|
|
6676
|
+
warnedNoPeer = true;
|
|
6677
|
+
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.";
|
|
6678
|
+
if (typeof onError === "function") notify(onError, new Error(message), "onError");
|
|
6679
|
+
else console.warn(message);
|
|
6680
|
+
};
|
|
6681
|
+
const clientByRequest = /* @__PURE__ */ new WeakMap();
|
|
6682
|
+
const clientForResponse = (request, hostArgs) => {
|
|
6683
|
+
const known = clientByRequest.get(request);
|
|
6684
|
+
if (known) return known;
|
|
6685
|
+
const client = resolveClientIp({
|
|
6686
|
+
peer: peerOf(request, hostArgs),
|
|
6687
|
+
headers: headerObject(request.headers),
|
|
6688
|
+
trustedProxy: options.trustedProxy
|
|
6689
|
+
});
|
|
6690
|
+
if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
|
|
6691
|
+
return client;
|
|
6692
|
+
};
|
|
6693
|
+
const screenFetchRequest = async (request, hostArgs = []) => {
|
|
6284
6694
|
let result;
|
|
6285
6695
|
let shaped;
|
|
6286
6696
|
try {
|
|
6287
6697
|
shaped = await fromFetchRequest(request, {
|
|
6698
|
+
peer: peerOf(request, hostArgs),
|
|
6288
6699
|
trustedProxy: options.trustedProxy,
|
|
6289
6700
|
maxBodyBytes: options.maxBodyBytes
|
|
6290
6701
|
});
|
|
6702
|
+
if (shaped?._clientIp) {
|
|
6703
|
+
clientByRequest.set(request, shaped._clientIp);
|
|
6704
|
+
if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
|
|
6705
|
+
}
|
|
6291
6706
|
if (shaped?._bodyInspectionSkip) {
|
|
6292
6707
|
recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
|
|
6293
6708
|
}
|
|
6294
6709
|
result = engine.evaluate(shaped);
|
|
6710
|
+
recordEvaluationSkips(result);
|
|
6295
6711
|
} catch (err) {
|
|
6296
6712
|
notify(onError, err, "onError");
|
|
6297
6713
|
return { blocked: null, client: void 0 };
|
|
@@ -6341,15 +6757,19 @@ async function createProtection(options = {}) {
|
|
|
6341
6757
|
const read = await readTextResponse(response, screenCap);
|
|
6342
6758
|
if (read.skip) {
|
|
6343
6759
|
if (read.skip !== "not-a-response") {
|
|
6344
|
-
recordSkip("response", read.skip, {
|
|
6760
|
+
recordSkip("response", read.skip, {
|
|
6761
|
+
status: response?.status,
|
|
6762
|
+
...read.encoding ? { encoding: read.encoding } : {},
|
|
6763
|
+
...read.charset ? { charset: read.charset } : {}
|
|
6764
|
+
});
|
|
6345
6765
|
}
|
|
6346
6766
|
if (read.skip === "not-a-response") return response;
|
|
6347
6767
|
return hardenHeadersOnly(response, reqCtx);
|
|
6348
6768
|
}
|
|
6349
|
-
const text = read.text;
|
|
6769
|
+
const text = readJsonEscapes(read.text);
|
|
6350
6770
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
6351
|
-
if (r.verdict === "block") return leakResponse();
|
|
6352
|
-
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
6771
|
+
if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
|
|
6772
|
+
if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
|
|
6353
6773
|
return response;
|
|
6354
6774
|
};
|
|
6355
6775
|
const headerValueChanged = (was, value) => {
|
|
@@ -6411,7 +6831,8 @@ async function createProtection(options = {}) {
|
|
|
6411
6831
|
const hardenHeadersOnly = (response, reqCtx) => {
|
|
6412
6832
|
try {
|
|
6413
6833
|
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
6414
|
-
const r = screenText("", meta, reqCtx,
|
|
6834
|
+
const r = screenText("", meta, reqCtx, decidedWithoutBody);
|
|
6835
|
+
if (r.verdict === "block") return leakResponse();
|
|
6415
6836
|
if (r.verdict !== "redact" || !r.headers) return response;
|
|
6416
6837
|
if (!headersChanged(meta.headers, r.headers)) return response;
|
|
6417
6838
|
return rebuildResponse(response, response.body, r.headers);
|
|
@@ -6431,6 +6852,8 @@ async function createProtection(options = {}) {
|
|
|
6431
6852
|
const chunks = [];
|
|
6432
6853
|
let size = 0;
|
|
6433
6854
|
let overflow = false;
|
|
6855
|
+
let withheld = false;
|
|
6856
|
+
let headScreened = false;
|
|
6434
6857
|
const MAX = screenCap;
|
|
6435
6858
|
let hardened = false;
|
|
6436
6859
|
let answeredWithoutBody = false;
|
|
@@ -6462,6 +6885,7 @@ async function createProtection(options = {}) {
|
|
|
6462
6885
|
}
|
|
6463
6886
|
};
|
|
6464
6887
|
const stillToAnswer = (rule) => !hardensWithoutBody(rule);
|
|
6888
|
+
const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
|
|
6465
6889
|
let pendingHead = null;
|
|
6466
6890
|
let sending = false;
|
|
6467
6891
|
const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
|
|
@@ -6523,7 +6947,12 @@ async function createProtection(options = {}) {
|
|
|
6523
6947
|
if (typeof res.flushHeaders === "function") {
|
|
6524
6948
|
const origFlushHeaders = res.flushHeaders.bind(res);
|
|
6525
6949
|
res.flushHeaders = function() {
|
|
6526
|
-
|
|
6950
|
+
const unread = screenUnreadHead();
|
|
6951
|
+
if (unread?.withhold) {
|
|
6952
|
+
sendWithheld();
|
|
6953
|
+
return void 0;
|
|
6954
|
+
}
|
|
6955
|
+
sendHead(unread);
|
|
6527
6956
|
return origFlushHeaders();
|
|
6528
6957
|
};
|
|
6529
6958
|
}
|
|
@@ -6558,6 +6987,32 @@ async function createProtection(options = {}) {
|
|
|
6558
6987
|
headers: at === -1 ? set : { ...set, ...writeHeadHeaderObject(pendingHead[at]) }
|
|
6559
6988
|
};
|
|
6560
6989
|
};
|
|
6990
|
+
const screenUnreadHead = () => {
|
|
6991
|
+
if (headScreened) return void 0;
|
|
6992
|
+
headScreened = true;
|
|
6993
|
+
try {
|
|
6994
|
+
const head = effectiveHead();
|
|
6995
|
+
const r = screenText("", head, reqCtx, decidedByHeaders);
|
|
6996
|
+
if (r.verdict === "block") {
|
|
6997
|
+
if (!reallySent()) return { withhold: true };
|
|
6998
|
+
recordSkip("response", "headers-sent", { action: "block" });
|
|
6999
|
+
return void 0;
|
|
7000
|
+
}
|
|
7001
|
+
if (r.verdict !== "redact" || !r.headers) return void 0;
|
|
7002
|
+
const changed = /* @__PURE__ */ new Map();
|
|
7003
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
7004
|
+
if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
|
|
7005
|
+
}
|
|
7006
|
+
if (changed.size && reallySent()) {
|
|
7007
|
+
recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
|
|
7008
|
+
return void 0;
|
|
7009
|
+
}
|
|
7010
|
+
return changed.size ? { headers: changed } : void 0;
|
|
7011
|
+
} catch (err) {
|
|
7012
|
+
notify(onError, err, "onError");
|
|
7013
|
+
return void 0;
|
|
7014
|
+
}
|
|
7015
|
+
};
|
|
6561
7016
|
const sendHead = (changes) => {
|
|
6562
7017
|
if (sending) return;
|
|
6563
7018
|
const hardened2 = hardenOnce();
|
|
@@ -6579,6 +7034,17 @@ async function createProtection(options = {}) {
|
|
|
6579
7034
|
applyToResponse({ headers: merged });
|
|
6580
7035
|
origWriteHead(...args);
|
|
6581
7036
|
};
|
|
7037
|
+
const sendWithheld = (cb) => {
|
|
7038
|
+
const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
|
|
7039
|
+
const headers = /* @__PURE__ */ new Map();
|
|
7040
|
+
for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
|
|
7041
|
+
headers.set("content-type", "application/json");
|
|
7042
|
+
headers.set("content-length", String(Buffer.byteLength(body)));
|
|
7043
|
+
headers.set("transfer-encoding", null);
|
|
7044
|
+
withheld = true;
|
|
7045
|
+
sendHead({ status: 500, headers });
|
|
7046
|
+
return origEnd(body, cb);
|
|
7047
|
+
};
|
|
6582
7048
|
const applyToResponse = (changes) => {
|
|
6583
7049
|
if (changes.status !== void 0) res.statusCode = changes.status;
|
|
6584
7050
|
for (const [name, value] of changes.headers ?? []) {
|
|
@@ -6594,7 +7060,12 @@ async function createProtection(options = {}) {
|
|
|
6594
7060
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
6595
7061
|
size += buf.length;
|
|
6596
7062
|
if (size > MAX) {
|
|
6597
|
-
|
|
7063
|
+
const unread = screenUnreadHead();
|
|
7064
|
+
if (unread?.withhold) {
|
|
7065
|
+
sendWithheld();
|
|
7066
|
+
return;
|
|
7067
|
+
}
|
|
7068
|
+
sendHead(unread);
|
|
6598
7069
|
for (const c of chunks) origWrite(c);
|
|
6599
7070
|
chunks.length = 0;
|
|
6600
7071
|
origWrite(buf);
|
|
@@ -6605,6 +7076,11 @@ async function createProtection(options = {}) {
|
|
|
6605
7076
|
chunks.push(buf);
|
|
6606
7077
|
};
|
|
6607
7078
|
res.write = function(chunk, enc, cb) {
|
|
7079
|
+
if (withheld) {
|
|
7080
|
+
if (typeof enc === "function") enc();
|
|
7081
|
+
else if (typeof cb === "function") cb();
|
|
7082
|
+
return true;
|
|
7083
|
+
}
|
|
6608
7084
|
if (overflow) return origWrite(chunk, enc, cb);
|
|
6609
7085
|
collect(chunk, enc);
|
|
6610
7086
|
if (typeof enc === "function") enc();
|
|
@@ -6620,15 +7096,24 @@ async function createProtection(options = {}) {
|
|
|
6620
7096
|
cb = enc;
|
|
6621
7097
|
enc = void 0;
|
|
6622
7098
|
}
|
|
7099
|
+
if (withheld) {
|
|
7100
|
+
if (typeof cb === "function") cb();
|
|
7101
|
+
return res;
|
|
7102
|
+
}
|
|
6623
7103
|
if (overflow) {
|
|
6624
7104
|
if (chunk != null) origWrite(chunk, enc);
|
|
6625
7105
|
return origEnd(cb);
|
|
6626
7106
|
}
|
|
6627
7107
|
collect(chunk, enc);
|
|
7108
|
+
if (withheld) {
|
|
7109
|
+
if (typeof cb === "function") cb();
|
|
7110
|
+
return res;
|
|
7111
|
+
}
|
|
6628
7112
|
if (overflow) return origEnd(cb);
|
|
6629
7113
|
hardenOnce();
|
|
6630
|
-
const passThrough = () => {
|
|
6631
|
-
|
|
7114
|
+
const passThrough = (changes) => {
|
|
7115
|
+
if (changes?.withhold) return sendWithheld(cb);
|
|
7116
|
+
sendHead(changes);
|
|
6632
7117
|
for (const c of chunks) origWrite(c);
|
|
6633
7118
|
return origEnd(cb);
|
|
6634
7119
|
};
|
|
@@ -6639,21 +7124,28 @@ async function createProtection(options = {}) {
|
|
|
6639
7124
|
const kind = screenableContentType(ct);
|
|
6640
7125
|
if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
|
|
6641
7126
|
recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
|
|
6642
|
-
return passThrough();
|
|
7127
|
+
return passThrough(screenUnreadHead());
|
|
6643
7128
|
}
|
|
6644
7129
|
const codings = declaredCodings(head.headers["content-encoding"]);
|
|
6645
7130
|
if (codings.length > 0 && stillEncoded(buffer)) {
|
|
6646
7131
|
recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
|
|
6647
|
-
return passThrough();
|
|
7132
|
+
return passThrough(screenUnreadHead());
|
|
7133
|
+
}
|
|
7134
|
+
const reading = charsetReading(ct, buffer);
|
|
7135
|
+
if (reading.skip) {
|
|
7136
|
+
recordSkip("response", "unsupported-charset", { charset: reading.skip });
|
|
7137
|
+
return passThrough(screenUnreadHead());
|
|
6648
7138
|
}
|
|
6649
|
-
const text = buffer.toString("utf8");
|
|
7139
|
+
const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
|
|
6650
7140
|
let r;
|
|
6651
7141
|
try {
|
|
6652
|
-
r = screenText(text, head, reqCtx,
|
|
7142
|
+
r = screenText(text, head, reqCtx, notYetAnswered);
|
|
6653
7143
|
} catch (err) {
|
|
6654
7144
|
notify(onError, err, "onError");
|
|
6655
7145
|
return passThrough();
|
|
6656
7146
|
}
|
|
7147
|
+
if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
|
|
7148
|
+
if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
|
|
6657
7149
|
if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
|
|
6658
7150
|
if (reallySent() && res.chunkedEncoding !== true) {
|
|
6659
7151
|
notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
|
|
@@ -6661,20 +7153,11 @@ async function createProtection(options = {}) {
|
|
|
6661
7153
|
return res;
|
|
6662
7154
|
}
|
|
6663
7155
|
if (r.verdict === "block") {
|
|
6664
|
-
const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
|
|
6665
7156
|
if (reallySent()) {
|
|
6666
7157
|
res.destroy?.();
|
|
6667
7158
|
return res;
|
|
6668
7159
|
}
|
|
6669
|
-
|
|
6670
|
-
status: 500,
|
|
6671
|
-
headers: /* @__PURE__ */ new Map([
|
|
6672
|
-
["content-type", "application/json"],
|
|
6673
|
-
["content-length", String(Buffer.byteLength(body))],
|
|
6674
|
-
["transfer-encoding", null]
|
|
6675
|
-
])
|
|
6676
|
-
});
|
|
6677
|
-
return origEnd(body, cb);
|
|
7160
|
+
return sendWithheld(cb);
|
|
6678
7161
|
}
|
|
6679
7162
|
const changed = /* @__PURE__ */ new Map();
|
|
6680
7163
|
if (r.headers) {
|
|
@@ -6770,21 +7253,15 @@ async function createProtection(options = {}) {
|
|
|
6770
7253
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
6771
7254
|
// A standalone response screen with no request phase of its own — the Supabase guard's forwarded
|
|
6772
7255
|
// upstream response. It resolves once here, which is the only resolution for this call.
|
|
6773
|
-
screenResponse: (response, request) => screenResp(
|
|
6774
|
-
response,
|
|
6775
|
-
request ? reqContextFromFetch(
|
|
6776
|
-
request,
|
|
6777
|
-
resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
|
|
6778
|
-
) : void 0
|
|
6779
|
-
),
|
|
7256
|
+
screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
|
|
6780
7257
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
6781
7258
|
fetchGuard() {
|
|
6782
|
-
return async (request) => (await screenFetchRequest(request)).blocked;
|
|
7259
|
+
return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
|
|
6783
7260
|
},
|
|
6784
7261
|
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
6785
7262
|
fetch(handler) {
|
|
6786
7263
|
return async (request, ...rest) => {
|
|
6787
|
-
const { blocked, client } = await screenFetchRequest(request);
|
|
7264
|
+
const { blocked, client } = await screenFetchRequest(request, rest);
|
|
6788
7265
|
if (blocked) return blocked;
|
|
6789
7266
|
const response = await handler(request, ...rest);
|
|
6790
7267
|
return screenResp(response, reqContextFromFetch(request, client));
|
|
@@ -6798,6 +7275,7 @@ async function createProtection(options = {}) {
|
|
|
6798
7275
|
const { shaped, client } = shapeExpressRequest(req);
|
|
6799
7276
|
try {
|
|
6800
7277
|
result = engine.evaluate(shaped);
|
|
7278
|
+
recordEvaluationSkips(result);
|
|
6801
7279
|
} catch (err) {
|
|
6802
7280
|
notify(onError, err, "onError");
|
|
6803
7281
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
@@ -6832,33 +7310,25 @@ async function createProtection(options = {}) {
|
|
|
6832
7310
|
screenNodeRequest(req, res, next, "", req.body);
|
|
6833
7311
|
return;
|
|
6834
7312
|
}
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
size += chunk.length;
|
|
6840
|
-
if (size > maxBytes) {
|
|
6841
|
-
overflow = true;
|
|
7313
|
+
readBodyPrefix(req, maxBytes, (err, read) => {
|
|
7314
|
+
if (err) {
|
|
7315
|
+
notify(onError, err, "onError");
|
|
7316
|
+
next();
|
|
6842
7317
|
return;
|
|
6843
7318
|
}
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
notify(onError, err, "onError");
|
|
6848
|
-
next();
|
|
6849
|
-
});
|
|
6850
|
-
req.on("end", () => {
|
|
6851
|
-
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
6852
|
-
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
7319
|
+
if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
|
|
7320
|
+
if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
|
|
7321
|
+
screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
|
|
6853
7322
|
});
|
|
6854
7323
|
};
|
|
6855
|
-
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
7324
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
|
|
6856
7325
|
let shaped;
|
|
6857
7326
|
let result;
|
|
6858
7327
|
try {
|
|
6859
7328
|
shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
|
|
6860
7329
|
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
6861
7330
|
result = engine.evaluate(shaped);
|
|
7331
|
+
recordEvaluationSkips(result);
|
|
6862
7332
|
} catch (err) {
|
|
6863
7333
|
notify(onError, err, "onError");
|
|
6864
7334
|
return next();
|
|
@@ -6877,7 +7347,7 @@ async function createProtection(options = {}) {
|
|
|
6877
7347
|
}
|
|
6878
7348
|
},
|
|
6879
7349
|
() => {
|
|
6880
|
-
if (req.body === void 0) req.body = shaped.body;
|
|
7350
|
+
if (req.body === void 0 && !truncated) req.body = shaped.body;
|
|
6881
7351
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
6882
7352
|
next();
|
|
6883
7353
|
},
|
|
@@ -6943,12 +7413,17 @@ async function createProtection(options = {}) {
|
|
|
6943
7413
|
protection.stop = () => {
|
|
6944
7414
|
loop?.stop();
|
|
6945
7415
|
recovery?.stop();
|
|
7416
|
+
protection.uninstallEgress?.();
|
|
6946
7417
|
const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
|
|
6947
7418
|
(wait) => wait && typeof wait.then === "function"
|
|
6948
7419
|
);
|
|
6949
7420
|
return Promise.all(outstanding).then(() => void 0);
|
|
6950
7421
|
};
|
|
6951
|
-
protection.stopRefresh =
|
|
7422
|
+
protection.stopRefresh = () => {
|
|
7423
|
+
loop?.stop();
|
|
7424
|
+
recovery?.stop();
|
|
7425
|
+
return Promise.resolve();
|
|
7426
|
+
};
|
|
6952
7427
|
Object.defineProperty(protection, "ruleSource", {
|
|
6953
7428
|
get: () => ({ ...ruleSource }),
|
|
6954
7429
|
enumerable: true
|
|
@@ -6961,6 +7436,10 @@ async function createProtection(options = {}) {
|
|
|
6961
7436
|
get: () => detections ? () => detections.health() : void 0,
|
|
6962
7437
|
enumerable: true
|
|
6963
7438
|
});
|
|
7439
|
+
Object.defineProperty(protection, "blockLogHealth", {
|
|
7440
|
+
get: () => firewallLog ? () => firewallLog.health() : void 0,
|
|
7441
|
+
enumerable: true
|
|
7442
|
+
});
|
|
6964
7443
|
return protection;
|
|
6965
7444
|
}
|
|
6966
7445
|
function accessorOf(obj, name) {
|
|
@@ -7035,6 +7514,14 @@ function screenableContentType(ct) {
|
|
|
7035
7514
|
if (base === "application/octet-stream") return "sniff";
|
|
7036
7515
|
return "skip";
|
|
7037
7516
|
}
|
|
7517
|
+
function prefilterText(body, headers) {
|
|
7518
|
+
const values = [];
|
|
7519
|
+
for (const value of Object.values(headers ?? {})) {
|
|
7520
|
+
if (Array.isArray(value)) values.push(...value.map(String));
|
|
7521
|
+
else if (value !== void 0 && value !== null) values.push(String(value));
|
|
7522
|
+
}
|
|
7523
|
+
return [body, ...values].join("\n").toLowerCase();
|
|
7524
|
+
}
|
|
7038
7525
|
function looksBinary(bytes) {
|
|
7039
7526
|
const n = Math.min(bytes.length, 512);
|
|
7040
7527
|
let ctrl = 0;
|
|
@@ -7058,6 +7545,52 @@ function stillEncoded(bytes) {
|
|
|
7058
7545
|
return true;
|
|
7059
7546
|
}
|
|
7060
7547
|
}
|
|
7548
|
+
var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
|
|
7549
|
+
var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
|
|
7550
|
+
function declaredCharset(ct) {
|
|
7551
|
+
const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
|
|
7552
|
+
const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
|
|
7553
|
+
return label || null;
|
|
7554
|
+
}
|
|
7555
|
+
function charsetReading(ct, bytes) {
|
|
7556
|
+
if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
|
|
7557
|
+
if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
|
|
7558
|
+
if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
|
|
7559
|
+
const charset = declaredCharset(ct);
|
|
7560
|
+
if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
|
|
7561
|
+
if (WIDE_CHARSET.test(charset)) return { skip: charset };
|
|
7562
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
7563
|
+
const b = bytes[i];
|
|
7564
|
+
if (b >= 128 || b === 0 || b === 27) return { skip: charset };
|
|
7565
|
+
}
|
|
7566
|
+
return { ascii: true };
|
|
7567
|
+
}
|
|
7568
|
+
function decodeForScreening(ct, bytes) {
|
|
7569
|
+
const reading = charsetReading(ct, bytes);
|
|
7570
|
+
if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
|
|
7571
|
+
return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
|
|
7572
|
+
}
|
|
7573
|
+
function withBom(bom, body) {
|
|
7574
|
+
return bom ? "\uFEFF" + body : body;
|
|
7575
|
+
}
|
|
7576
|
+
function outgrowsCharset(ascii, body) {
|
|
7577
|
+
return ascii === true && /[^\x00-\x7f]/.test(body);
|
|
7578
|
+
}
|
|
7579
|
+
var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
|
|
7580
|
+
function readJsonEscapes(text) {
|
|
7581
|
+
if (!text.includes("\\u") && !text.includes("\\/")) return text;
|
|
7582
|
+
try {
|
|
7583
|
+
JSON.parse(text);
|
|
7584
|
+
} catch {
|
|
7585
|
+
return text;
|
|
7586
|
+
}
|
|
7587
|
+
return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
|
|
7588
|
+
if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
|
|
7589
|
+
const code = parseInt(hex, 16);
|
|
7590
|
+
if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
|
|
7591
|
+
return String.fromCharCode(code);
|
|
7592
|
+
});
|
|
7593
|
+
}
|
|
7061
7594
|
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
7062
7595
|
if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
|
|
7063
7596
|
const ct = response.headers?.get?.("content-type") || "";
|
|
@@ -7111,17 +7644,18 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
7111
7644
|
const bytes = concatBytes(chunks, size);
|
|
7112
7645
|
if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
|
|
7113
7646
|
try {
|
|
7114
|
-
return
|
|
7647
|
+
return decodeForScreening(ct, bytes);
|
|
7115
7648
|
} catch {
|
|
7116
7649
|
return { skip: "decode-failed" };
|
|
7117
7650
|
}
|
|
7118
7651
|
}
|
|
7119
7652
|
try {
|
|
7120
|
-
if (codings.length > 0) {
|
|
7653
|
+
if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
|
|
7121
7654
|
const bytes = new Uint8Array(await clone.arrayBuffer());
|
|
7122
7655
|
if (bytes.byteLength > cap) return { skip: "body-cap" };
|
|
7123
|
-
if (stillEncoded(bytes)) return encodedSkip();
|
|
7124
|
-
|
|
7656
|
+
if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
|
|
7657
|
+
if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
|
|
7658
|
+
return decodeForScreening(ct, bytes);
|
|
7125
7659
|
}
|
|
7126
7660
|
const text = await clone.text();
|
|
7127
7661
|
if (text.length > cap) return { skip: "body-cap" };
|
|
@@ -7152,8 +7686,9 @@ function headerObject(headers) {
|
|
|
7152
7686
|
function hasSpanMutations(rule) {
|
|
7153
7687
|
let found = false;
|
|
7154
7688
|
const walk = (conds) => {
|
|
7155
|
-
for (const c of conds
|
|
7689
|
+
for (const c of Array.isArray(conds) ? conds : []) {
|
|
7156
7690
|
if (found) return;
|
|
7691
|
+
if (!c || typeof c !== "object") continue;
|
|
7157
7692
|
if (Array.isArray(c.rules)) walk(c.rules);
|
|
7158
7693
|
const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
|
|
7159
7694
|
if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
|
|
@@ -7162,10 +7697,34 @@ function hasSpanMutations(rule) {
|
|
|
7162
7697
|
walk(rule.rule_v2);
|
|
7163
7698
|
return found;
|
|
7164
7699
|
}
|
|
7165
|
-
function
|
|
7700
|
+
function redactorScope(parameter) {
|
|
7701
|
+
if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
|
|
7702
|
+
const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
|
|
7703
|
+
for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
|
|
7704
|
+
if (typeof name !== "string" || name === "") return null;
|
|
7705
|
+
if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
|
|
7706
|
+
else if (name === "response.headers") scope.allHeaders = true;
|
|
7707
|
+
else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
|
|
7708
|
+
scope.body = true;
|
|
7709
|
+
scope.allHeaders = true;
|
|
7710
|
+
} else return null;
|
|
7711
|
+
}
|
|
7712
|
+
return scope;
|
|
7713
|
+
}
|
|
7714
|
+
var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
|
|
7715
|
+
function masksHeader(redactor, name) {
|
|
7716
|
+
return redactor.scope.allHeaders || redactor.scope.headers.has(name);
|
|
7717
|
+
}
|
|
7718
|
+
function extractRedactors(rule, onUnscoped) {
|
|
7166
7719
|
const out = [];
|
|
7720
|
+
const scoped = (c) => {
|
|
7721
|
+
const scope = redactorScope(c.parameter);
|
|
7722
|
+
if (scope === null) onUnscoped?.(c.parameter);
|
|
7723
|
+
return scope;
|
|
7724
|
+
};
|
|
7167
7725
|
const walk = (conds) => {
|
|
7168
|
-
for (const c of conds
|
|
7726
|
+
for (const c of Array.isArray(conds) ? conds : []) {
|
|
7727
|
+
if (!c || typeof c !== "object") continue;
|
|
7169
7728
|
if (Array.isArray(c.rules)) walk(c.rules);
|
|
7170
7729
|
const m = c.match;
|
|
7171
7730
|
if (!m) continue;
|
|
@@ -7173,16 +7732,21 @@ function extractRedactors(rule) {
|
|
|
7173
7732
|
const safe = safeRegExp(m.value);
|
|
7174
7733
|
if (safe) {
|
|
7175
7734
|
const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
|
|
7735
|
+
const scope = scoped(c);
|
|
7176
7736
|
try {
|
|
7177
|
-
out.push({ re: new RegExp(safe.source, flags) });
|
|
7737
|
+
if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
|
|
7178
7738
|
} catch {
|
|
7179
7739
|
}
|
|
7180
7740
|
}
|
|
7181
7741
|
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
7182
|
-
|
|
7742
|
+
const scope = scoped(c);
|
|
7743
|
+
if (scope) out.push({ literal: String(m.value), scope });
|
|
7183
7744
|
} else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
|
|
7184
|
-
|
|
7185
|
-
|
|
7745
|
+
const scope = scoped(c);
|
|
7746
|
+
if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
|
|
7747
|
+
} else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
|
|
7748
|
+
onUnscoped?.(c.parameter);
|
|
7749
|
+
} else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
|
|
7186
7750
|
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
7187
7751
|
for (const key of keys) {
|
|
7188
7752
|
out.push({ jsonPath: String(key).split("."), condition: m.match });
|
|
@@ -7204,30 +7768,101 @@ function isJson(text) {
|
|
|
7204
7768
|
return false;
|
|
7205
7769
|
}
|
|
7206
7770
|
}
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
const left = expected.next();
|
|
7214
|
-
const right = actual.next();
|
|
7215
|
-
if (left.done || right.done) return left.done === right.done;
|
|
7216
|
-
if (left.value !== right.value) return false;
|
|
7217
|
-
}
|
|
7771
|
+
var JSON_STRING = 1;
|
|
7772
|
+
var JSON_NUMBER = 2;
|
|
7773
|
+
var JSON_LITERAL = 3;
|
|
7774
|
+
var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
7775
|
+
function isHexCode(c) {
|
|
7776
|
+
return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
|
|
7218
7777
|
}
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7778
|
+
var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
|
|
7779
|
+
function lexJson(text) {
|
|
7780
|
+
let kinds = new Int32Array(1024);
|
|
7781
|
+
let spans = new Int32Array(2048);
|
|
7782
|
+
let count = 0;
|
|
7783
|
+
const n = text.length;
|
|
7784
|
+
let i = 0;
|
|
7785
|
+
while (i < n) {
|
|
7786
|
+
const c = text.charCodeAt(i);
|
|
7787
|
+
if (c === 32 || c === 9 || c === 10 || c === 13) {
|
|
7788
|
+
i++;
|
|
7225
7789
|
continue;
|
|
7226
7790
|
}
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7791
|
+
const start = i;
|
|
7792
|
+
let kind;
|
|
7793
|
+
if (c === 34) {
|
|
7794
|
+
i++;
|
|
7795
|
+
const close = text.indexOf('"', i);
|
|
7796
|
+
if (close === -1) return null;
|
|
7797
|
+
if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
|
|
7798
|
+
i = close;
|
|
7799
|
+
} else {
|
|
7800
|
+
for (; ; ) {
|
|
7801
|
+
if (i >= n) return null;
|
|
7802
|
+
const d = text.charCodeAt(i);
|
|
7803
|
+
if (d === 34) break;
|
|
7804
|
+
if (d < 32) return null;
|
|
7805
|
+
if (d !== 92) {
|
|
7806
|
+
i++;
|
|
7807
|
+
continue;
|
|
7808
|
+
}
|
|
7809
|
+
const e = text.charCodeAt(i + 1);
|
|
7810
|
+
if (e === 117) {
|
|
7811
|
+
for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
|
|
7812
|
+
i += 6;
|
|
7813
|
+
} else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
|
|
7814
|
+
i += 2;
|
|
7815
|
+
} else {
|
|
7816
|
+
return null;
|
|
7817
|
+
}
|
|
7818
|
+
}
|
|
7819
|
+
}
|
|
7820
|
+
i++;
|
|
7821
|
+
kind = JSON_STRING;
|
|
7822
|
+
} else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
|
|
7823
|
+
i++;
|
|
7824
|
+
kind = c;
|
|
7825
|
+
} else if (text.startsWith("true", i) || text.startsWith("null", i)) {
|
|
7826
|
+
i += 4;
|
|
7827
|
+
kind = JSON_LITERAL;
|
|
7828
|
+
} else if (text.startsWith("false", i)) {
|
|
7829
|
+
i += 5;
|
|
7830
|
+
kind = JSON_LITERAL;
|
|
7831
|
+
} else {
|
|
7832
|
+
JSON_NUMBER_TOKEN.lastIndex = i;
|
|
7833
|
+
if (!JSON_NUMBER_TOKEN.test(text)) return null;
|
|
7834
|
+
i = JSON_NUMBER_TOKEN.lastIndex;
|
|
7835
|
+
kind = JSON_NUMBER;
|
|
7836
|
+
}
|
|
7837
|
+
if (count === kinds.length) {
|
|
7838
|
+
const grownKinds = new Int32Array(count * 2);
|
|
7839
|
+
grownKinds.set(kinds);
|
|
7840
|
+
kinds = grownKinds;
|
|
7841
|
+
const grownSpans = new Int32Array(count * 4);
|
|
7842
|
+
grownSpans.set(spans);
|
|
7843
|
+
spans = grownSpans;
|
|
7844
|
+
}
|
|
7845
|
+
kinds[count] = kind;
|
|
7846
|
+
spans[2 * count] = start;
|
|
7847
|
+
spans[2 * count + 1] = i;
|
|
7848
|
+
count++;
|
|
7849
|
+
}
|
|
7850
|
+
return { kinds, spans, count };
|
|
7851
|
+
}
|
|
7852
|
+
function sameJsonStructure(beforeText, before, afterText, after) {
|
|
7853
|
+
if (!before || !after || before.count !== after.count) return false;
|
|
7854
|
+
const { kinds, spans, count } = before;
|
|
7855
|
+
for (let k = 0; k < count; k++) {
|
|
7856
|
+
const kind = kinds[k];
|
|
7857
|
+
if (after.kinds[k] !== kind) return false;
|
|
7858
|
+
if (kind > JSON_LITERAL) continue;
|
|
7859
|
+
if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
|
|
7860
|
+
const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
|
|
7861
|
+
const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
|
|
7862
|
+
if (was === now) continue;
|
|
7863
|
+
if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
|
|
7230
7864
|
}
|
|
7865
|
+
return true;
|
|
7231
7866
|
}
|
|
7232
7867
|
function applyRedactors(body, redactors, mask, transform) {
|
|
7233
7868
|
let out = body;
|
|
@@ -7244,6 +7879,11 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
7244
7879
|
}
|
|
7245
7880
|
return out;
|
|
7246
7881
|
}
|
|
7882
|
+
function readsBody(parameter) {
|
|
7883
|
+
if (!Array.isArray(parameter)) return isBodyParam(parameter);
|
|
7884
|
+
const names = parameter.every((member) => typeof member === "string" && member !== "");
|
|
7885
|
+
return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
|
|
7886
|
+
}
|
|
7247
7887
|
function isBodyParam(parameter) {
|
|
7248
7888
|
return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
|
|
7249
7889
|
}
|
|
@@ -7275,7 +7915,7 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
|
7275
7915
|
let obj;
|
|
7276
7916
|
try {
|
|
7277
7917
|
JSON.parse(text);
|
|
7278
|
-
preserved =
|
|
7918
|
+
preserved = preserveNumbers(text, mask);
|
|
7279
7919
|
obj = JSON.parse(preserved.text);
|
|
7280
7920
|
} catch {
|
|
7281
7921
|
return text;
|
|
@@ -7294,22 +7934,22 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
|
7294
7934
|
}
|
|
7295
7935
|
});
|
|
7296
7936
|
}
|
|
7297
|
-
return changed ?
|
|
7937
|
+
return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
|
|
7298
7938
|
}
|
|
7299
|
-
function
|
|
7939
|
+
function preserveNumbers(text, mask) {
|
|
7300
7940
|
const occupied = /* @__PURE__ */ new Set([mask]);
|
|
7301
|
-
const
|
|
7941
|
+
const found = [];
|
|
7302
7942
|
const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
|
|
7303
7943
|
for (const match of text.matchAll(tokens)) {
|
|
7304
7944
|
const token = match[0];
|
|
7305
7945
|
if (token[0] === '"') occupied.add(JSON.parse(token));
|
|
7306
|
-
else
|
|
7946
|
+
else found.push({ start: match.index, token });
|
|
7307
7947
|
}
|
|
7308
7948
|
const numbers = /* @__PURE__ */ new Map();
|
|
7309
7949
|
const chunks = [];
|
|
7310
7950
|
let offset = 0;
|
|
7311
7951
|
let index = 0;
|
|
7312
|
-
for (const { start, token } of
|
|
7952
|
+
for (const { start, token } of found) {
|
|
7313
7953
|
let marker;
|
|
7314
7954
|
do {
|
|
7315
7955
|
marker = "__PSNUMBER_" + index++ + "__";
|
|
@@ -7321,7 +7961,7 @@ function preserveBigInts(text, mask) {
|
|
|
7321
7961
|
chunks.push(text.slice(offset));
|
|
7322
7962
|
return { text: chunks.join(""), numbers };
|
|
7323
7963
|
}
|
|
7324
|
-
function
|
|
7964
|
+
function restoreNumbers(text, numbers) {
|
|
7325
7965
|
return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
|
|
7326
7966
|
}
|
|
7327
7967
|
function isHeaderMutation(action) {
|