@patchstack/connect 0.5.12 → 0.5.16

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();
@@ -718,422 +1124,144 @@ var RequestResolver = class {
718
1124
  if (body === void 0 || body === null) {
719
1125
  return [];
720
1126
  }
721
- if (typeof body === "string") {
722
- return [body];
723
- }
724
- try {
725
- return [JSON.stringify(body)];
726
- } catch {
727
- return [String(body)];
728
- }
729
- }
730
- #resolveAll() {
731
- const parts = [];
732
- const uri = this.#req.originalUrl ?? this.#req.url ?? "/";
733
- parts.push(uri);
734
- const queryString = uri.includes("?") ? uri.split("?")[1] : "";
735
- if (queryString) {
736
- parts.push(queryString);
737
- }
738
- const body = this.#req.body;
739
- if (body) {
740
- parts.push(typeof body === "string" ? body : JSON.stringify(body));
741
- }
742
- if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
743
- parts.push(this.#req._rawBody);
744
- }
745
- const headers = this.#req.headers ?? {};
746
- const excludedHeaders = /* @__PURE__ */ new Set([
747
- "host",
748
- "connection",
749
- "cache-control",
750
- "accept",
751
- "accept-encoding",
752
- "accept-language",
753
- "priority",
754
- "sec-ch-ua",
755
- "sec-ch-ua-mobile",
756
- "sec-ch-ua-platform",
757
- "sec-fetch-dest",
758
- "sec-fetch-mode",
759
- "sec-fetch-site",
760
- "sec-fetch-user",
761
- "upgrade-insecure-requests"
762
- ]);
763
- for (const [name, value] of Object.entries(headers)) {
764
- if (!excludedHeaders.has(name)) {
765
- parts.push(`${name}: ${value}`);
766
- }
767
- }
768
- const cookies = this.#parseCookies();
769
- const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ");
770
- if (cookieStr) {
771
- parts.push(cookieStr);
772
- }
773
- return [parts.join(" ")];
774
- }
775
- #resolveWildcard(obj, pattern) {
776
- if (typeof obj !== "object" || obj === null) {
777
- return [];
778
- }
779
- const prefix = pattern.slice(0, -1);
780
- const values = [];
781
- for (const [key, value] of Object.entries(obj)) {
782
- if (key.startsWith(prefix)) {
783
- values.push(value);
784
- }
785
- }
786
- return values;
787
- }
788
- #getNestedValue(obj, key) {
789
- if (typeof obj !== "object" || obj === null) {
790
- return void 0;
791
- }
792
- const own = (o, k) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
793
- if (own(obj, key)) {
794
- return obj[key];
795
- }
796
- const parts = key.split(".");
797
- let current = obj;
798
- for (const part of parts) {
799
- if (!own(current, part)) {
800
- return void 0;
801
- }
802
- current = current[part];
803
- }
804
- return current;
805
- }
806
- #parseCookies() {
807
- if (this.#cookies !== null) {
808
- return this.#cookies;
809
- }
810
- if (this.#req.cookies) {
811
- this.#cookies = this.#req.cookies;
812
- return this.#cookies;
813
- }
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;
830
- return this.#cookies;
831
- }
832
- };
833
-
834
- // src/protect/notify.js
835
- var reported = /* @__PURE__ */ new Set();
836
- function notify(fn, arg, label) {
837
- if (typeof fn !== "function") return false;
838
- try {
839
- const result = fn(arg);
840
- if (result !== null && typeof result === "object" && typeof result.then === "function") {
841
- try {
842
- result.then(void 0, (err) => warnOnce2(label, err));
843
- } 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;
1127
+ if (typeof body === "string") {
1128
+ return [body];
1022
1129
  }
1023
- if (typeof value === "string") {
1024
- append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
1025
- continue;
1130
+ try {
1131
+ return [JSON.stringify(body)];
1132
+ } catch {
1133
+ return [String(body)];
1026
1134
  }
1027
- if (typeof value !== "object") {
1028
- append(String(value).slice(0, MAX_RAW_CHARS));
1029
- continue;
1135
+ }
1136
+ #resolveAll() {
1137
+ const parts = [];
1138
+ const uri = this.#req.originalUrl ?? this.#req.url ?? "/";
1139
+ parts.push(uri);
1140
+ const queryString = uri.includes("?") ? uri.split("?")[1] : "";
1141
+ if (queryString) {
1142
+ parts.push(queryString);
1030
1143
  }
1031
- if (visited.has(value)) {
1032
- append("[Circular]");
1033
- continue;
1144
+ const body = this.#req.body;
1145
+ if (body) {
1146
+ parts.push(typeof body === "string" ? body : JSON.stringify(body));
1034
1147
  }
1035
- if (task.depth >= MAX_RAW_DEPTH) {
1036
- append('"[DepthLimit]"');
1037
- continue;
1148
+ if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
1149
+ parts.push(this.#req._rawBody);
1038
1150
  }
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 });
1151
+ const headers = this.#req.headers ?? {};
1152
+ const excludedHeaders = /* @__PURE__ */ new Set([
1153
+ "host",
1154
+ "connection",
1155
+ "cache-control",
1156
+ "accept",
1157
+ "accept-encoding",
1158
+ "accept-language",
1159
+ "priority",
1160
+ "sec-ch-ua",
1161
+ "sec-ch-ua-mobile",
1162
+ "sec-ch-ua-platform",
1163
+ "sec-fetch-dest",
1164
+ "sec-fetch-mode",
1165
+ "sec-fetch-site",
1166
+ "sec-fetch-user",
1167
+ "upgrade-insecure-requests"
1168
+ ]);
1169
+ for (const [name, value] of Object.entries(headers)) {
1170
+ if (!excludedHeaders.has(name)) {
1171
+ parts.push(`${name}: ${value}`);
1046
1172
  }
1047
- if (value.length > length2) {
1048
- if (length2 > 0) items.push({ kind: "text", text: "," });
1049
- items.push({ kind: "text", text: '"[EntryLimit]"' });
1173
+ }
1174
+ const cookies = this.#parseCookies();
1175
+ const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ");
1176
+ if (cookieStr) {
1177
+ parts.push(cookieStr);
1178
+ }
1179
+ return [parts.join(" ")];
1180
+ }
1181
+ #resolveWildcard(obj, pattern) {
1182
+ if (typeof obj !== "object" || obj === null) {
1183
+ return [];
1184
+ }
1185
+ const prefix = pattern.slice(0, -1);
1186
+ const values = [];
1187
+ for (const [key, value] of Object.entries(obj)) {
1188
+ if (key.startsWith(prefix)) {
1189
+ values.push(value);
1050
1190
  }
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
1191
  }
1056
- let keys;
1057
- try {
1058
- keys = Object.getOwnPropertyNames(value);
1059
- } catch {
1060
- append('"[Unreadable]"');
1061
- continue;
1192
+ return values;
1193
+ }
1194
+ #getNestedValue(obj, key) {
1195
+ if (typeof obj !== "object" || obj === null) {
1196
+ return void 0;
1062
1197
  }
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 });
1198
+ const own = (o, k) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
1199
+ if (own(obj, key)) {
1200
+ return obj[key];
1070
1201
  }
1071
- if (keys.length > length) {
1072
- if (length > 0) entries.push({ kind: "text", text: "," });
1073
- entries.push({ kind: "text", text: '"[EntryLimit]":null' });
1202
+ const parts = key.split(".");
1203
+ let current = obj;
1204
+ for (const part of parts) {
1205
+ if (!own(current, part)) {
1206
+ return void 0;
1207
+ }
1208
+ current = current[part];
1074
1209
  }
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: "{" });
1210
+ return current;
1078
1211
  }
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) {
1212
+ #parseCookies() {
1213
+ if (this.#cookies !== null) {
1214
+ return this.#cookies;
1215
+ }
1216
+ if (this.#req.cookies) {
1217
+ this.#cookies = this.#req.cookies;
1218
+ return this.#cookies;
1219
+ }
1220
+ this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
1221
+ return this.#cookies;
1222
+ }
1223
+ };
1224
+
1225
+ // src/protect/notify.js
1226
+ var reported = /* @__PURE__ */ new WeakMap();
1227
+ var warnings = 0;
1228
+ var MAX_WARNINGS = 20;
1229
+ function notify(fn, arg, label) {
1230
+ if (typeof fn !== "function") return false;
1085
1231
  try {
1086
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
1087
- return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
1088
- } catch {
1089
- return "[Unreadable]";
1232
+ const result = fn(arg);
1233
+ if (result !== null && typeof result === "object" && typeof result.then === "function") {
1234
+ try {
1235
+ result.then(void 0, (err) => warnOnce2(fn, label, err));
1236
+ } catch {
1237
+ }
1238
+ }
1239
+ return true;
1240
+ } catch (err) {
1241
+ warnOnce2(fn, label, err);
1242
+ return false;
1090
1243
  }
1091
1244
  }
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;
1125
- }
1126
- if (Array.isArray(value)) {
1127
- return value.map((item) => normalizeObject(item, options, depth + 1));
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
@@ -1235,6 +1363,184 @@ function hasAdjacentUnbounded(body, flags) {
1235
1363
  }
1236
1364
  return false;
1237
1365
  }
1366
+ function parseRepetition(body) {
1367
+ let i = 0;
1368
+ const parseAlternation = () => {
1369
+ const branches = [parseSequence()];
1370
+ while (body[i] === "|") {
1371
+ i++;
1372
+ branches.push(parseSequence());
1373
+ }
1374
+ return branches.length === 1 ? branches[0] : { type: "alt", branches };
1375
+ };
1376
+ const parseSequence = () => {
1377
+ const items = [];
1378
+ while (i < body.length && body[i] !== "|" && body[i] !== ")") {
1379
+ const atom = parseAtom();
1380
+ if (atom === null) return null;
1381
+ const quantified = parseQuantifier(atom);
1382
+ if (quantified === null) return null;
1383
+ items.push(quantified);
1384
+ }
1385
+ return { type: "seq", items };
1386
+ };
1387
+ const parseAtom = () => {
1388
+ const ch = body[i];
1389
+ if (ch === "(") {
1390
+ const start2 = i;
1391
+ i++;
1392
+ let kind = "capture";
1393
+ if (body.startsWith("?:", i)) {
1394
+ kind = "group";
1395
+ i += 2;
1396
+ } else if (body.startsWith("?=", i) || body.startsWith("?!", i)) {
1397
+ kind = "look";
1398
+ i += 2;
1399
+ } else if (body.startsWith("?<=", i) || body.startsWith("?<!", i)) {
1400
+ kind = "look";
1401
+ i += 3;
1402
+ } else if (body.startsWith("?<", i)) {
1403
+ const close = body.indexOf(">", i + 2);
1404
+ if (close === -1) return null;
1405
+ i = close + 1;
1406
+ } else if (body[i] === "?") {
1407
+ return null;
1408
+ }
1409
+ const inner = parseAlternation();
1410
+ if (inner === null || body[i] !== ")") return null;
1411
+ i++;
1412
+ return { type: "group", kind, body: inner, source: body.slice(start2, i) };
1413
+ }
1414
+ const start = i;
1415
+ if (ch === "[") {
1416
+ i++;
1417
+ if (body[i] === "^") i++;
1418
+ if (body[i] === "]") i++;
1419
+ while (i < body.length && body[i] !== "]") i += body[i] === "\\" ? 2 : 1;
1420
+ if (body[i] !== "]") return null;
1421
+ i++;
1422
+ return { type: "atom", source: body.slice(start, i) };
1423
+ }
1424
+ if (ch === "\\") {
1425
+ const next = body[i + 1];
1426
+ if (next === void 0) return null;
1427
+ if ((next === "p" || next === "P" || next === "u") && body[i + 2] === "{") {
1428
+ const close = body.indexOf("}", i + 3);
1429
+ if (close === -1) return null;
1430
+ i = close + 1;
1431
+ } else if (next === "k" && body[i + 2] === "<") {
1432
+ const close = body.indexOf(">", i + 3);
1433
+ if (close === -1) return null;
1434
+ i = close + 1;
1435
+ } else {
1436
+ i += 2;
1437
+ }
1438
+ return { type: /[bB]/.test(next) ? "assert" : "atom", source: body.slice(start, i) };
1439
+ }
1440
+ if (ch === "^" || ch === "$") {
1441
+ i++;
1442
+ return { type: "assert" };
1443
+ }
1444
+ if (ch === "*" || ch === "+" || ch === "?") return null;
1445
+ i++;
1446
+ return { type: "atom", source: body.slice(start, i) };
1447
+ };
1448
+ const parseQuantifier = (atom) => {
1449
+ let min;
1450
+ let max;
1451
+ const ch = body[i];
1452
+ if (ch === "*") [min, max, i] = [0, Infinity, i + 1];
1453
+ else if (ch === "+") [min, max, i] = [1, Infinity, i + 1];
1454
+ else if (ch === "?") [min, max, i] = [0, 1, i + 1];
1455
+ else if (ch === "{") {
1456
+ const bounds = /^\{(\d+)(,(\d*))?\}/.exec(body.slice(i));
1457
+ if (!bounds) return atom;
1458
+ min = Number(bounds[1]);
1459
+ max = bounds[2] === void 0 ? min : bounds[3] === "" ? Infinity : Number(bounds[3]);
1460
+ i += bounds[0].length;
1461
+ } else {
1462
+ return atom;
1463
+ }
1464
+ if (body[i] === "?") i++;
1465
+ if (body[i] === "*" || body[i] === "+" || body[i] === "?" || body[i] === "{") return null;
1466
+ return { type: "quant", min, max, body: atom };
1467
+ };
1468
+ const root = parseAlternation();
1469
+ return root !== null && i === body.length ? root : null;
1470
+ }
1471
+ function* nodesOf(node) {
1472
+ if (!node) return;
1473
+ yield node;
1474
+ if (node.type === "alt") for (const branch of node.branches) yield* nodesOf(branch);
1475
+ else if (node.type === "seq") for (const item of node.items) yield* nodesOf(item);
1476
+ else if (node.type === "group" || node.type === "quant") yield* nodesOf(node.body);
1477
+ }
1478
+ var BOUNDED_NESTING_LIMIT = 100;
1479
+ var BOUNDED_ALTERNATION_LIMIT = 16;
1480
+ var OPTIONAL_REPEAT_LIMIT = 8;
1481
+ function hasFencedRepetitions(quant, flags) {
1482
+ let body = quant.body;
1483
+ if (body.type === "group" && body.kind !== "look") body = body.body;
1484
+ if (body.type !== "seq" || body.items.length < 2) return false;
1485
+ const variable = [...nodesOf(body)].filter((n) => n.type === "quant" && (n.max > 1 || n.min < n.max));
1486
+ if (variable.some((n) => n.body.type !== "atom" || n.body.source === void 0)) return false;
1487
+ const atomFlags = flags.replace(/[^i]/g, "");
1488
+ const unicode = /[uv]/.test(flags);
1489
+ let insides;
1490
+ try {
1491
+ insides = variable.map((n) => new RegExp(`^(?:${n.body.source})$`, `${atomFlags}${unicode ? "u" : ""}`));
1492
+ } catch {
1493
+ return false;
1494
+ }
1495
+ const literalOf = (source) => {
1496
+ if (source.length === 1 && !".[]()|^$\\".includes(source)) return source;
1497
+ if (source.length === 2 && source[0] === "\\" && /[^\w\s]/.test(source[1])) return source[1];
1498
+ return null;
1499
+ };
1500
+ const disjoint = (edge) => {
1501
+ if (edge?.type !== "atom" || edge.source === void 0) return false;
1502
+ const literal = literalOf(edge.source);
1503
+ if (literal !== null) return insides.every((re) => !re.test(literal));
1504
+ if (unicode) return false;
1505
+ let fence;
1506
+ try {
1507
+ fence = new RegExp(`^(?:${edge.source})$`, atomFlags);
1508
+ } catch {
1509
+ return false;
1510
+ }
1511
+ for (let unit = 0; unit <= 65535; unit++) {
1512
+ const ch = String.fromCharCode(unit);
1513
+ if (fence.test(ch) && insides.some((re) => re.test(ch))) return false;
1514
+ }
1515
+ return true;
1516
+ };
1517
+ return disjoint(body.items[0]) || disjoint(body.items[body.items.length - 1]);
1518
+ }
1519
+ function hasCatastrophicShape(body, flags) {
1520
+ const root = parseRepetition(body);
1521
+ if (root === null) return null;
1522
+ for (const node of nodesOf(root)) {
1523
+ if (node.type === "group" && node.kind === "look" && hasAdjacentUnbounded(lookaroundBody(node.source), flags)) return true;
1524
+ if (node.type !== "quant" || node.max <= 1) continue;
1525
+ const fenced = hasFencedRepetitions(node, flags);
1526
+ for (const inner of nodesOf(node.body)) {
1527
+ if (inner === node.body && inner.type === "quant") continue;
1528
+ if (inner.type === "quant" && inner.max > 1 && !fenced) {
1529
+ if (node.max === Infinity || inner.max === Infinity || node.max * inner.max > BOUNDED_NESTING_LIMIT) return true;
1530
+ }
1531
+ if (inner.type === "quant" && inner.min === 0 && inner.max <= 1 && !fenced && (node.max === Infinity || node.max > OPTIONAL_REPEAT_LIMIT)) {
1532
+ return true;
1533
+ }
1534
+ if (inner.type === "alt" && inner.branches.length > 1 && (node.max === Infinity || node.max > BOUNDED_ALTERNATION_LIMIT)) {
1535
+ return true;
1536
+ }
1537
+ }
1538
+ }
1539
+ return false;
1540
+ }
1541
+ function lookaroundBody(source) {
1542
+ return source.slice(source.startsWith("(?<") ? 4 : 3, -1);
1543
+ }
1238
1544
  function groupEnd(body, start) {
1239
1545
  let depth = 0;
1240
1546
  let inClass = false;
@@ -1261,6 +1567,8 @@ function warnRejectedPatternOnce(pattern) {
1261
1567
  );
1262
1568
  }
