@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.
@@ -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/request.js
430
- var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
431
- var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
432
- var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
433
- function base64DecodeUtf8(value) {
434
- const str = String(value);
435
- if (typeof Buffer !== "undefined") {
436
- return Buffer.from(str, "base64").toString("utf-8");
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
- const binary = atob(str);
439
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
440
- return new TextDecoder().decode(bytes);
453
+ return cookies;
454
+ }
455
+
456
+ // src/protect/engine/normalizer.js
457
+ var HTML_ENTITIES = {
458
+ "&": "&",
459
+ "&lt;": "<",
460
+ "&gt;": ">",
461
+ "&quot;": '"',
462
+ "&#39;": "'",
463
+ "&apos;": "'",
464
+ "&nbsp;": " ",
465
+ "&#x27;": "'",
466
+ "&#x22;": '"',
467
+ "&#x3C;": "<",
468
+ "&#x3E;": ">",
469
+ "&#x26;": "&",
470
+ "&#34;": '"',
471
+ "&#60;": "<",
472
+ "&#62;": ">",
473
+ "&#38;": "&"
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]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
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
- var RequestResolver = class {
482
- #req;
483
- #cookies;
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
- resolve(parameter) {
489
- if (!parameter || parameter === "false") {
490
- return [null];
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 (parameter === "rules") {
493
- return [null];
635
+ if (task.kind === "key") {
636
+ append(boundedJsonString(task.value));
637
+ append(":");
638
+ continue;
494
639
  }
495
- if (parameter === "raw") {
496
- return this.#resolveRaw();
640
+ nodes++;
641
+ if (nodes > MAX_RAW_NODES) {
642
+ append('"[NodeLimit]"');
643
+ continue;
497
644
  }
498
- if (parameter === "all") {
499
- return this.#resolveAll();
645
+ const value = task.value;
646
+ if (value === null || value === void 0) {
647
+ append(task.root ? "" : "null");
648
+ continue;
500
649
  }
501
- const dotIndex = parameter.indexOf(".");
502
- if (dotIndex === -1) {
503
- return [];
650
+ if (typeof value === "string") {
651
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
652
+ continue;
504
653
  }
505
- const source = parameter.substring(0, dotIndex);
506
- const key = parameter.substring(dotIndex + 1);
507
- switch (source) {
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
- // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
529
- // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
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 (key === "status") {
536
- return resp.status !== void 0 ? [String(resp.status)] : [];
662
+ if (task.depth >= MAX_RAW_DEPTH) {
663
+ append('"[DepthLimit]"');
664
+ continue;
537
665
  }
538
- if (key === "body") {
539
- return resp.body != null && resp.body !== "" ? [resp.body] : [];
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
- if (key === "headers") {
542
- const headers = resp.headers ?? {};
543
- return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
683
+ let keys;
684
+ try {
685
+ keys = Object.getOwnPropertyNames(value);
686
+ } catch {
687
+ append('"[Unreadable]"');
688
+ continue;
544
689
  }
545
- if (key.startsWith("header.")) {
546
- const name = key.slice("header.".length).toLowerCase();
547
- const headers = resp.headers ?? {};
548
- return Object.hasOwn(headers, name) ? [headers[name]] : [];
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
- try {
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
- const value = this.#getNestedValue(query, key);
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
- const value = this.#getNestedValue(body, key);
630
- return value !== void 0 ? [value] : [];
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 value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
644
- return value !== void 0 ? [value] : [];
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
- const header = this.#req.headers?.cookie;
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 Set();
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
- "&amp;": "&",
866
- "&lt;": "<",
867
- "&gt;": ">",
868
- "&quot;": '"',
869
- "&#39;": "'",
870
- "&apos;": "'",
871
- "&nbsp;": " ",
872
- "&#x27;": "'",
873
- "&#x22;": '"',
874
- "&#x3C;": "<",
875
- "&#x3E;": ">",
876
- "&#x26;": "&",
877
- "&#34;": '"',
878
- "&#60;": "<",
879
- "&#62;": ">",
880
- "&#38;": "&"
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
- if (Array.isArray(value)) {
1127
- return value.map((item) => normalizeObject(item, options, depth + 1));
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
- if (typeof value === "object" && value !== null) {
1130
- const result = {};
1131
- for (const [key, val] of Object.entries(value)) {
1132
- setOwn(result, key, normalizeObject(val, options, depth + 1));
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
- return result;
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 out = [];
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
- out.push(node);
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 out;
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
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
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(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
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((g[6] << 16 >>> 0 | g[7]) >>> 0);
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
- normalizedReq = { ...req, ...normalizeRequest(req) };
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
- for (const leaf of collectLeafValues(value)) {
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: parseCookies(headers.cookie),
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: parseCookies2(headers.cookie),
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
- function parseCookies2(header) {
3596
- const cookies = {};
3597
- if (!header) {
3598
- return cookies;
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
- for (const pair of header.split(";")) {
3601
- const idx = pair.indexOf("=");
3602
- if (idx === -1) {
3603
- continue;
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 cookies;
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
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
3730
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
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
- restores.push(() => {
3796
- if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
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
- const restore = patchHttpModule(mod.default ?? mod, block, screen, skip);
3811
- if (restore) {
4042
+ if (registry.surfaces.has(moduleName)) continue;
4043
+ const release = patchHttpModule(mod.default ?? mod, registry);
4044
+ if (release) {
3812
4045
  patchedAny = true;
3813
- restores.push(() => {
3814
- restore();
3815
- syncBuiltins();
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
- for (const restore of restores) {
4059
+ registry.screens.delete(own);
4060
+ if (registry.screens.size > 0) return;
4061
+ for (const [name, surface] of registry.surfaces) {
3824
4062
  try {
3825
- restore();
4063
+ if (surface.release()) registry.surfaces.delete(name);
3826
4064
  } catch {
3827
4065
  }
3828
4066
  }
3829
4067
  };
3830
4068
  }
3831
- function patchHttpModule(http, block, screen, skip) {
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 && block(target.url, target.host, target.method)) {
3839
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
3840
- }
3841
- if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
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, screen.lookup, skip);
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 === guardedRequest) http.request = originalRequest;
3872
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
3873
- if (GuardedClientRequest && http.ClientRequest === GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
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
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
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 !== secret) return new Response("forbidden", { status: 403 });
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
- (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
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 { record() {
5569
- }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
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 (!token || ended) return;
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
- if (p && typeof p.then === "function") await p.catch(() => {
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) return;
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
- rule,
6102
- engine: new RuleEngine({ firewall: [rule], onError }),
6103
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
6104
- // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
6105
- // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
6106
- // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
6107
- // fails such a rule CLOSED (block) instead of serving a no-op redaction.
6108
- mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
6109
- // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
6110
- // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
6111
- // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
6112
- prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
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
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
6175
- if (only && !only(rule)) continue;
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.toLowerCase();
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
- body = applyRedactors(body, spanRedactors, mask, transform);
6225
- if (body !== beforeSpan && !preservesJsonStructure(beforeSpan, body)) return { verdict: "block" };
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, spanRedactors, mask));
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, spanRedactors, mask) : 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 screenFetchRequest = async (request) => {
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, { status: response?.status, ...read.encoding ? { encoding: read.encoding } : {} });
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, hardensWithoutBody);
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
- sendHead();
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
- sendHead();
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
- sendHead();
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, answeredWithoutBody ? stillToAnswer : void 0);
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
- sendHead({
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
- const chunks = [];
6836
- let size = 0;
6837
- let overflow = false;
6838
- req.on("data", (chunk) => {
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
- chunks.push(chunk);
6845
- });
6846
- req.on("error", (err) => {
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 = protection.stop;
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 { text: new TextDecoder().decode(bytes) };
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
- return { text: new TextDecoder().decode(bytes) };
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 extractRedactors(rule) {
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
- out.push({ literal: String(m.value) });
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
- out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
7185
- } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
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
- function preservesJsonStructure(before, after) {
7208
- if (!isJson(before)) return true;
7209
- if (!isJson(after)) return false;
7210
- const expected = jsonStructure(before);
7211
- const actual = jsonStructure(after);
7212
- for (; ; ) {
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
- function* jsonStructure(text) {
7220
- const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|[^\s]/g;
7221
- for (const match of text.matchAll(tokens)) {
7222
- const token = match[0];
7223
- if (token[0] !== '"') {
7224
- yield "token:" + token;
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
- let next = match.index + token.length;
7228
- while (text[next] === " " || text[next] === " " || text[next] === "\r" || text[next] === "\n") next++;
7229
- yield text[next] === ":" ? "key:" + JSON.parse(token) : "string";
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 = preserveBigInts(text, mask);
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 ? restoreBigInts(JSON.stringify(obj), preserved.numbers) : text;
7937
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
7298
7938
  }
7299
- function preserveBigInts(text, mask) {
7939
+ function preserveNumbers(text, mask) {
7300
7940
  const occupied = /* @__PURE__ */ new Set([mask]);
7301
- const integers = [];
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 if (/^-?\d{16,}$/.test(token)) integers.push({ start: match.index, token });
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 integers) {
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 restoreBigInts(text, numbers) {
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) {