1263
1569
  var MAX_PATTERN_LENGTH = 1e3;
1570
+ var regexVerdicts = /* @__PURE__ */ new Map();
1571
+ var REGEX_VERDICT_LIMIT = 2048;
1264
1572
  function safeRegExp(pattern) {
1265
1573
  if (!pattern) {
1266
1574
  return null;
@@ -1272,16 +1580,32 @@ function safeRegExp(pattern) {
1272
1580
  if (!match) {
1273
1581
  return null;
1274
1582
  }
1275
- for (const dangerous of REDOS_PATTERNS) {
1276
- if (dangerous.test(match[1])) return null;
1583
+ let safe = regexVerdicts.get(pattern);
1584
+ if (safe === void 0) {
1585
+ safe = isSafeBody(match[1], match[2]);
1586
+ if (regexVerdicts.size >= REGEX_VERDICT_LIMIT) regexVerdicts.clear();
1587
+ regexVerdicts.set(pattern, safe);
1277
1588
  }
1278
- if (hasAdjacentUnbounded(match[1], match[2])) return null;
1589
+ if (!safe) return null;
1279
1590
  try {
1280
1591
  return new RegExp(match[1], match[2]);
1281
1592
  } catch {
1282
1593
  return null;
1283
1594
  }
1284
1595
  }
1596
+ function isSafeBody(body, flags) {
1597
+ for (const dangerous of REDOS_PATTERNS) {
1598
+ if (dangerous.test(body)) return false;
1599
+ }
1600
+ if (hasAdjacentUnbounded(body, flags)) return false;
1601
+ if (hasCatastrophicShape(body, flags) === true) return false;
1602
+ try {
1603
+ new RegExp(body, flags);
1604
+ } catch {
1605
+ return false;
1606
+ }
1607
+ return true;
1608
+ }
1285
1609
  function ctypeResult(strValue, isClass, matchVal) {
1286
1610
  if (strValue === "") {
1287
1611
  return false;
@@ -1325,17 +1649,21 @@ function arrayKeyValue(value, matchObj) {
1325
1649
  }
1326
1650
  var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
1327
1651
  function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
1328
- const out = [];
1652
+ const leaves = [];
1329
1653
  const stack = [[root, 0]];
1330
1654
  let visited = 0;
1655
+ let truncated = false;
1331
1656
  while (stack.length) {
1332
1657
  const [node, depth] = stack.pop();
1333
1658
  if (node === null || node === void 0) continue;
1334
1659
  if (typeof node !== "object") {
1335
- out.push(node);
1660
+ leaves.push(node);
1661
+ continue;
1662
+ }
1663
+ if (depth >= maxDepth || visited >= nodeCap) {
1664
+ truncated = true;
1336
1665
  continue;
1337
1666
  }
1338
- if (depth >= maxDepth || visited >= nodeCap) continue;
1339
1667
  visited++;
1340
1668
  if (Array.isArray(node)) {
1341
1669
  for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
@@ -1343,7 +1671,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
1343
1671
  for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
1344
1672
  }
1345
1673
  }
1346
- 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
+ }
1347
1686
  }
1348
1687
  var warnedKeys = /* @__PURE__ */ new Set();
1349
1688
  function warnOnce3(key, message) {
@@ -1361,12 +1700,22 @@ function warnUnsupportedMatchType(type) {
1361
1700
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
1362
1701
  );
1363
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
+ }
1364
1711
  function hostFromValue(value) {
1365
- const raw = String(value ?? "").trim();
1712
+ const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
1366
1713
  if (raw === "") return "";
1367
- 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)) {
1368
1717
  try {
1369
- return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
1718
+ return new URL(relative ? `http:${raw}` : raw).hostname;
1370
1719
  } catch {
1371
1720
  return raw;
1372
1721
  }
@@ -1394,10 +1743,18 @@ function isInternalHost(hostname) {
1394
1743
  const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
1395
1744
  if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
1396
1745
  if ((g[0] & 65472) === 65152) return true;
1746
+ if ((g[0] & 65472) === 65216) return true;
1397
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;
1398
1750
  if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
1399
- return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
1751
+ return isPrivateV4Int(low32);
1400
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);
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);
1401
1758
  return false;
1402
1759
  }
1403
1760
  const v4 = parseIPv4ToInt(host);
@@ -1407,6 +1764,10 @@ function isInternalHost(hostname) {
1407
1764
  function isPrivateV4Int(n) {
1408
1765
  const a = n >>> 24 & 255;
1409
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;
1410
1771
  if (a === 127 || a === 10 || a === 0) return true;
1411
1772
  if (a === 169 && b === 254) return true;
1412
1773
  if (a === 172 && b >= 16 && b <= 31) return true;
@@ -1755,8 +2116,12 @@ var RuleEngine = class {
1755
2116
  let normalizedReq;
1756
2117
  let resolver;
1757
2118
  try {
1758
- normalizedReq = { ...req, ...normalizeRequest(req) };
2119
+ let limited = false;
2120
+ normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
2121
+ limited = true;
2122
+ } }) };
1759
2123
  resolver = new RequestResolver(normalizedReq);
2124
+ if (limited) resolver.noteSkip("container-cap");
1760
2125
  } catch (err) {
1761
2126
  this.#reportError(err);
1762
2127
  return { blocked: false, rule: null, message: null };
@@ -1782,14 +2147,15 @@ var RuleEngine = class {
1782
2147
  // that re-read the request instead would be reading it a second time: a getter, a stream or
1783
2148
  // anything else that answers once can give a different value, and evidence that disagrees
1784
2149
  // with the match it belongs to is worse than none.
1785
- resolver
2150
+ resolver,
2151
+ ...skipsOf(resolver)
1786
2152
  };
1787
2153
  }
1788
2154
  } catch (err) {
1789
2155
  this.#reportError(err);
1790
2156
  }
1791
2157
  }
1792
- return { blocked: false, rule: null, message: null };
2158
+ return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
1793
2159
  }
1794
2160
  #evaluateRule(conditions, resolver) {
1795
2161
  const inclusiveConditions = conditions.filter((c) => c.inclusive);
@@ -1845,9 +2211,15 @@ var RuleEngine = class {
1845
2211
  if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
1846
2212
  if (matchValue(match.type, value, match.value, match)) return true;
1847
2213
  } else {
1848
- for (const leaf of collectLeafValues(value)) {
2214
+ const { leaves, truncated } = collectLeafValues(value);
2215
+ for (const leaf of leaves) {
1849
2216
  if (matchValue(match.type, leaf, match.value, match)) return true;
1850
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
+ }
1851
2223
  }
1852
2224
  continue;
1853
2225
  }
@@ -2234,6 +2606,9 @@ function conditionShapeProblem(condition) {
2234
2606
  if (nested && !named) {
2235
2607
  return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2236
2608
  }
2609
+ if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
2610
+ return "inclusive must be true or false";
2611
+ }
2237
2612
  if (named && nested) {
2238
2613
  if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2239
2614
  if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
@@ -3176,7 +3551,7 @@ async function fromFetchRequest(request, options = {}) {
3176
3551
  // that guessed differently would attribute one request to two addresses.
3177
3552
  ip: client.ip ?? "",
3178
3553
  _clientIp: client,
3179
- cookies: parseCookies(headers.cookie),
3554
+ cookies: parseCookieHeader(headers.cookie),
3180
3555
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
3181
3556
  // drops, so prototype-pollution rules on `raw` are robust.
3182
3557
  _rawBody: rawBody,
@@ -3238,7 +3613,7 @@ function concatChunks(chunks, total) {
3238
3613
  }
3239
3614
  function parseBody(rawBody, contentType) {
3240
3615
  const ct = String(contentType || "").toLowerCase();
3241
- const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
3616
+ const isJson2 = ct.includes("application/json") || /\+json\b/.test(ct);
3242
3617
  const isForm = ct.includes("application/x-www-form-urlencoded");
3243
3618
  const isMultipart = ct.includes("multipart/form-data");
3244
3619
  const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
@@ -3252,7 +3627,7 @@ function parseBody(rawBody, contentType) {
3252
3627
  for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
3253
3628
  return { body, files: void 0 };
3254
3629
  }
3255
- if (isJson || isAmbiguous) {
3630
+ if (isJson2 || isAmbiguous) {
3256
3631
  try {
3257
3632
  const parsed = JSON.parse(rawBody);
3258
3633
  if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
@@ -3268,38 +3643,115 @@ function parseMultipart(rawBody, boundary) {
3268
3643
  const sep = /\r?\n\r?\n/.exec(part);
3269
3644
  if (!sep) continue;
3270
3645
  const rawHeaders = part.slice(0, sep.index);
3271
- const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
3272
- if (!disposition) continue;
3273
- const name = /name="([^"]*)"/i.exec(disposition)?.[1];
3274
- if (name == null) continue;
3646
+ const disposition = /content-disposition:([^\r\n]*)/i.exec(rawHeaders)?.[1];
3647
+ if (disposition === void 0) continue;
3648
+ const { names, filename } = dispositionFields(disposition);
3649
+ if (names.length === 0) continue;
3275
3650
  const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
3276
- const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
3277
- if (filename !== void 0) {
3278
- const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
3279
- const file = { filename, type: partType, content };
3280
- appendOwn(files, name, file);
3281
- } else {
3282
- appendOwn(body, name, content);
3651
+ const file = filename === void 0 ? void 0 : { filename, type: /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "", content };
3652
+ for (const name of names) {
3653
+ if (file) appendOwn(files, name, file);
3654
+ else appendOwn(body, name, content);
3283
3655
  }
3284
3656
  }
3285
3657
  return { body, files };
3286
3658
  }
3287
- function parseCookies(header) {
3288
- const cookies = {};
3289
- if (!header) {
3290
- return cookies;
3291
- }
3292
- for (const pair of header.split(";")) {
3293
- const idx = pair.indexOf("=");
3294
- if (idx === -1) {
3295
- continue;
3659
+ function dispositionFields(value) {
3660
+ const text = String(value);
3661
+ const names = /* @__PURE__ */ new Set();
3662
+ let filename;
3663
+ let extendedFilename;
3664
+ let i = text.indexOf(";");
3665
+ while (i !== -1 && i < text.length) {
3666
+ i++;
3667
+ while (text[i] === " " || text[i] === " ") i++;
3668
+ let key = "";
3669
+ while (i < text.length && text[i] !== "=" && text[i] !== ";") key += text[i++];
3670
+ key = key.trim().toLowerCase();
3671
+ let val = null;
3672
+ if (text[i] === "=") {
3673
+ i++;
3674
+ while (text[i] === " " || text[i] === " ") i++;
3675
+ if (text[i] === '"') {
3676
+ i++;
3677
+ val = "";
3678
+ while (i < text.length && text[i] !== '"') {
3679
+ if (text[i] === "\\" && i + 1 < text.length) i++;
3680
+ val += text[i++];
3681
+ }
3682
+ i++;
3683
+ while (i < text.length && text[i] !== ";") i++;
3684
+ } else {
3685
+ let bare = "";
3686
+ while (i < text.length && text[i] !== ";") bare += text[i++];
3687
+ val = bare.trim();
3688
+ }
3689
+ }
3690
+ if (val !== null) {
3691
+ if (key === "name") names.add(val);
3692
+ else if (key === "filename") filename = val;
3693
+ else if (key === "filename*") extendedFilename = decodeExtendedValue(val);
3296
3694
  }
3297
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
3695
+ i = i < text.length && text[i] === ";" ? i : -1;
3696
+ }
3697
+ return { names: [...names], filename: extendedFilename ?? filename };
3698
+ }
3699
+ function decodeExtendedValue(value) {
3700
+ const match = /^([^']*)'[^']*'(.*)$/.exec(value);
3701
+ if (!match) return value;
3702
+ try {
3703
+ return decodeURIComponent(match[2]);
3704
+ } catch {
3705
+ return match[2];
3298
3706
  }
3299
- return cookies;
3300
3707
  }
3301
3708
 
3302
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
+ }
3303
3755
  function fromNodeRequest(req, rawBody = "", options = {}) {
3304
3756
  const method = (req.method || "GET").toUpperCase();
3305
3757
  const headers = {};
@@ -3345,29 +3797,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
3345
3797
  headers,
3346
3798
  ip: client.ip ?? "",
3347
3799
  _clientIp: client,
3348
- cookies: parseCookies2(headers.cookie),
3800
+ cookies: parseCookieHeader(headers.cookie),
3349
3801
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
3350
3802
  _rawBody: rawBody
3351
3803
  };
3352
3804
  }
3353
- function parseCookies2(header) {
3354
- const cookies = {};
3355
- if (!header) {
3356
- 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;
3357
3825
  }
3358
- for (const pair of header.split(";")) {
3359
- const idx = pair.indexOf("=");
3360
- if (idx === -1) {
3361
- continue;
3362
- }
3363
- 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;
3364
3831
  }
3365
- return cookies;
3832
+ return refused;
3366
3833
  }
3367
-
3368
- // src/protect/egress.js
3369
3834
  async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
3370
- const restores = [];
3371
3835
  if (typeof shouldBlock !== "function") return () => {
3372
3836
  };
3373
3837
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
@@ -3412,6 +3876,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3412
3876
  resolve(false);
3413
3877
  }
3414
3878
  });
3879
+ const registry = egressRegistry();
3880
+ const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
3881
+ registry.screens.add(own);
3415
3882
  const originalFetch = globalThis.fetch;
3416
3883
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
3417
3884
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
@@ -3484,15 +3951,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3484
3951
  } catch {
3485
3952
  host = null;
3486
3953
  }
3487
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
3488
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
3489
- }
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);
3490
3958
  };
3491
3959
  const guarded = async (input, init) => {
3492
3960
  let cur;
3493
3961
  try {
3494
3962
  cur = new Request(input, { ...init || {}, redirect: "manual" });
3495
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", {});
3496
3967
  return originalFetch(input, init);
3497
3968
  }
3498
3969
  const callerRedirect = init && init.redirect || input && input.redirect || "follow";
@@ -3503,13 +3974,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3503
3974
  const headers = new Headers(cur.headers);
3504
3975
  const signal = cur.signal;
3505
3976
  const replay = captureReplayBody(cur);
3977
+ const { body: _callerBody, ...callerOptions } = init || {};
3506
3978
  let body;
3507
3979
  for (let hop = 0; ; hop++) {
3508
3980
  let resp;
3509
3981
  try {
3510
- resp = await originalFetch(
3511
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3512
- );
3982
+ resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
3513
3983
  } catch (error) {
3514
3984
  replay.cancel();
3515
3985
  throw error;
@@ -3517,7 +3987,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3517
3987
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
3518
3988
  if (!location) {
3519
3989
  replay.cancel();
3520
- return resp;
3990
+ return hop === 0 ? resp : markRedirected(resp);
3521
3991
  }
3522
3992
  await discardResponseBody(resp);
3523
3993
  if (hop >= MAX_REDIRECTS) {
@@ -3550,43 +4020,84 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3550
4020
  };
3551
4021
  guarded.__patchstackGuarded = true;
3552
4022
  globalThis.fetch = guarded;
3553
- restores.push(() => {
3554
- 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
+ }
3555
4029
  });
3556
4030
  }
4031
+ let syncBuiltins = () => {
4032
+ };
4033
+ try {
4034
+ const { syncBuiltinESMExports } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1));
4035
+ if (typeof syncBuiltinESMExports === "function") syncBuiltins = syncBuiltinESMExports;
4036
+ } catch {
4037
+ }
4038
+ let patchedAny = false;
3557
4039
  for (const moduleName of ["node:http", "node:https"]) {
3558
4040
  try {
3559
4041
  const mod = await import(moduleName);
3560
- const restore = patchHttpModule(mod.default ?? mod, block, screen, skip);
3561
- if (restore) restores.push(restore);
4042
+ if (registry.surfaces.has(moduleName)) continue;
4043
+ const release = patchHttpModule(mod.default ?? mod, registry);
4044
+ if (release) {
4045
+ patchedAny = true;
4046
+ registry.surfaces.set(moduleName, {
4047
+ release() {
4048
+ if (!release()) return false;
4049
+ syncBuiltins();
4050
+ return true;
4051
+ }
4052
+ });
4053
+ }
3562
4054
  } catch {
3563
4055
  }
3564
4056
  }
4057
+ if (patchedAny) syncBuiltins();
3565
4058
  return () => {
3566
- 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) {
3567
4062
  try {
3568
- restore();
4063
+ if (surface.release()) registry.surfaces.delete(name);
3569
4064
  } catch {
3570
4065
  }
3571
4066
  }
3572
4067
  };
3573
4068
  }
3574
- 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) {
3575
4077
  if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
3576
4078
  const originalRequest = http.request;
3577
4079
  const originalGet = http.get;
3578
- const wrap = (original) => function(...args) {
4080
+ const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
4081
+ const guardArgs = (args) => {
3579
4082
  const target = extractHttpTarget(args);
3580
- if (target && block(target.url, target.host, target.method)) {
3581
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
3582
- }
3583
- 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
+ };
3584
4092
  try {
3585
- args = withScreeningLookup(args, target, block, screen.lookup, skip);
4093
+ return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
3586
4094
  } catch {
3587
4095
  }
3588
4096
  }
3589
- return original.apply(this, args);
4097
+ return args;
4098
+ };
4099
+ const wrap = (original) => function(...args) {
4100
+ return original.apply(this, guardArgs(args));
3590
4101
  };
3591
4102
  const guardedRequest = wrap(originalRequest);
3592
4103
  http.request = guardedRequest;
@@ -3595,11 +4106,26 @@ function patchHttpModule(http, block, screen, skip) {
3595
4106
  guardedGet = wrap(originalGet);
3596
4107
  http.get = guardedGet;
3597
4108
  }
4109
+ let GuardedClientRequest;
4110
+ if (OriginalClientRequest) {
4111
+ GuardedClientRequest = function ClientRequest(...args) {
4112
+ const screened = guardArgs(args);
4113
+ return new.target ? Reflect.construct(OriginalClientRequest, screened, new.target) : Reflect.construct(OriginalClientRequest, screened);
4114
+ };
4115
+ GuardedClientRequest.prototype = OriginalClientRequest.prototype;
4116
+ Object.setPrototypeOf(GuardedClientRequest, OriginalClientRequest);
4117
+ http.ClientRequest = GuardedClientRequest;
4118
+ }
3598
4119
  http.__patchstackGuarded = true;
3599
4120
  return () => {
3600
- if (http.request === guardedRequest) http.request = originalRequest;
3601
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
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;
3602
4127
  delete http.__patchstackGuarded;
4128
+ return true;
3603
4129
  };
3604
4130
  }
3605
4131
  function extractHttpTarget(args) {
@@ -3608,19 +4134,27 @@ function extractHttpTarget(args) {
3608
4134
  if (typeof first === "string" || first instanceof URL) {
3609
4135
  const url = new URL(String(first));
3610
4136
  const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
3611
- return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
3612
- }
3613
- if (first && typeof first === "object") {
3614
- const host = normalizeHost(first.hostname || first.host) || "localhost";
3615
- const protocol = first.protocol || "http:";
3616
- const port = first.port ? `:${first.port}` : "";
3617
- const path = first.path || "/";
3618
- return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
4137
+ return targetOf({ ...optionsFromUrl(url), ...opts ?? {} });
3619
4138
  }
4139
+ if (first && typeof first === "object") return targetOf(first);
3620
4140
  } catch {
3621
4141
  }
3622
4142
  return null;
3623
4143
  }
4144
+ function optionsFromUrl(url) {
4145
+ const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
4146
+ const options = { protocol: url.protocol, hostname, path: `${url.pathname || "/"}${url.search}` };
4147
+ if (url.port !== "") options.port = Number(url.port);
4148
+ return options;
4149
+ }
4150
+ function targetOf(options) {
4151
+ const host = normalizeHost(options.hostname || options.host) || "localhost";
4152
+ const protocol = options.protocol || "http:";
4153
+ const port = options.port ? `:${options.port}` : "";
4154
+ const path = options.path || "/";
4155
+ const authority = host.includes(":") ? `[${host}]` : host;
4156
+ return { url: `${protocol}//${authority}${port}${path}`, host, method: options.method || "GET" };
4157
+ }
3624
4158
  function normalizeHost(raw) {
3625
4159
  const host = String(raw || "").trim();
3626
4160
  const bracketed = /^\[([^\]]+)\]/.exec(host);
@@ -4001,19 +4535,49 @@ async function loadFs() {
4001
4535
  }
4002
4536
  return fsMod;
4003
4537
  }
4538
+ async function sourceIdentity(options = {}) {
4539
+ if (typeof options.siteUuid === "string" && options.siteUuid.trim() !== "") {
4540
+ const endpoint = typeof options.pulseRulesUrl === "string" ? options.pulseRulesUrl : "";
4541
+ return `site:${options.siteUuid.trim().toLowerCase()}@${endpoint}`;
4542
+ }
4543
+ if (typeof options.token === "string" && options.token !== "") {
4544
+ const digest = await sha256Hex(options.token);
4545
+ if (digest === null) return null;
4546
+ const endpoint = typeof options.baseUrl === "string" ? options.baseUrl : "";
4547
+ return `token:${digest}@${endpoint}`;
4548
+ }
4549
+ return null;
4550
+ }
4551
+ async function sha256Hex(text) {
4552
+ try {
4553
+ const subtle = globalThis.crypto?.subtle;
4554
+ if (!subtle || typeof TextEncoder === "undefined") return null;
4555
+ const digest = await subtle.digest("SHA-256", new TextEncoder().encode(text));
4556
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
4557
+ } catch {
4558
+ return null;
4559
+ }
4560
+ }
4004
4561
  function makeStore(options = {}) {
4005
4562
  let mem = null;
4006
4563
  const durable = durableTier(options);
4564
+ let identity;
4565
+ const identityOf = () => identity ??= sourceIdentity(options);
4007
4566
  return {
4008
4567
  async read() {
4009
4568
  if (mem) return mem;
4569
+ const source = await identityOf();
4570
+ if (source === null) return null;
4010
4571
  const env = await durable.read();
4011
- if (env) mem = env;
4572
+ if (!env || env.source !== source) return null;
4573
+ mem = env;
4012
4574
  return env;
4013
4575
  },
4014
4576
  async write(env) {
4015
- if (env) mem = env;
4016
- await durable.write(env);
4577
+ if (!env) return;
4578
+ const stamped = { ...env, source: await identityOf() };
4579
+ mem = stamped;
4580
+ await durable.write(stamped);
4017
4581
  }
4018
4582
  };
4019
4583
  }
@@ -4135,11 +4699,12 @@ function toEnvelope(value) {
4135
4699
  buildId,
4136
4700
  // A confirmation belongs to the presentation stored beside it. A crossed or partially written
4137
4701
  // envelope confirms nothing, even when one of its fields happens to name the current map.
4138
- matchedBuildId: buildId !== null && matched === buildId ? matched : null
4702
+ matchedBuildId: buildId !== null && matched === buildId ? matched : null,
4703
+ source: typeof value.source === "string" ? value.source : null
4139
4704
  };
4140
4705
  }
4141
4706
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
4142
- return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
4707
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null, source: null };
4143
4708
  }
4144
4709
  return null;
4145
4710
  }
@@ -4206,13 +4771,17 @@ function withScopedDryRun(bundle, confirmed, reason, options = {}) {
4206
4771
  ...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
4207
4772
  ...held > unusable ? [reason] : []
4208
4773
  ];
4209
- notify(
4210
- options.onError,
4211
- new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
4212
- "onError"
4213
- );
4774
+ const message = `${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`;
4775
+ if (typeof options.onError === "function") notify(options.onError, new Error(message), "onError");
4776
+ else warnHeldOnce(message);
4214
4777
  return { ...bundle, firewall };
4215
4778
  }
4779
+ var warnedHeld = /* @__PURE__ */ new Set();
4780
+ function warnHeldOnce(message) {
4781
+ if (warnedHeld.has(message)) return;
4782
+ warnedHeld.add(message);
4783
+ console.warn(`[patchstack] ${message}. Pass { onError } to handle this yourself.`);
4784
+ }
4216
4785
  function confirmedBy(res, presented) {
4217
4786
  if (presented === null) return null;
4218
4787
  const verdict = res?.build;
@@ -4387,6 +4956,60 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
4387
4956
  }
4388
4957
  };
4389
4958
  }
4959
+ var RECOVERY_DELAYS_MS = [5e3, 15e3, 45e3, 12e4, 3e5, 6e5];
4960
+ function startRecovery(tick, { onError } = {}) {
4961
+ let stopped = false;
4962
+ let attempt = 0;
4963
+ let timer = null;
4964
+ const schedule = () => {
4965
+ if (stopped) return;
4966
+ const delay = RECOVERY_DELAYS_MS[Math.min(attempt, RECOVERY_DELAYS_MS.length - 1)];
4967
+ timer = setTimeout(run, delay * (1 - Math.random() * JITTER_FRACTION3));
4968
+ timer?.unref?.();
4969
+ };
4970
+ const run = async () => {
4971
+ if (stopped) return;
4972
+ attempt++;
4973
+ try {
4974
+ const status = await tick();
4975
+ if (!status || status.ok !== false) {
4976
+ stopped = true;
4977
+ return;
4978
+ }
4979
+ } catch (err) {
4980
+ notify(onError, err, "onError");
4981
+ }
4982
+ schedule();
4983
+ };
4984
+ schedule();
4985
+ return {
4986
+ stop() {
4987
+ stopped = true;
4988
+ if (timer) clearTimeout(timer);
4989
+ }
4990
+ };
4991
+ }
4992
+ function serialise(tick) {
4993
+ let running = null;
4994
+ let next = null;
4995
+ const run = () => {
4996
+ if (!running) {
4997
+ running = Promise.resolve().then(() => tick()).finally(() => {
4998
+ running = null;
4999
+ });
5000
+ return running;
5001
+ }
5002
+ if (!next) {
5003
+ next = running.catch(() => {
5004
+ }).then(() => {
5005
+ next = null;
5006
+ return run();
5007
+ });
5008
+ }
5009
+ return next;
5010
+ };
5011
+ return run;
5012
+ }
4390
5013
  function makeRefreshHandler(tick, secret) {
4391
5014
  let inflight2 = null;
4392
5015
  const runOnce = () => {
@@ -4401,7 +5024,7 @@ function makeRefreshHandler(tick, secret) {
4401
5024
  return async (request) => {
4402
5025
  if (!secret) return new Response("not found", { status: 404 });
4403
5026
  const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
4404
- if (provided !== secret) return new Response("forbidden", { status: 403 });
5027
+ if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
4405
5028
  let refreshed = true;
4406
5029
  try {
4407
5030
  const status = await runOnce();
@@ -4412,6 +5035,33 @@ function makeRefreshHandler(tick, secret) {
4412
5035
  return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
4413
5036
  };
4414
5037
  }
5038
+ async function sameSecret(provided, secret) {
5039
+ if (typeof provided !== "string" || typeof secret !== "string") return false;
5040
+ const subtle = globalThis.crypto?.subtle;
5041
+ if (subtle && typeof TextEncoder === "function") {
5042
+ try {
5043
+ const encoder2 = new TextEncoder();
5044
+ const [a, b] = await Promise.all([
5045
+ subtle.digest("SHA-256", encoder2.encode(provided)),
5046
+ subtle.digest("SHA-256", encoder2.encode(secret))
5047
+ ]);
5048
+ return equalBytes(new Uint8Array(a), new Uint8Array(b));
5049
+ } catch {
5050
+ }
5051
+ }
5052
+ let diff = provided.length ^ secret.length;
5053
+ const length = Math.max(provided.length, secret.length);
5054
+ for (let i = 0; i < length; i++) {
5055
+ diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
5056
+ }
5057
+ return diff === 0;
5058
+ }
5059
+ function equalBytes(a, b) {
5060
+ if (a.length !== b.length) return false;
5061
+ let diff = 0;
5062
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
5063
+ return diff === 0;
5064
+ }
4415
5065
 
4416
5066
  // src/protect/detections.js
4417
5067
  var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
@@ -5145,9 +5795,180 @@ function hardensWithoutBody(rule) {
5145
5795
  if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
5146
5796
  const { parameters, complete } = readRuleParameters(rule);
5147
5797
  if (!complete) return false;
5148
- return parameters.every(
5149
- (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
5798
+ return parameters.every(bodyIndependent);
5799
+ }
5800
+ function readsOnlyResponseHeaders(rule) {
5801
+ if (!rule) return false;
5802
+ const { parameters, complete } = readRuleParameters(rule);
5803
+ if (!complete || !parameters.some(isHeaderParameter)) return false;
5804
+ return parameters.every(bodyIndependent);
5805
+ }
5806
+ function isHeaderParameter(parameter) {
5807
+ return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
5808
+ }
5809
+ function bodyIndependent(parameter) {
5810
+ return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
5811
+ }
5812
+
5813
+ // src/protect/supabase-guard.js
5814
+ var GUARD_PATH = "/_patchstack/guard";
5815
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5816
+ var DEFAULT_TIMEOUT_MS = 3e4;
5817
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5818
+ "connection",
5819
+ "cookie",
5820
+ "cookie2",
5821
+ "forwarded",
5822
+ "host",
5823
+ "keep-alive",
5824
+ "proxy-authenticate",
5825
+ "proxy-authorization",
5826
+ "te",
5827
+ "trailer",
5828
+ "transfer-encoding",
5829
+ "upgrade",
5830
+ "x-forwarded-for",
5831
+ "x-forwarded-host",
5832
+ "x-forwarded-port",
5833
+ "x-forwarded-proto",
5834
+ "x-ps-target",
5835
+ "x-real-ip"
5836
+ ]);
5837
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5838
+ "connection",
5839
+ "content-encoding",
5840
+ "content-length",
5841
+ "keep-alive",
5842
+ "proxy-authenticate",
5843
+ "proxy-authorization",
5844
+ "set-cookie",
5845
+ "set-cookie2",
5846
+ "te",
5847
+ "trailer",
5848
+ "transfer-encoding",
5849
+ "upgrade"
5850
+ ]);
5851
+ var BodyTooLargeError = class extends Error {
5852
+ };
5853
+ async function boundedBody(request, maxBytes) {
5854
+ const stated = Number(request.headers.get("content-length"));
5855
+ if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5856
+ if (!request.body) return void 0;
5857
+ const reader = request.body.getReader();
5858
+ const chunks = [];
5859
+ let total = 0;
5860
+ try {
5861
+ for (; ; ) {
5862
+ const { done, value } = await reader.read();
5863
+ if (done) break;
5864
+ total += value.byteLength;
5865
+ if (total > maxBytes) {
5866
+ void reader.cancel().catch(() => {
5867
+ });
5868
+ throw new BodyTooLargeError();
5869
+ }
5870
+ chunks.push(value);
5871
+ }
5872
+ } finally {
5873
+ reader.releaseLock();
5874
+ }
5875
+ const body = new Uint8Array(total);
5876
+ let offset = 0;
5877
+ for (const chunk of chunks) {
5878
+ body.set(chunk, offset);
5879
+ offset += chunk.byteLength;
5880
+ }
5881
+ return body;
5882
+ }
5883
+ function requestHeaders(input) {
5884
+ const output = new Headers();
5885
+ const connectionTokens = new Set(
5886
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5887
+ );
5888
+ input.forEach((value, key) => {
5889
+ const lower = key.toLowerCase();
5890
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5891
+ });
5892
+ return output;
5893
+ }
5894
+ function responseHeaders(input) {
5895
+ const output = new Headers();
5896
+ const connectionTokens = new Set(
5897
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5150
5898
  );
5899
+ input.forEach((value, key) => {
5900
+ const lower = key.toLowerCase();
5901
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5902
+ });
5903
+ return output;
5904
+ }
5905
+ var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
5906
+ function createSupabaseGuard({
5907
+ protection,
5908
+ supabaseUrl,
5909
+ fetchImpl = fetch,
5910
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5911
+ timeoutMs = DEFAULT_TIMEOUT_MS
5912
+ }) {
5913
+ const guard = protection.fetchGuard();
5914
+ const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5915
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5916
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
5917
+ return async function handleGuardRequest(request, ...hostArgs) {
5918
+ const target = request.headers.get("x-ps-target");
5919
+ if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
5920
+ let targetUrl;
5921
+ try {
5922
+ targetUrl = new URL(target);
5923
+ } catch {
5924
+ return new Response("patchstack: invalid target", { status: 400 });
5925
+ }
5926
+ if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
5927
+ return new Response("patchstack: target not allowed", { status: 403 });
5928
+ }
5929
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
5930
+ let body;
5931
+ try {
5932
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5933
+ } catch (error) {
5934
+ if (error instanceof BodyTooLargeError) {
5935
+ return new Response("patchstack: request body too large", { status: 413 });
5936
+ }
5937
+ throw error;
5938
+ }
5939
+ const forwardHeaders = requestHeaders(request.headers);
5940
+ const evalReq = new Request(targetUrl.toString(), {
5941
+ method: request.method,
5942
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5943
+ // sanitized at the cross-origin boundary below.
5944
+ headers: request.headers,
5945
+ body
5946
+ });
5947
+ Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
5948
+ const blocked = await guard(evalReq, ...hostArgs);
5949
+ if (blocked) return blocked;
5950
+ let upstream;
5951
+ try {
5952
+ upstream = await fetchImpl(targetUrl.toString(), {
5953
+ method: request.method,
5954
+ headers: forwardHeaders,
5955
+ body,
5956
+ redirect: "manual",
5957
+ signal: AbortSignal.timeout(upstreamTimeout)
5958
+ });
5959
+ } catch (error) {
5960
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5961
+ return new Response("patchstack: upstream timed out", { status: 504 });
5962
+ }
5963
+ throw error;
5964
+ }
5965
+ const forwarded = new Response(upstream.body, {
5966
+ status: upstream.status,
5967
+ statusText: upstream.statusText,
5968
+ headers: responseHeaders(upstream.headers)
5969
+ });
5970
+ return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
5971
+ };
5151
5972
  }
5152
5973
 
5153
5974
  // src/protect/firewall-log.js
@@ -5196,8 +6017,13 @@ function resolveApiBase(pulseOrManifestUrl) {
5196
6017
  function createFirewallLogReporter(opts) {
5197
6018
  const creds = parseApiKey(opts.apiKey);
5198
6019
  if (!creds) {
5199
- return { record() {
5200
- }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
6020
+ return {
6021
+ record() {
6022
+ },
6023
+ flush: () => Promise.resolve(),
6024
+ stop: () => Promise.resolve(),
6025
+ health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
6026
+ };
5201
6027
  }
5202
6028
  const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
5203
6029
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -5210,6 +6036,19 @@ function createFirewallLogReporter(opts) {
5210
6036
  let drainPromise = null;
5211
6037
  let activeController = null;
5212
6038
  let ended = false;
6039
+ let recorded = 0;
6040
+ let delivered = 0;
6041
+ let failed = 0;
6042
+ let dropped = 0;
6043
+ let activeBatch = null;
6044
+ const settle = (sent, outcome) => {
6045
+ if (sent.counted) return;
6046
+ sent.counted = true;
6047
+ if (outcome === "delivered") delivered += sent.size;
6048
+ else if (outcome === "failed") failed += sent.size;
6049
+ else dropped += sent.size;
6050
+ if (activeBatch === sent) activeBatch = null;
6051
+ };
5213
6052
  let cachedToken = null;
5214
6053
  let tokenInflight = null;
5215
6054
  const fetchAccessToken = async (signal) => {
@@ -5263,10 +6102,16 @@ function createFirewallLogReporter(opts) {
5263
6102
  const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
5264
6103
  attemptTimer?.unref?.();
5265
6104
  let entry;
6105
+ const sent = { size: batch.length, counted: false };
6106
+ activeBatch = sent;
5266
6107
  entry = (async () => {
5267
6108
  try {
5268
6109
  const token = await fetchAccessToken(controller?.signal);
5269
- if (!token || ended) return;
6110
+ if (ended) return;
6111
+ if (!token) {
6112
+ settle(sent, "failed");
6113
+ return;
6114
+ }
5270
6115
  const body = new URLSearchParams();
5271
6116
  body.set("type", "firewall");
5272
6117
  body.set("logs", JSON.stringify(batch));
@@ -5283,10 +6128,11 @@ function createFirewallLogReporter(opts) {
5283
6128
  // Both phases carry the attempt controller, so slow transports cannot accumulate work.
5284
6129
  ...controller ? { signal: controller.signal } : {}
5285
6130
  });
5286
- if (p && typeof p.then === "function") await p.catch(() => {
5287
- });
6131
+ const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
6132
+ settle(sent, res && res.ok ? "delivered" : "failed");
5288
6133
  } catch {
5289
6134
  } finally {
6135
+ settle(sent, "failed");
5290
6136
  clearTimeout(attemptTimer);
5291
6137
  if (activeController === controller) activeController = null;
5292
6138
  if (inFlight === entry) inFlight = null;
@@ -5313,234 +6159,85 @@ function createFirewallLogReporter(opts) {
5313
6159
  if (stopped) return;
5314
6160
  const fid = event?.rule?.id;
5315
6161
  if (fid === void 0 || fid === null || fid === "") return;
5316
- if (queue.length >= MAX_QUEUE2) return;
5317
- queue.push({
5318
- fid,
5319
- method: event.method ?? null,
5320
- request_uri: event.path ?? null,
5321
- ip: event.ip ?? null,
5322
- user_agent: event.userAgent ?? null,
5323
- log_date: (/* @__PURE__ */ new Date()).toISOString()
5324
- });
5325
- if (queue.length >= MAX_BATCH2) {
5326
- flush();
6162
+ if (queue.length >= MAX_QUEUE2) {
6163
+ dropped++;
5327
6164
  return;
5328
6165
  }
5329
- if (!timer) timer = setTimeout(flush, flushMs);
5330
- },
5331
- flush,
5332
- /**
5333
- * Stop, and hand back a wait for what was outstanding.
5334
- *
5335
- * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
5336
- * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
5337
- * spinning would be worse than leaving the records where they are.
5338
- */
5339
- stop() {
5340
- if (drainPromise) return drainPromise;
5341
- stopped = true;
5342
- if (timer) {
5343
- clearTimeout(timer);
5344
- timer = null;
5345
- }
5346
- const terminate = () => {
5347
- ended = true;
5348
- activeController?.abort();
5349
- activeController = null;
5350
- queue = [];
5351
- inFlight = null;
5352
- };
5353
- let budget = null;
5354
- const spent = new Promise((resolve) => {
5355
- budget = setTimeout(() => {
5356
- terminate();
5357
- resolve();
5358
- }, STOP_BUDGET_MS2);
5359
- if (typeof budget.unref === "function") budget.unref();
5360
- });
5361
- const work = (async () => {
5362
- while (!ended) {
5363
- if (inFlight) {
5364
- await inFlight;
5365
- continue;
5366
- }
5367
- if (queue.length === 0) break;
5368
- const before = queue.length;
5369
- await flush();
5370
- if (queue.length >= before) break;
5371
- }
5372
- })();
5373
- drainPromise = Promise.race([work, spent]).then(() => {
5374
- if (budget) clearTimeout(budget);
5375
- });
5376
- return drainPromise;
5377
- }
5378
- };
5379
- }
5380
- function telemetryEnabled() {
5381
- const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
5382
- if (v === void 0 || v === "") return true;
5383
- return !/^(0|false|off|no)$/i.test(String(v));
5384
- }
5385
-
5386
- // src/protect/supabase-guard.js
5387
- var GUARD_PATH = "/_patchstack/guard";
5388
- var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5389
- var DEFAULT_TIMEOUT_MS = 3e4;
5390
- var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5391
- "connection",
5392
- "cookie",
5393
- "cookie2",
5394
- "forwarded",
5395
- "host",
5396
- "keep-alive",
5397
- "proxy-authenticate",
5398
- "proxy-authorization",
5399
- "te",
5400
- "trailer",
5401
- "transfer-encoding",
5402
- "upgrade",
5403
- "x-forwarded-for",
5404
- "x-forwarded-host",
5405
- "x-forwarded-port",
5406
- "x-forwarded-proto",
5407
- "x-ps-target",
5408
- "x-real-ip"
5409
- ]);
5410
- var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5411
- "connection",
5412
- "content-encoding",
5413
- "content-length",
5414
- "keep-alive",
5415
- "proxy-authenticate",
5416
- "proxy-authorization",
5417
- "set-cookie",
5418
- "set-cookie2",
5419
- "te",
5420
- "trailer",
5421
- "transfer-encoding",
5422
- "upgrade"
5423
- ]);
5424
- var BodyTooLargeError = class extends Error {
5425
- };
5426
- async function boundedBody(request, maxBytes) {
5427
- const stated = Number(request.headers.get("content-length"));
5428
- if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5429
- if (!request.body) return void 0;
5430
- const reader = request.body.getReader();
5431
- const chunks = [];
5432
- let total = 0;
5433
- try {
5434
- for (; ; ) {
5435
- const { done, value } = await reader.read();
5436
- if (done) break;
5437
- total += value.byteLength;
5438
- if (total > maxBytes) {
5439
- void reader.cancel().catch(() => {
5440
- });
5441
- throw new BodyTooLargeError();
5442
- }
5443
- chunks.push(value);
5444
- }
5445
- } finally {
5446
- reader.releaseLock();
5447
- }
5448
- const body = new Uint8Array(total);
5449
- let offset = 0;
5450
- for (const chunk of chunks) {
5451
- body.set(chunk, offset);
5452
- offset += chunk.byteLength;
5453
- }
5454
- return body;
5455
- }
5456
- function requestHeaders(input) {
5457
- const output = new Headers();
5458
- const connectionTokens = new Set(
5459
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5460
- );
5461
- input.forEach((value, key) => {
5462
- const lower = key.toLowerCase();
5463
- if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5464
- });
5465
- return output;
5466
- }
5467
- function responseHeaders(input) {
5468
- const output = new Headers();
5469
- const connectionTokens = new Set(
5470
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5471
- );
5472
- input.forEach((value, key) => {
5473
- const lower = key.toLowerCase();
5474
- if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5475
- });
5476
- return output;
5477
- }
5478
- function createSupabaseGuard({
5479
- protection,
5480
- supabaseUrl,
5481
- fetchImpl = fetch,
5482
- maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5483
- timeoutMs = DEFAULT_TIMEOUT_MS
5484
- }) {
5485
- const guard = protection.fetchGuard();
5486
- const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5487
- const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5488
- const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
5489
- return async function handleGuardRequest(request) {
5490
- const target = request.headers.get("x-ps-target");
5491
- if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
5492
- let targetUrl;
5493
- try {
5494
- targetUrl = new URL(target);
5495
- } catch {
5496
- return new Response("patchstack: invalid target", { status: 400 });
5497
- }
5498
- if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
5499
- return new Response("patchstack: target not allowed", { status: 403 });
5500
- }
5501
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
5502
- let body;
5503
- try {
5504
- body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5505
- } catch (error) {
5506
- if (error instanceof BodyTooLargeError) {
5507
- return new Response("patchstack: request body too large", { status: 413 });
5508
- }
5509
- throw error;
5510
- }
5511
- const forwardHeaders = requestHeaders(request.headers);
5512
- const evalReq = new Request(targetUrl.toString(), {
5513
- method: request.method,
5514
- // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5515
- // sanitized at the cross-origin boundary below.
5516
- headers: request.headers,
5517
- body
5518
- });
5519
- const blocked = await guard(evalReq);
5520
- if (blocked) return blocked;
5521
- let upstream;
5522
- try {
5523
- upstream = await fetchImpl(targetUrl.toString(), {
5524
- method: request.method,
5525
- headers: forwardHeaders,
5526
- body,
5527
- redirect: "manual",
5528
- signal: AbortSignal.timeout(upstreamTimeout)
6166
+ recorded++;
6167
+ queue.push({
6168
+ fid,
6169
+ method: event.method ?? null,
6170
+ request_uri: event.path ?? null,
6171
+ ip: event.ip ?? null,
6172
+ user_agent: event.userAgent ?? null,
6173
+ log_date: (/* @__PURE__ */ new Date()).toISOString()
5529
6174
  });
5530
- } catch (error) {
5531
- if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5532
- return new Response("patchstack: upstream timed out", { status: 504 });
6175
+ if (queue.length >= MAX_BATCH2) {
6176
+ flush();
6177
+ return;
5533
6178
  }
5534
- throw error;
6179
+ if (!timer) timer = setTimeout(flush, flushMs);
6180
+ },
6181
+ flush,
6182
+ /** Where the records went so far; see the counters above. `queued` is what is waiting now. */
6183
+ health() {
6184
+ return { recorded, delivered, failed, dropped, queued: queue.length };
6185
+ },
6186
+ /**
6187
+ * Stop, and hand back a wait for what was outstanding.
6188
+ *
6189
+ * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
6190
+ * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
6191
+ * spinning would be worse than leaving the records where they are.
6192
+ */
6193
+ stop() {
6194
+ if (drainPromise) return drainPromise;
6195
+ stopped = true;
6196
+ if (timer) {
6197
+ clearTimeout(timer);
6198
+ timer = null;
6199
+ }
6200
+ const terminate = () => {
6201
+ ended = true;
6202
+ activeController?.abort();
6203
+ activeController = null;
6204
+ if (activeBatch) settle(activeBatch, "dropped");
6205
+ dropped += queue.length;
6206
+ queue = [];
6207
+ inFlight = null;
6208
+ };
6209
+ let budget = null;
6210
+ const spent = new Promise((resolve) => {
6211
+ budget = setTimeout(() => {
6212
+ terminate();
6213
+ resolve();
6214
+ }, STOP_BUDGET_MS2);
6215
+ if (typeof budget.unref === "function") budget.unref();
6216
+ });
6217
+ const work = (async () => {
6218
+ while (!ended) {
6219
+ if (inFlight) {
6220
+ await inFlight;
6221
+ continue;
6222
+ }
6223
+ if (queue.length === 0) break;
6224
+ const before = queue.length;
6225
+ await flush();
6226
+ if (queue.length >= before) break;
6227
+ }
6228
+ })();
6229
+ drainPromise = Promise.race([work, spent]).then(() => {
6230
+ if (budget) clearTimeout(budget);
6231
+ });
6232
+ return drainPromise;
5535
6233
  }
5536
- const forwarded = new Response(upstream.body, {
5537
- status: upstream.status,
5538
- statusText: upstream.statusText,
5539
- headers: responseHeaders(upstream.headers)
5540
- });
5541
- return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
5542
6234
  };
5543
6235
  }
6236
+ function telemetryEnabled() {
6237
+ const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
6238
+ if (v === void 0 || v === "") return true;
6239
+ return !/^(0|false|off|no)$/i.test(String(v));
6240
+ }
5544
6241
 
5545
6242
  // src/protect/verify-sentinel.js
5546
6243
  var VERIFY_HEADER = "x-patchstack-verify";
@@ -5632,6 +6329,7 @@ async function createProtection(options = {}) {
5632
6329
  "onDetect"
5633
6330
  );
5634
6331
  };
6332
+ const nodeHttp = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)).then((m) => m.default ?? m, () => null);
5635
6333
  const store = makeStore(options);
5636
6334
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
5637
6335
  const pulseAuth = await resolvePulseAuth(options);
@@ -5657,6 +6355,21 @@ async function createProtection(options = {}) {
5657
6355
  pulseAuth,
5658
6356
  detectionState: preFetchState
5659
6357
  });
6358
+ let ruleSource = { ...bundle.source ?? { ok: true, origin: "empty" } };
6359
+ const warnedSources = /* @__PURE__ */ new Set();
6360
+ const hasLiveSource = Boolean(options.siteUuid || options.token);
6361
+ const noteRuleSource = (source) => {
6362
+ ruleSource = { ...source ?? { ok: true, origin: ruleSource.origin } };
6363
+ if (!hasLiveSource || typeof onError === "function" || ruleSource.ok !== false) return;
6364
+ const cause = `${ruleSource.origin}:${ruleSource.reason ?? ""}`;
6365
+ if (warnedSources.has(cause)) return;
6366
+ warnedSources.add(cause);
6367
+ const running = ruleSource.origin === "cache" ? "the last rules it received" : ruleSource.origin === "bundled" ? "its bundled fallback rules" : "no rules at all";
6368
+ console.warn(
6369
+ `[patchstack] the guard's rules are not current (${ruleSource.reason ?? "the rules source did not answer"}); it is running on ${running}. Pass { onError } to handle this yourself.`
6370
+ );
6371
+ };
6372
+ noteRuleSource(bundle.source);
5660
6373
  let detectionReporting = "not-enrolled";
5661
6374
  let currentOrigin;
5662
6375
  const applyReportingState = async (origin) => {
@@ -5712,20 +6425,47 @@ async function createProtection(options = {}) {
5712
6425
  whitelist_keys: delivered.whitelist_keys,
5713
6426
  onError
5714
6427
  });
5715
- responseRuleSet = responseRules.map((rule) => ({
5716
- rule,
5717
- engine: new RuleEngine({ firewall: [rule], onError }),
5718
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
5719
- // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
5720
- // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
5721
- // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
5722
- // fails such a rule CLOSED (block) instead of serving a no-op redaction.
5723
- mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
5724
- // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
5725
- // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
5726
- // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
5727
- prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
5728
- }));
6428
+ responseRuleSet = responseRules.map((rule) => {
6429
+ const unscoped = [];
6430
+ const rewrites = rule.action === "redact" || rule.action === "encode";
6431
+ let derived;
6432
+ try {
6433
+ const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
6434
+ const mutatedSpan = rewrites && hasSpanMutations(rule);
6435
+ derived = {
6436
+ redactors,
6437
+ mutatedSpan,
6438
+ // A redaction that reads response headers only, and masks spans in them, and an explicit block
6439
+ // that reads response headers only, are decided and carried out without the body — so they
6440
+ // still apply to a response whose body was not screened.
6441
+ redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
6442
+ blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
6443
+ };
6444
+ } catch (err) {
6445
+ notify(onError, err, "onError");
6446
+ derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
6447
+ }
6448
+ if (unscoped.length) {
6449
+ notify(
6450
+ onError,
6451
+ new Error(
6452
+ `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`
6453
+ ),
6454
+ "onError"
6455
+ );
6456
+ }
6457
+ return {
6458
+ rule,
6459
+ engine: new RuleEngine({ firewall: [rule], onError }),
6460
+ ...derived,
6461
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
6462
+ // have any chance of matching. Lets screenText skip the full scan on responses with no candidate
6463
+ // — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
6464
+ // Case-insensitive, and checked against the header values as well as the body, since a rule may
6465
+ // read either.
6466
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
6467
+ };
6468
+ });
5729
6469
  egressRuleSet = egressRules.map((rule) => ({
5730
6470
  rule,
5731
6471
  engine: new RuleEngine({ firewall: [rule], onError })
@@ -5739,6 +6479,9 @@ async function createProtection(options = {}) {
5739
6479
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
5740
6480
  notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
5741
6481
  };
6482
+ const recordEvaluationSkips = (result) => {
6483
+ for (const reason of result?.skips ?? []) recordSkip("request", reason);
6484
+ };
5742
6485
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
5743
6486
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
5744
6487
  const planCache = createPlanCache();
@@ -5781,15 +6524,19 @@ async function createProtection(options = {}) {
5781
6524
  });
5782
6525
  return effectiveMode === "block" ? block() : allow2();
5783
6526
  };
6527
+ const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
6528
+ const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
5784
6529
  const screenText = (text, meta, reqCtx, only) => {
5785
6530
  let blockRule = null;
5786
6531
  const redactions = [];
5787
6532
  const headerMutations = [];
5788
6533
  let lowerText = null;
5789
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
5790
- if (only && !only(rule)) continue;
6534
+ const responseSkips = /* @__PURE__ */ new Set();
6535
+ for (const entry of responseRuleSet) {
6536
+ const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
6537
+ if (only && !only(rule, entry)) continue;
5791
6538
  if (prefilter) {
5792
- if (lowerText === null) lowerText = text.toLowerCase();
6539
+ if (lowerText === null) lowerText = prefilterText(text, meta.headers);
5793
6540
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
5794
6541
  }
5795
6542
  let result;
@@ -5799,6 +6546,11 @@ async function createProtection(options = {}) {
5799
6546
  notify(onError, err, "onError");
5800
6547
  continue;
5801
6548
  }
6549
+ for (const reason of result.skips ?? []) {
6550
+ if (responseSkips.has(reason)) continue;
6551
+ responseSkips.add(reason);
6552
+ recordSkip("response", reason);
6553
+ }
5802
6554
  if (!result.blocked) continue;
5803
6555
  const responseMode = ruleMode(rule);
5804
6556
  onDetect({
@@ -5828,6 +6580,8 @@ async function createProtection(options = {}) {
5828
6580
  if (blockRule) return { verdict: "block" };
5829
6581
  let body = text;
5830
6582
  const headers = { ...meta.headers || {} };
6583
+ let responseIsJson;
6584
+ let structure;
5831
6585
  for (const { rule, redactors } of redactions) {
5832
6586
  const mask = maskFn(rule.category);
5833
6587
  const transform = rule.action === "encode" ? htmlEscape : null;
@@ -5835,17 +6589,29 @@ async function createProtection(options = {}) {
5835
6589
  const spanRedactors = redactors.filter((r) => !r.jsonPath);
5836
6590
  if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
5837
6591
  if (!spanRedactors.length) continue;
5838
- body = applyRedactors(body, spanRedactors, mask, transform);
6592
+ const beforeSpan = body;
6593
+ const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
6594
+ if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
6595
+ if (body !== beforeSpan) {
6596
+ responseIsJson ??= isJson(text);
6597
+ if (responseIsJson) {
6598
+ if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
6599
+ const tokens = lexJson(body);
6600
+ if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
6601
+ structure = { text: body, tokens };
6602
+ }
6603
+ }
5839
6604
  if (transform) continue;
5840
6605
  for (const name of Object.keys(headers)) {
5841
6606
  const value = headers[name];
6607
+ const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
5842
6608
  if (typeof value === "string") {
5843
- setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
6609
+ setOwn(headers, name, applyRedactors(value, headerSpans, mask));
5844
6610
  } else if (Array.isArray(value)) {
5845
6611
  setOwn(
5846
6612
  headers,
5847
6613
  name,
5848
- value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
6614
+ value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
5849
6615
  );
5850
6616
  }
5851
6617
  }
@@ -5892,18 +6658,53 @@ async function createProtection(options = {}) {
5892
6658
  }
5893
6659
  };
5894
6660
  };
5895
- const screenFetchRequest = async (request) => {
6661
+ const peerOf = (request, hostArgs) => {
6662
+ if (typeof options.peerAddress !== "function") return void 0;
6663
+ try {
6664
+ return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
6665
+ } catch (err) {
6666
+ notify(onError, err, "onError");
6667
+ return void 0;
6668
+ }
6669
+ };
6670
+ let warnedNoPeer = false;
6671
+ const warnNoPeer = () => {
6672
+ if (warnedNoPeer) return;
6673
+ warnedNoPeer = true;
6674
+ 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.";
6675
+ if (typeof onError === "function") notify(onError, new Error(message), "onError");
6676
+ else console.warn(message);
6677
+ };
6678
+ const clientByRequest = /* @__PURE__ */ new WeakMap();
6679
+ const clientForResponse = (request, hostArgs) => {
6680
+ const known = clientByRequest.get(request);
6681
+ if (known) return known;
6682
+ const client = resolveClientIp({
6683
+ peer: peerOf(request, hostArgs),
6684
+ headers: headerObject(request.headers),
6685
+ trustedProxy: options.trustedProxy
6686
+ });
6687
+ if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
6688
+ return client;
6689
+ };
6690
+ const screenFetchRequest = async (request, hostArgs = []) => {
5896
6691
  let result;
5897
6692
  let shaped;
5898
6693
  try {
5899
6694
  shaped = await fromFetchRequest(request, {
6695
+ peer: peerOf(request, hostArgs),
5900
6696
  trustedProxy: options.trustedProxy,
5901
6697
  maxBodyBytes: options.maxBodyBytes
5902
6698
  });
6699
+ if (shaped?._clientIp) {
6700
+ clientByRequest.set(request, shaped._clientIp);
6701
+ if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
6702
+ }
5903
6703
  if (shaped?._bodyInspectionSkip) {
5904
6704
  recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
5905
6705
  }
5906
6706
  result = engine.evaluate(shaped);
6707
+ recordEvaluationSkips(result);
5907
6708
  } catch (err) {
5908
6709
  notify(onError, err, "onError");
5909
6710
  return { blocked: null, client: void 0 };
@@ -5952,14 +6753,20 @@ async function createProtection(options = {}) {
5952
6753
  const screenResp = async (response, reqCtx) => {
5953
6754
  const read = await readTextResponse(response, screenCap);
5954
6755
  if (read.skip) {
5955
- if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
6756
+ if (read.skip !== "not-a-response") {
6757
+ recordSkip("response", read.skip, {
6758
+ status: response?.status,
6759
+ ...read.encoding ? { encoding: read.encoding } : {},
6760
+ ...read.charset ? { charset: read.charset } : {}
6761
+ });
6762
+ }
5956
6763
  if (read.skip === "not-a-response") return response;
5957
6764
  return hardenHeadersOnly(response, reqCtx);
5958
6765
  }
5959
- const text = read.text;
6766
+ const text = readJsonEscapes(read.text);
5960
6767
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
5961
- if (r.verdict === "block") return leakResponse();
5962
- if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
6768
+ if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
6769
+ if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
5963
6770
  return response;
5964
6771
  };
5965
6772
  const headerValueChanged = (was, value) => {
@@ -6021,7 +6828,8 @@ async function createProtection(options = {}) {
6021
6828
  const hardenHeadersOnly = (response, reqCtx) => {
6022
6829
  try {
6023
6830
  const meta = { status: response.status, headers: headerObject(response.headers) };
6024
- const r = screenText("", meta, reqCtx, hardensWithoutBody);
6831
+ const r = screenText("", meta, reqCtx, decidedWithoutBody);
6832
+ if (r.verdict === "block") return leakResponse();
6025
6833
  if (r.verdict !== "redact" || !r.headers) return response;
6026
6834
  if (!headersChanged(meta.headers, r.headers)) return response;
6027
6835
  return rebuildResponse(response, response.body, r.headers);
@@ -6033,9 +6841,16 @@ async function createProtection(options = {}) {
6033
6841
  const wrapNodeResponse = (res, reqCtx) => {
6034
6842
  const origWrite = res.write.bind(res);
6035
6843
  const origEnd = res.end.bind(res);
6844
+ const setHeaderNow = typeof res.setHeader === "function" ? res.setHeader.bind(res) : null;
6845
+ const removeHeaderNow = typeof res.removeHeader === "function" ? res.removeHeader.bind(res) : null;
6846
+ const sentAccessor = accessorOf(res, "headersSent");
6847
+ let sentValue = sentAccessor ? void 0 : res.headersSent;
6848
+ const reallySent = () => Boolean(sentAccessor ? sentAccessor.call(res) : sentValue);
6036
6849
  const chunks = [];
6037
6850
  let size = 0;
6038
6851
  let overflow = false;
6852
+ let withheld = false;
6853
+ let headScreened = false;
6039
6854
  const MAX = screenCap;
6040
6855
  let hardened = false;
6041
6856
  let answeredWithoutBody = false;
@@ -6043,7 +6858,7 @@ async function createProtection(options = {}) {
6043
6858
  if (hardened) return null;
6044
6859
  hardened = true;
6045
6860
  try {
6046
- if (res.headersSent || typeof res.setHeader !== "function") return null;
6861
+ if (reallySent() || !setHeaderNow) return null;
6047
6862
  const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
6048
6863
  const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
6049
6864
  const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
@@ -6055,8 +6870,8 @@ async function createProtection(options = {}) {
6055
6870
  if (!headerValueChanged(headers[name], value)) continue;
6056
6871
  changed.set(name.toLowerCase(), value);
6057
6872
  try {
6058
- if (value === null || value === void 0) res.removeHeader?.(name);
6059
- else res.setHeader(name, value);
6873
+ if (value === null || value === void 0) removeHeaderNow?.(name);
6874
+ else setHeaderNow(name, value);
6060
6875
  } catch {
6061
6876
  }
6062
6877
  }
@@ -6067,26 +6882,187 @@ async function createProtection(options = {}) {
6067
6882
  }
6068
6883
  };
6069
6884
  const stillToAnswer = (rule) => !hardensWithoutBody(rule);
6070
- if (typeof res.writeHead === "function") {
6071
- const origWriteHead = res.writeHead.bind(res);
6885
+ const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
6886
+ let pendingHead = null;
6887
+ let sending = false;
6888
+ const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
6889
+ if (origWriteHead) {
6072
6890
  res.writeHead = function(...args) {
6073
- const at = writeHeadHeaderIndex(args);
6074
- const given = at === -1 ? null : args[at];
6075
- const changed = hardenBeforeFlush({
6076
- status: typeof args[0] === "number" ? args[0] : void 0,
6077
- headers: given ? writeHeadHeaderObject(given) : null
6078
- });
6079
- if (!changed || !given) return origWriteHead(...args);
6080
- const rewritten = [...args];
6081
- rewritten[at] = rewriteWriteHeadHeaders(given, changed);
6082
- return origWriteHead(...rewritten);
6891
+ if (sending || reallySent()) return origWriteHead(...args);
6892
+ if (pendingHead) throw headersSentError("write");
6893
+ const judged = judgeWriteHead(args);
6894
+ if (judged) {
6895
+ res.statusCode = judged.statusCode;
6896
+ res.statusMessage = judged.statusMessage;
6897
+ mirrorHeaders(judged.headers);
6898
+ pendingHead = args;
6899
+ } else {
6900
+ pendingHead = args;
6901
+ if (typeof args[0] === "number") res.statusCode = args[0];
6902
+ if (typeof args[1] === "string") res.statusMessage = args[1];
6903
+ }
6904
+ return res;
6905
+ };
6906
+ }
6907
+ function judgeWriteHead(args) {
6908
+ const ServerResponse = nodeHttp?.ServerResponse;
6909
+ if (typeof ServerResponse !== "function") return null;
6910
+ const detached = new ServerResponse({ method: res.req?.method ?? "GET", httpVersionMajor: 1, httpVersionMinor: 1, headers: {} });
6911
+ if (res.statusMessage !== void 0) detached.statusMessage = res.statusMessage;
6912
+ for (const [name, value] of headerEntries(res)) detached.setHeader(name, value);
6913
+ detached.writeHead(...args);
6914
+ return { statusCode: detached.statusCode, statusMessage: detached.statusMessage, headers: headerEntries(detached) };
6915
+ }
6916
+ function headerEntries(target) {
6917
+ const names = typeof target.getRawHeaderNames === "function" ? target.getRawHeaderNames() : typeof target.getHeaderNames === "function" ? target.getHeaderNames() : [];
6918
+ return names.map((name) => [name, target.getHeader(name)]);
6919
+ }
6920
+ function mirrorHeaders(entries) {
6921
+ if (!setHeaderNow) return;
6922
+ for (const [name, value] of entries) {
6923
+ const current = res.getHeader?.(name);
6924
+ if (current === void 0 || headerValueChanged(current, value)) setHeaderNow(name, value);
6925
+ }
6926
+ }
6927
+ Object.defineProperty(res, "headersSent", {
6928
+ configurable: true,
6929
+ enumerable: true,
6930
+ get: () => pendingHead !== null || reallySent(),
6931
+ // A response-like object that tracks the flag itself keeps doing so.
6932
+ set: (value) => {
6933
+ if (!sentAccessor) sentValue = value;
6934
+ }
6935
+ });
6936
+ for (const [method, verb] of [["setHeader", "set"], ["removeHeader", "remove"], ["appendHeader", "append"]]) {
6937
+ const original = typeof res[method] === "function" ? res[method].bind(res) : null;
6938
+ if (!original) continue;
6939
+ res[method] = function(...args) {
6940
+ if (pendingHead) throw headersSentError(verb);
6941
+ return original(...args);
6942
+ };
6943
+ }
6944
+ if (typeof res.flushHeaders === "function") {
6945
+ const origFlushHeaders = res.flushHeaders.bind(res);
6946
+ res.flushHeaders = function() {
6947
+ const unread = screenUnreadHead();
6948
+ if (unread?.withhold) {
6949
+ sendWithheld();
6950
+ return void 0;
6951
+ }
6952
+ sendHead(unread);
6953
+ return origFlushHeaders();
6083
6954
  };
6084
6955
  }
6956
+ let hardenDone = false;
6957
+ let hardenedValues = null;
6958
+ const hardenOnce = () => {
6959
+ if (hardenDone) return hardenedValues;
6960
+ hardenDone = true;
6961
+ if (!pendingHead) {
6962
+ hardenedValues = hardenBeforeFlush();
6963
+ return hardenedValues;
6964
+ }
6965
+ const at = writeHeadHeaderIndex(pendingHead);
6966
+ hardenedValues = hardenBeforeFlush({
6967
+ status: typeof pendingHead[0] === "number" ? pendingHead[0] : void 0,
6968
+ headers: at === -1 ? null : writeHeadHeaderObject(pendingHead[at])
6969
+ });
6970
+ return hardenedValues;
6971
+ };
6972
+ const effectiveHead = () => {
6973
+ const set = typeof res.getHeaders === "function" ? { ...res.getHeaders() } : {};
6974
+ if (typeof res.getHeaders !== "function" && typeof res.getHeader === "function") {
6975
+ for (const name of ["content-type", "content-length"]) {
6976
+ const value = res.getHeader(name);
6977
+ if (value !== void 0) set[name] = value;
6978
+ }
6979
+ }
6980
+ if (!pendingHead) return { status: res.statusCode, headers: set };
6981
+ const at = writeHeadHeaderIndex(pendingHead);
6982
+ return {
6983
+ status: typeof pendingHead[0] === "number" ? pendingHead[0] : res.statusCode,
6984
+ headers: at === -1 ? set : { ...set, ...writeHeadHeaderObject(pendingHead[at]) }
6985
+ };
6986
+ };
6987
+ const screenUnreadHead = () => {
6988
+ if (headScreened) return void 0;
6989
+ headScreened = true;
6990
+ try {
6991
+ const head = effectiveHead();
6992
+ const r = screenText("", head, reqCtx, decidedByHeaders);
6993
+ if (r.verdict === "block") {
6994
+ if (!reallySent()) return { withhold: true };
6995
+ recordSkip("response", "headers-sent", { action: "block" });
6996
+ return void 0;
6997
+ }
6998
+ if (r.verdict !== "redact" || !r.headers) return void 0;
6999
+ const changed = /* @__PURE__ */ new Map();
7000
+ for (const [name, value] of Object.entries(r.headers)) {
7001
+ if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
7002
+ }
7003
+ if (changed.size && reallySent()) {
7004
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
7005
+ return void 0;
7006
+ }
7007
+ return changed.size ? { headers: changed } : void 0;
7008
+ } catch (err) {
7009
+ notify(onError, err, "onError");
7010
+ return void 0;
7011
+ }
7012
+ };
7013
+ const sendHead = (changes) => {
7014
+ if (sending) return;
7015
+ const hardened2 = hardenOnce();
7016
+ sending = true;
7017
+ if (!pendingHead) {
7018
+ if (changes) applyToResponse(changes);
7019
+ return;
7020
+ }
7021
+ const args = [...pendingHead];
7022
+ pendingHead = null;
7023
+ const merged = new Map(hardened2 ?? []);
7024
+ for (const [name, value] of changes?.headers ?? []) merged.set(name, value);
7025
+ if (changes?.status !== void 0) {
7026
+ args[0] = changes.status;
7027
+ if (typeof args[1] === "string") args.splice(1, 1);
7028
+ }
7029
+ const headerAt = writeHeadHeaderIndex(args);
7030
+ if (headerAt !== -1 && merged.size) args[headerAt] = rewriteWriteHeadHeaders(args[headerAt], merged);
7031
+ applyToResponse({ headers: merged });
7032
+ origWriteHead(...args);
7033
+ };
7034
+ const sendWithheld = (cb) => {
7035
+ const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
7036
+ const headers = /* @__PURE__ */ new Map();
7037
+ for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
7038
+ headers.set("content-type", "application/json");
7039
+ headers.set("content-length", String(Buffer.byteLength(body)));
7040
+ headers.set("transfer-encoding", null);
7041
+ withheld = true;
7042
+ sendHead({ status: 500, headers });
7043
+ return origEnd(body, cb);
7044
+ };
7045
+ const applyToResponse = (changes) => {
7046
+ if (changes.status !== void 0) res.statusCode = changes.status;
7047
+ for (const [name, value] of changes.headers ?? []) {
7048
+ try {
7049
+ if (value === null || value === void 0) removeHeaderNow?.(name);
7050
+ else setHeaderNow?.(name, value);
7051
+ } catch {
7052
+ }
7053
+ }
7054
+ };
6085
7055
  const collect = (chunk, enc) => {
6086
7056
  if (chunk == null) return;
6087
7057
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
6088
7058
  size += buf.length;
6089
7059
  if (size > MAX) {
7060
+ const unread = screenUnreadHead();
7061
+ if (unread?.withhold) {
7062
+ sendWithheld();
7063
+ return;
7064
+ }
7065
+ sendHead(unread);
6090
7066
  for (const c of chunks) origWrite(c);
6091
7067
  chunks.length = 0;
6092
7068
  origWrite(buf);
@@ -6097,7 +7073,11 @@ async function createProtection(options = {}) {
6097
7073
  chunks.push(buf);
6098
7074
  };
6099
7075
  res.write = function(chunk, enc, cb) {
6100
- hardenBeforeFlush();
7076
+ if (withheld) {
7077
+ if (typeof enc === "function") enc();
7078
+ else if (typeof cb === "function") cb();
7079
+ return true;
7080
+ }
6101
7081
  if (overflow) return origWrite(chunk, enc, cb);
6102
7082
  collect(chunk, enc);
6103
7083
  if (typeof enc === "function") enc();
@@ -6105,7 +7085,6 @@ async function createProtection(options = {}) {
6105
7085
  return true;
6106
7086
  };
6107
7087
  res.end = function(chunk, enc, cb) {
6108
- hardenBeforeFlush();
6109
7088
  if (typeof chunk === "function") {
6110
7089
  cb = chunk;
6111
7090
  chunk = void 0;
@@ -6114,74 +7093,90 @@ async function createProtection(options = {}) {
6114
7093
  cb = enc;
6115
7094
  enc = void 0;
6116
7095
  }
7096
+ if (withheld) {
7097
+ if (typeof cb === "function") cb();
7098
+ return res;
7099
+ }
6117
7100
  if (overflow) {
6118
7101
  if (chunk != null) origWrite(chunk, enc);
6119
7102
  return origEnd(cb);
6120
7103
  }
6121
7104
  collect(chunk, enc);
7105
+ if (withheld) {
7106
+ if (typeof cb === "function") cb();
7107
+ return res;
7108
+ }
6122
7109
  if (overflow) return origEnd(cb);
7110
+ hardenOnce();
7111
+ const passThrough = (changes) => {
7112
+ if (changes?.withhold) return sendWithheld(cb);
7113
+ sendHead(changes);
7114
+ for (const c of chunks) origWrite(c);
7115
+ return origEnd(cb);
7116
+ };
6123
7117
  const buffer = Buffer.concat(chunks);
6124
- let ct = res.getHeader ? res.getHeader("content-type") : void 0;
7118
+ const head = effectiveHead();
7119
+ let ct = head.headers["content-type"];
6125
7120
  if (Array.isArray(ct)) ct = ct[0];
6126
7121
  const kind = screenableContentType(ct);
6127
7122
  if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
6128
7123
  recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
6129
- for (const c of chunks) origWrite(c);
6130
- return origEnd(cb);
7124
+ return passThrough(screenUnreadHead());
7125
+ }
7126
+ const codings = declaredCodings(head.headers["content-encoding"]);
7127
+ if (codings.length > 0 && stillEncoded(buffer)) {
7128
+ recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
7129
+ return passThrough(screenUnreadHead());
7130
+ }
7131
+ const reading = charsetReading(ct, buffer);
7132
+ if (reading.skip) {
7133
+ recordSkip("response", "unsupported-charset", { charset: reading.skip });
7134
+ return passThrough(screenUnreadHead());
6131
7135
  }
6132
- const text = buffer.toString("utf8");
7136
+ const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
6133
7137
  let r;
6134
7138
  try {
6135
- r = screenText(
6136
- text,
6137
- { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
6138
- reqCtx,
6139
- answeredWithoutBody ? stillToAnswer : void 0
6140
- );
7139
+ r = screenText(text, head, reqCtx, notYetAnswered);
6141
7140
  } catch (err) {
6142
7141
  notify(onError, err, "onError");
6143
- for (const c of chunks) origWrite(c);
6144
- return origEnd(cb);
7142
+ return passThrough();
7143
+ }
7144
+ if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
7145
+ if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
7146
+ if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
7147
+ if (reallySent() && res.chunkedEncoding !== true) {
7148
+ notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
7149
+ res.destroy?.();
7150
+ return res;
6145
7151
  }
6146
7152
  if (r.verdict === "block") {
6147
- res.statusCode = 500;
6148
- try {
6149
- res.setHeader("content-type", "application/json");
6150
- } catch {
7153
+ if (reallySent()) {
7154
+ res.destroy?.();
7155
+ return res;
6151
7156
  }
6152
- return origEnd(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), cb);
7157
+ return sendWithheld(cb);
6153
7158
  }
6154
- if (r.verdict === "redact") {
6155
- try {
6156
- res.removeHeader && res.removeHeader("content-length");
6157
- } catch {
7159
+ const changed = /* @__PURE__ */ new Map();
7160
+ if (r.headers) {
7161
+ for (const [name, value] of Object.entries(r.headers)) {
7162
+ if (name.toLowerCase() === "content-length") continue;
7163
+ if (!headerValueChanged(head.headers[name], value)) continue;
7164
+ changed.set(name.toLowerCase(), value);
6158
7165
  }
6159
- if (r.headers && res.setHeader) {
6160
- const current = res.getHeaders ? res.getHeaders() : {};
6161
- for (const [name, value] of Object.entries(r.headers)) {
6162
- if (name.toLowerCase() === "content-length") continue;
6163
- if (value === null || value === void 0) {
6164
- try {
6165
- res.removeHeader && res.removeHeader(name);
6166
- } catch {
6167
- }
6168
- } else if (Array.isArray(value)) {
6169
- try {
6170
- res.setHeader(name, value);
6171
- } catch {
6172
- }
6173
- } else if (typeof value === "string" && current[name] !== value) {
6174
- try {
6175
- res.setHeader(name, value);
6176
- } catch {
6177
- }
6178
- }
6179
- }
7166
+ }
7167
+ if (!reallySent()) {
7168
+ const declared = head.headers["content-length"];
7169
+ if (declared === void 0) {
7170
+ changed.set("content-length", null);
7171
+ } else {
7172
+ changed.set("content-length", String(Buffer.byteLength(r.body)));
7173
+ changed.set("transfer-encoding", null);
6180
7174
  }
6181
- return origEnd(r.body, cb);
7175
+ sendHead({ headers: changed });
7176
+ } else if (changed.size) {
7177
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
6182
7178
  }
6183
- for (const c of chunks) origWrite(c);
6184
- return origEnd(cb);
7179
+ return origEnd(r.body, cb);
6185
7180
  };
6186
7181
  };
6187
7182
  const allow = new Set(allowHosts.map((h) => String(h).toLowerCase()));
@@ -6255,21 +7250,15 @@ async function createProtection(options = {}) {
6255
7250
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
6256
7251
  // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
6257
7252
  // upstream response. It resolves once here, which is the only resolution for this call.
6258
- screenResponse: (response, request) => screenResp(
6259
- response,
6260
- request ? reqContextFromFetch(
6261
- request,
6262
- resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
6263
- ) : void 0
6264
- ),
7253
+ screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
6265
7254
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
6266
7255
  fetchGuard() {
6267
- return async (request) => (await screenFetchRequest(request)).blocked;
7256
+ return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
6268
7257
  },
6269
7258
  // Wrap a fetch handler: screens the request, then the response (redact/block).
6270
7259
  fetch(handler) {
6271
7260
  return async (request, ...rest) => {
6272
- const { blocked, client } = await screenFetchRequest(request);
7261
+ const { blocked, client } = await screenFetchRequest(request, rest);
6273
7262
  if (blocked) return blocked;
6274
7263
  const response = await handler(request, ...rest);
6275
7264
  return screenResp(response, reqContextFromFetch(request, client));
@@ -6283,6 +7272,7 @@ async function createProtection(options = {}) {
6283
7272
  const { shaped, client } = shapeExpressRequest(req);
6284
7273
  try {
6285
7274
  result = engine.evaluate(shaped);
7275
+ recordEvaluationSkips(result);
6286
7276
  } catch (err) {
6287
7277
  notify(onError, err, "onError");
6288
7278
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
@@ -6317,33 +7307,25 @@ async function createProtection(options = {}) {
6317
7307
  screenNodeRequest(req, res, next, "", req.body);
6318
7308
  return;
6319
7309
  }
6320
- const chunks = [];
6321
- let size = 0;
6322
- let overflow = false;
6323
- req.on("data", (chunk) => {
6324
- size += chunk.length;
6325
- if (size > maxBytes) {
6326
- overflow = true;
7310
+ readBodyPrefix(req, maxBytes, (err, read) => {
7311
+ if (err) {
7312
+ notify(onError, err, "onError");
7313
+ next();
6327
7314
  return;
6328
7315
  }
6329
- chunks.push(chunk);
6330
- });
6331
- req.on("error", (err) => {
6332
- notify(onError, err, "onError");
6333
- next();
6334
- });
6335
- req.on("end", () => {
6336
- if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
6337
- screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
7316
+ if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
7317
+ if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
7318
+ screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
6338
7319
  });
6339
7320
  };
6340
- function screenNodeRequest(req, res, next, rawBody, parsedBody) {
7321
+ function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
6341
7322
  let shaped;
6342
7323
  let result;
6343
7324
  try {
6344
7325
  shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
6345
7326
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
6346
7327
  result = engine.evaluate(shaped);
7328
+ recordEvaluationSkips(result);
6347
7329
  } catch (err) {
6348
7330
  notify(onError, err, "onError");
6349
7331
  return next();
@@ -6362,7 +7344,7 @@ async function createProtection(options = {}) {
6362
7344
  }
6363
7345
  },
6364
7346
  () => {
6365
- if (req.body === void 0) req.body = shaped.body;
7347
+ if (req.body === void 0 && !truncated) req.body = shaped.body;
6366
7348
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
6367
7349
  next();
6368
7350
  },
@@ -6394,6 +7376,7 @@ async function createProtection(options = {}) {
6394
7376
  notify(onError, err, "onError");
6395
7377
  }
6396
7378
  }
7379
+ let recovery = null;
6397
7380
  const runRefreshTick = async () => {
6398
7381
  if (reporter) {
6399
7382
  try {
@@ -6410,23 +7393,38 @@ async function createProtection(options = {}) {
6410
7393
  });
6411
7394
  mode = resolveMode(options, next);
6412
7395
  applyBundle(next);
7396
+ noteRuleSource(next.source);
7397
+ if (next.source?.ok !== false) recovery?.stop();
6413
7398
  await applyAndAcknowledge(next.source?.origin, declaredState);
6414
7399
  if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
6415
7400
  return next.source ?? { ok: true };
6416
7401
  };
7402
+ const refreshTick = serialise(runRefreshTick);
6417
7403
  if (live) {
6418
- protection.refresh = () => runRefreshTick();
6419
- protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
7404
+ protection.refresh = () => refreshTick();
7405
+ protection.refreshHandler = () => makeRefreshHandler(refreshTick, refreshSecret);
6420
7406
  }
6421
- const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
7407
+ const loop = options.refreshMs > 0 && live ? startRefresh(refreshTick, { refreshMs: options.refreshMs, onError }) : null;
7408
+ const canAsk = Boolean(options.token || pulseAuth);
7409
+ recovery = live && canAsk && !loop && ruleSource.ok === false ? startRecovery(refreshTick, { onError }) : null;
6422
7410
  protection.stop = () => {
6423
7411
  loop?.stop();
7412
+ recovery?.stop();
7413
+ protection.uninstallEgress?.();
6424
7414
  const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
6425
7415
  (wait) => wait && typeof wait.then === "function"
6426
7416
  );
6427
7417
  return Promise.all(outstanding).then(() => void 0);
6428
7418
  };
6429
- protection.stopRefresh = protection.stop;
7419
+ protection.stopRefresh = () => {
7420
+ loop?.stop();
7421
+ recovery?.stop();
7422
+ return Promise.resolve();
7423
+ };
7424
+ Object.defineProperty(protection, "ruleSource", {
7425
+ get: () => ({ ...ruleSource }),
7426
+ enumerable: true
7427
+ });
6430
7428
  Object.defineProperty(protection, "detectionReporting", {
6431
7429
  get: () => detectionReporting,
6432
7430
  enumerable: true
@@ -6435,8 +7433,22 @@ async function createProtection(options = {}) {
6435
7433
  get: () => detections ? () => detections.health() : void 0,
6436
7434
  enumerable: true
6437
7435
  });
7436
+ Object.defineProperty(protection, "blockLogHealth", {
7437
+ get: () => firewallLog ? () => firewallLog.health() : void 0,
7438
+ enumerable: true
7439
+ });
6438
7440
  return protection;
6439
7441
  }
7442
+ function accessorOf(obj, name) {
7443
+ for (let o = obj; o; o = Object.getPrototypeOf(o)) {
7444
+ const d = Object.getOwnPropertyDescriptor(o, name);
7445
+ if (d) return typeof d.get === "function" ? d.get : null;
7446
+ }
7447
+ return null;
7448
+ }
7449
+ function headersSentError(verb) {
7450
+ return Object.assign(new Error(`Cannot ${verb} headers after they are sent to the client`), { code: "ERR_HTTP_HEADERS_SENT" });
7451
+ }
6440
7452
  function resolveMode(options, bundle) {
6441
7453
  const env = typeof process !== "undefined" ? process.env?.PATCHSTACK_MODE : void 0;
6442
7454
  if (env === "block" || env === "dry-run") return env;
@@ -6499,6 +7511,14 @@ function screenableContentType(ct) {
6499
7511
  if (base === "application/octet-stream") return "sniff";
6500
7512
  return "skip";
6501
7513
  }
7514
+ function prefilterText(body, headers) {
7515
+ const values = [];
7516
+ for (const value of Object.values(headers ?? {})) {
7517
+ if (Array.isArray(value)) values.push(...value.map(String));
7518
+ else if (value !== void 0 && value !== null) values.push(String(value));
7519
+ }
7520
+ return [body, ...values].join("\n").toLowerCase();
7521
+ }
6502
7522
  function looksBinary(bytes) {
6503
7523
  const n = Math.min(bytes.length, 512);
6504
7524
  let ctrl = 0;
@@ -6509,11 +7529,72 @@ function looksBinary(bytes) {
6509
7529
  }
6510
7530
  return n > 0 && ctrl / n > 0.1;
6511
7531
  }
7532
+ function declaredCodings(value) {
7533
+ const raw = Array.isArray(value) ? value.join(",") : String(value ?? "");
7534
+ return raw.toLowerCase().split(",").map((c) => c.trim()).filter((c) => c !== "" && c !== "identity");
7535
+ }
7536
+ function stillEncoded(bytes) {
7537
+ if (looksBinary(bytes)) return true;
7538
+ try {
7539
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
7540
+ return false;
7541
+ } catch {
7542
+ return true;
7543
+ }
7544
+ }
7545
+ var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
7546
+ var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
7547
+ function declaredCharset(ct) {
7548
+ const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
7549
+ const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
7550
+ return label || null;
7551
+ }
7552
+ function charsetReading(ct, bytes) {
7553
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
7554
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
7555
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
7556
+ const charset = declaredCharset(ct);
7557
+ if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
7558
+ if (WIDE_CHARSET.test(charset)) return { skip: charset };
7559
+ for (let i = 0; i < bytes.length; i++) {
7560
+ const b = bytes[i];
7561
+ if (b >= 128 || b === 0 || b === 27) return { skip: charset };
7562
+ }
7563
+ return { ascii: true };
7564
+ }
7565
+ function decodeForScreening(ct, bytes) {
7566
+ const reading = charsetReading(ct, bytes);
7567
+ if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
7568
+ return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
7569
+ }
7570
+ function withBom(bom, body) {
7571
+ return bom ? "\uFEFF" + body : body;
7572
+ }
7573
+ function outgrowsCharset(ascii, body) {
7574
+ return ascii === true && /[^\x00-\x7f]/.test(body);
7575
+ }
7576
+ var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
7577
+ function readJsonEscapes(text) {
7578
+ if (!text.includes("\\u") && !text.includes("\\/")) return text;
7579
+ try {
7580
+ JSON.parse(text);
7581
+ } catch {
7582
+ return text;
7583
+ }
7584
+ return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
7585
+ if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
7586
+ const code = parseInt(hex, 16);
7587
+ if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
7588
+ return String.fromCharCode(code);
7589
+ });
7590
+ }
6512
7591
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6513
7592
  if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
6514
7593
  const ct = response.headers?.get?.("content-type") || "";
6515
7594
  const kind = screenableContentType(ct);
6516
7595
  if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
7596
+ const codings = declaredCodings(response.headers?.get?.("content-encoding"));
7597
+ const encodedSkip = () => ({ skip: "encoded-body", encoding: codings.join(", ") });
6517
7598
  const sniff = kind === "sniff";
6518
7599
  const len = Number(response.headers?.get?.("content-length") || 0);
6519
7600
  if (len && len > cap) return { skip: "body-cap" };
@@ -6557,13 +7638,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6557
7638
  } finally {
6558
7639
  reader.releaseLock();
6559
7640
  }
7641
+ const bytes = concatBytes(chunks, size);
7642
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
6560
7643
  try {
6561
- return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
7644
+ return decodeForScreening(ct, bytes);
6562
7645
  } catch {
6563
7646
  return { skip: "decode-failed" };
6564
7647
  }
6565
7648
  }
6566
7649
  try {
7650
+ if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
7651
+ const bytes = new Uint8Array(await clone.arrayBuffer());
7652
+ if (bytes.byteLength > cap) return { skip: "body-cap" };
7653
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
7654
+ if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
7655
+ return decodeForScreening(ct, bytes);
7656
+ }
6567
7657
  const text = await clone.text();
6568
7658
  if (text.length > cap) return { skip: "body-cap" };
6569
7659
  if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
@@ -6593,8 +7683,9 @@ function headerObject(headers) {
6593
7683
  function hasSpanMutations(rule) {
6594
7684
  let found = false;
6595
7685
  const walk = (conds) => {
6596
- for (const c of conds ?? []) {
7686
+ for (const c of Array.isArray(conds) ? conds : []) {
6597
7687
  if (found) return;
7688
+ if (!c || typeof c !== "object") continue;
6598
7689
  if (Array.isArray(c.rules)) walk(c.rules);
6599
7690
  const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
6600
7691
  if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
@@ -6603,10 +7694,34 @@ function hasSpanMutations(rule) {
6603
7694
  walk(rule.rule_v2);
6604
7695
  return found;
6605
7696
  }
6606
- function extractRedactors(rule) {
7697
+ function redactorScope(parameter) {
7698
+ if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
7699
+ const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
7700
+ for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
7701
+ if (typeof name !== "string" || name === "") return null;
7702
+ if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
7703
+ else if (name === "response.headers") scope.allHeaders = true;
7704
+ else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
7705
+ scope.body = true;
7706
+ scope.allHeaders = true;
7707
+ } else return null;
7708
+ }
7709
+ return scope;
7710
+ }
7711
+ var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
7712
+ function masksHeader(redactor, name) {
7713
+ return redactor.scope.allHeaders || redactor.scope.headers.has(name);
7714
+ }
7715
+ function extractRedactors(rule, onUnscoped) {
6607
7716
  const out = [];
7717
+ const scoped = (c) => {
7718
+ const scope = redactorScope(c.parameter);
7719
+ if (scope === null) onUnscoped?.(c.parameter);
7720
+ return scope;
7721
+ };
6608
7722
  const walk = (conds) => {
6609
- for (const c of conds ?? []) {
7723
+ for (const c of Array.isArray(conds) ? conds : []) {
7724
+ if (!c || typeof c !== "object") continue;
6610
7725
  if (Array.isArray(c.rules)) walk(c.rules);
6611
7726
  const m = c.match;
6612
7727
  if (!m) continue;
@@ -6614,16 +7729,21 @@ function extractRedactors(rule) {
6614
7729
  const safe = safeRegExp(m.value);
6615
7730
  if (safe) {
6616
7731
  const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
7732
+ const scope = scoped(c);
6617
7733
  try {
6618
- out.push({ re: new RegExp(safe.source, flags) });
7734
+ if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
6619
7735
  } catch {
6620
7736
  }
6621
7737
  }
6622
7738
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
6623
- out.push({ literal: String(m.value) });
7739
+ const scope = scoped(c);
7740
+ if (scope) out.push({ literal: String(m.value), scope });
6624
7741
  } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
6625
- out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
6626
- } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
7742
+ const scope = scoped(c);
7743
+ if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
7744
+ } else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
7745
+ onUnscoped?.(c.parameter);
7746
+ } else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
6627
7747
  const keys = Array.isArray(m.key) ? m.key : [m.key];
6628
7748
  for (const key of keys) {
6629
7749
  out.push({ jsonPath: String(key).split("."), condition: m.match });
@@ -6637,6 +7757,110 @@ function extractRedactors(rule) {
6637
7757
  function htmlEscape(str) {
6638
7758
  return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
6639
7759
  }
7760
+ function isJson(text) {
7761
+ try {
7762
+ JSON.parse(text);
7763
+ return true;
7764
+ } catch {
7765
+ return false;
7766
+ }
7767
+ }
7768
+ var JSON_STRING = 1;
7769
+ var JSON_NUMBER = 2;
7770
+ var JSON_LITERAL = 3;
7771
+ var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
7772
+ function isHexCode(c) {
7773
+ return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
7774
+ }
7775
+ var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
7776
+ function lexJson(text) {
7777
+ let kinds = new Int32Array(1024);
7778
+ let spans = new Int32Array(2048);
7779
+ let count = 0;
7780
+ const n = text.length;
7781
+ let i = 0;
7782
+ while (i < n) {
7783
+ const c = text.charCodeAt(i);
7784
+ if (c === 32 || c === 9 || c === 10 || c === 13) {
7785
+ i++;
7786
+ continue;
7787
+ }
7788
+ const start = i;
7789
+ let kind;
7790
+ if (c === 34) {
7791
+ i++;
7792
+ const close = text.indexOf('"', i);
7793
+ if (close === -1) return null;
7794
+ if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
7795
+ i = close;
7796
+ } else {
7797
+ for (; ; ) {
7798
+ if (i >= n) return null;
7799
+ const d = text.charCodeAt(i);
7800
+ if (d === 34) break;
7801
+ if (d < 32) return null;
7802
+ if (d !== 92) {
7803
+ i++;
7804
+ continue;
7805
+ }
7806
+ const e = text.charCodeAt(i + 1);
7807
+ if (e === 117) {
7808
+ for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
7809
+ i += 6;
7810
+ } else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
7811
+ i += 2;
7812
+ } else {
7813
+ return null;
7814
+ }
7815
+ }
7816
+ }
7817
+ i++;
7818
+ kind = JSON_STRING;
7819
+ } else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
7820
+ i++;
7821
+ kind = c;
7822
+ } else if (text.startsWith("true", i) || text.startsWith("null", i)) {
7823
+ i += 4;
7824
+ kind = JSON_LITERAL;
7825
+ } else if (text.startsWith("false", i)) {
7826
+ i += 5;
7827
+ kind = JSON_LITERAL;
7828
+ } else {
7829
+ JSON_NUMBER_TOKEN.lastIndex = i;
7830
+ if (!JSON_NUMBER_TOKEN.test(text)) return null;
7831
+ i = JSON_NUMBER_TOKEN.lastIndex;
7832
+ kind = JSON_NUMBER;
7833
+ }
7834
+ if (count === kinds.length) {
7835
+ const grownKinds = new Int32Array(count * 2);
7836
+ grownKinds.set(kinds);
7837
+ kinds = grownKinds;
7838
+ const grownSpans = new Int32Array(count * 4);
7839
+ grownSpans.set(spans);
7840
+ spans = grownSpans;
7841
+ }
7842
+ kinds[count] = kind;
7843
+ spans[2 * count] = start;
7844
+ spans[2 * count + 1] = i;
7845
+ count++;
7846
+ }
7847
+ return { kinds, spans, count };
7848
+ }
7849
+ function sameJsonStructure(beforeText, before, afterText, after) {
7850
+ if (!before || !after || before.count !== after.count) return false;
7851
+ const { kinds, spans, count } = before;
7852
+ for (let k = 0; k < count; k++) {
7853
+ const kind = kinds[k];
7854
+ if (after.kinds[k] !== kind) return false;
7855
+ if (kind > JSON_LITERAL) continue;
7856
+ if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
7857
+ const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
7858
+ const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
7859
+ if (was === now) continue;
7860
+ if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
7861
+ }
7862
+ return true;
7863
+ }
6640
7864
  function applyRedactors(body, redactors, mask, transform) {
6641
7865
  let out = body;
6642
7866
  for (const r of redactors) {
@@ -6652,6 +7876,11 @@ function applyRedactors(body, redactors, mask, transform) {
6652
7876
  }
6653
7877
  return out;
6654
7878
  }
7879
+ function readsBody(parameter) {
7880
+ if (!Array.isArray(parameter)) return isBodyParam(parameter);
7881
+ const names = parameter.every((member) => typeof member === "string" && member !== "");
7882
+ return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
7883
+ }
6655
7884
  function isBodyParam(parameter) {
6656
7885
  return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
6657
7886
  }
@@ -6679,10 +7908,12 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
6679
7908
  if (!pathRedactors.length || typeof text !== "string" || text.length > cap) return text;
6680
7909
  const head = text.trimStart()[0];
6681
7910
  if (head !== "{" && head !== "[") return text;
6682
- const preserved = preserveBigInts(text);
7911
+ let preserved;
6683
7912
  let obj;
6684
7913
  try {
6685
- obj = JSON.parse(preserved);
7914
+ JSON.parse(text);
7915
+ preserved = preserveNumbers(text, mask);
7916
+ obj = JSON.parse(preserved.text);
6686
7917
  } catch {
6687
7918
  return text;
6688
7919
  }
@@ -6691,65 +7922,44 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
6691
7922
  const pred = conditionPredicate(r.condition);
6692
7923
  walkLeaves(obj, r.jsonPath, (loc) => {
6693
7924
  try {
6694
- if (pred(loc.value)) {
6695
- loc.parent[loc.key] = transform ? transform(String(loc.value)) : mask;
7925
+ const number = preserved.numbers.get(loc.value);
7926
+ if (pred(number === void 0 ? loc.value : Number(number))) {
7927
+ setOwn(loc.parent, loc.key, transform ? transform(number ?? String(loc.value)) : mask);
6696
7928
  changed = true;
6697
7929
  }
6698
7930
  } catch {
6699
7931
  }
6700
7932
  });
6701
7933
  }
6702
- return changed ? restoreBigInts(JSON.stringify(obj)) : text;
6703
- }
6704
- var BIGINT_OPEN = "__PSBIGINT_9c2f__";
6705
- var BIGINT_CLOSE = "__DNEGIB__";
6706
- function preserveBigInts(text) {
6707
- let out = "";
6708
- let inStr = false;
6709
- for (let i = 0; i < text.length; ) {
6710
- const ch = text[i];
6711
- if (inStr) {
6712
- out += ch;
6713
- if (ch === "\\") {
6714
- out += text[i + 1] ?? "";
6715
- i += 2;
6716
- continue;
6717
- }
6718
- if (ch === '"') inStr = false;
6719
- i++;
6720
- continue;
6721
- }
6722
- if (ch === '"') {
6723
- inStr = true;
6724
- out += ch;
6725
- i++;
6726
- continue;
6727
- }
6728
- if (ch === "-" || ch >= "0" && ch <= "9") {
6729
- let j = ch === "-" ? i + 1 : i;
6730
- let digits = 0;
6731
- while (j < text.length && text[j] >= "0" && text[j] <= "9") {
6732
- digits++;
6733
- j++;
6734
- }
6735
- const next = text[j];
6736
- const isIntToken = digits > 0 && next !== "." && next !== "e" && next !== "E";
6737
- if (isIntToken && digits >= 16) {
6738
- out += `"${BIGINT_OPEN}${text.slice(i, j)}${BIGINT_CLOSE}"`;
6739
- } else {
6740
- out += text.slice(i, j || i + 1);
6741
- }
6742
- i = j > i ? j : i + 1;
6743
- continue;
6744
- }
6745
- out += ch;
6746
- i++;
6747
- }
6748
- return out;
7934
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
6749
7935
  }
6750
- function restoreBigInts(text) {
6751
- if (!text.includes(BIGINT_OPEN)) return text;
6752
- return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
7936
+ function preserveNumbers(text, mask) {
7937
+ const occupied = /* @__PURE__ */ new Set([mask]);
7938
+ const found = [];
7939
+ const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
7940
+ for (const match of text.matchAll(tokens)) {
7941
+ const token = match[0];
7942
+ if (token[0] === '"') occupied.add(JSON.parse(token));
7943
+ else found.push({ start: match.index, token });
7944
+ }
7945
+ const numbers = /* @__PURE__ */ new Map();
7946
+ const chunks = [];
7947
+ let offset = 0;
7948
+ let index = 0;
7949
+ for (const { start, token } of found) {
7950
+ let marker;
7951
+ do {
7952
+ marker = "__PSNUMBER_" + index++ + "__";
7953
+ } while (occupied.has(marker));
7954
+ numbers.set(marker, token);
7955
+ chunks.push(text.slice(offset, start), JSON.stringify(marker));
7956
+ offset = start + token.length;
7957
+ }
7958
+ chunks.push(text.slice(offset));
7959
+ return { text: chunks.join(""), numbers };
7960
+ }
7961
+ function restoreNumbers(text, numbers) {
7962
+ return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
6753
7963
  }
6754
7964
  function isHeaderMutation(action) {
6755
7965
  return action === "set-header" || action === "remove-header" || action === "harden-cookie";