@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.
package/dist/protect.js CHANGED
@@ -376,18 +376,124 @@ function appendOwn(target, key, value) {
376
376
  return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
377
377
  }
378
378
 
379
- // src/protect/engine/request.js
380
- var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
381
- var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
382
- var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
383
- function base64DecodeUtf8(value) {
384
- const str = String(value);
385
- if (typeof Buffer !== "undefined") {
386
- return Buffer.from(str, "base64").toString("utf-8");
379
+ // src/protect/engine/cookies.js
380
+ function parseCookieHeader(header) {
381
+ const cookies = {};
382
+ if (typeof header !== "string" || header === "") return cookies;
383
+ const repeated = /* @__PURE__ */ new Map();
384
+ for (const pair of header.split(";")) {
385
+ const eq = pair.indexOf("=");
386
+ if (eq === -1) continue;
387
+ const name = pair.slice(0, eq).trim();
388
+ if (name === "") continue;
389
+ let value = pair.slice(eq + 1).trim();
390
+ if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') value = value.slice(1, -1);
391
+ if (!Object.hasOwn(cookies, name)) {
392
+ setOwn(cookies, name, value);
393
+ } else {
394
+ let values = repeated.get(name);
395
+ if (values === void 0) {
396
+ values = [cookies[name]];
397
+ repeated.set(name, values);
398
+ setOwn(cookies, name, values);
399
+ }
400
+ values.push(value);
401
+ }
387
402
  }
388
- const binary = atob(str);
389
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
390
- return new TextDecoder().decode(bytes);
403
+ return cookies;
404
+ }
405
+
406
+ // src/protect/engine/normalizer.js
407
+ var HTML_ENTITIES = {
408
+ "&": "&",
409
+ "&lt;": "<",
410
+ "&gt;": ">",
411
+ "&quot;": '"',
412
+ "&#39;": "'",
413
+ "&apos;": "'",
414
+ "&nbsp;": " ",
415
+ "&#x27;": "'",
416
+ "&#x22;": '"',
417
+ "&#x3C;": "<",
418
+ "&#x3E;": ">",
419
+ "&#x26;": "&",
420
+ "&#34;": '"',
421
+ "&#60;": "<",
422
+ "&#62;": ">",
423
+ "&#38;": "&"
424
+ };
425
+ var MAX_DECODE_ITERATIONS = 5;
426
+ function normalize(value, options = {}) {
427
+ if (typeof value !== "string") {
428
+ return value;
429
+ }
430
+ const opts = {
431
+ urlDecode: true,
432
+ htmlDecode: true,
433
+ sqlComments: true,
434
+ nullBytes: true,
435
+ whitespace: true,
436
+ ...options
437
+ };
438
+ let result = value;
439
+ if (opts.nullBytes) {
440
+ result = removeNullBytes(result);
441
+ }
442
+ if (opts.urlDecode) {
443
+ result = urlDecode(result);
444
+ }
445
+ if (opts.htmlDecode) {
446
+ result = htmlEntityDecode(result);
447
+ }
448
+ if (opts.sqlComments) {
449
+ result = removeSqlComments(result);
450
+ }
451
+ if (opts.whitespace) {
452
+ result = normalizeWhitespace(result);
453
+ }
454
+ return result;
455
+ }
456
+ function urlDecode(value) {
457
+ if (typeof value !== "string") {
458
+ return value;
459
+ }
460
+ let result = value;
461
+ let previous = "";
462
+ let iterations = 0;
463
+ while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
464
+ previous = result;
465
+ iterations++;
466
+ result = safeUrlDecode(result);
467
+ }
468
+ return result;
469
+ }
470
+ var utf8 = new TextDecoder();
471
+ function safeUrlDecode(value) {
472
+ return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
473
+ try {
474
+ return decodeURIComponent(run);
475
+ } catch {
476
+ const bytes = new Uint8Array(run.length / 3);
477
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
478
+ return utf8.decode(bytes);
479
+ }
480
+ });
481
+ }
482
+ function decodeQueryPlus(target) {
483
+ if (typeof target !== "string") return target;
484
+ const query = target.indexOf("?");
485
+ if (query === -1) return target;
486
+ return target.slice(0, query + 1) + target.slice(query + 1).replace(/\+/g, " ");
487
+ }
488
+ function htmlEntityDecode(value) {
489
+ if (typeof value !== "string") {
490
+ return value;
491
+ }
492
+ let result = value;
493
+ for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
494
+ result = result.split(entity).join(char);
495
+ }
496
+ return decodeHtmlEntities(result);
391
497
  }
392
498
  var NAMED_ENTITIES = {
393
499
  lt: "<",
@@ -395,7 +501,6 @@ var NAMED_ENTITIES = {
395
501
  amp: "&",
396
502
  quot: '"',
397
503
  apos: "'",
398
- "#39": "'",
399
504
  nbsp: "\xA0",
400
505
  sol: "/",
401
506
  bsol: "\\",
@@ -414,7 +519,7 @@ var NAMED_ENTITIES = {
414
519
  ast: "*"
415
520
  };
416
521
  function decodeHtmlEntities(input) {
417
- return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
522
+ return input.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
418
523
  if (body[0] === "#") {
419
524
  const hex = body[1] === "x" || body[1] === "X";
420
525
  const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
@@ -428,74 +533,359 @@ function decodeHtmlEntities(input) {
428
533
  return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
429
534
  });
430
535
  }
431
- var RequestResolver = class {
432
- #req;
433
- #cookies;
434
- constructor(req) {
435
- this.#req = req;
436
- this.#cookies = null;
536
+ function removeSqlComments(value) {
537
+ if (typeof value !== "string") {
538
+ return value;
437
539
  }
438
- resolve(parameter) {
439
- if (!parameter || parameter === "false") {
440
- return [null];
540
+ let result = value;
541
+ result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
542
+ result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
543
+ return result;
544
+ }
545
+ function removeNullBytes(value) {
546
+ if (typeof value !== "string") {
547
+ return value;
548
+ }
549
+ let result = value.replace(/\x00/g, "");
550
+ result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
551
+ return result;
552
+ }
553
+ function normalizeWhitespace(value) {
554
+ if (typeof value !== "string") {
555
+ return value;
556
+ }
557
+ let result = value.replace(/[\t\r\n\f\v]+/g, " ");
558
+ result = result.replace(/ {2,}/g, " ");
559
+ return result;
560
+ }
561
+ var MAX_RAW_CHARS = 1024 * 1024;
562
+ var MAX_RAW_NODES = 25e3;
563
+ var MAX_RAW_DEPTH = 200;
564
+ var MAX_RAW_ENTRIES = 2e3;
565
+ function serializeForRawDetection(body) {
566
+ if (body === null || body === void 0) return "";
567
+ if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
568
+ const visited = /* @__PURE__ */ new Set();
569
+ const output = [];
570
+ const stack = [{ kind: "value", value: body, depth: 0, root: true }];
571
+ let chars = 0;
572
+ let nodes = 0;
573
+ const append = (text) => {
574
+ if (chars >= MAX_RAW_CHARS) return;
575
+ const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
576
+ output.push(piece);
577
+ chars += piece.length;
578
+ };
579
+ while (stack.length > 0 && chars < MAX_RAW_CHARS) {
580
+ const task = stack.pop();
581
+ if (task.kind === "text") {
582
+ append(task.text);
583
+ continue;
441
584
  }
442
- if (parameter === "rules") {
443
- return [null];
585
+ if (task.kind === "key") {
586
+ append(boundedJsonString(task.value));
587
+ append(":");
588
+ continue;
444
589
  }
445
- if (parameter === "raw") {
446
- return this.#resolveRaw();
590
+ nodes++;
591
+ if (nodes > MAX_RAW_NODES) {
592
+ append('"[NodeLimit]"');
593
+ continue;
447
594
  }
448
- if (parameter === "all") {
449
- return this.#resolveAll();
595
+ const value = task.value;
596
+ if (value === null || value === void 0) {
597
+ append(task.root ? "" : "null");
598
+ continue;
450
599
  }
451
- const dotIndex = parameter.indexOf(".");
452
- if (dotIndex === -1) {
453
- return [];
600
+ if (typeof value === "string") {
601
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
602
+ continue;
454
603
  }
455
- const source = parameter.substring(0, dotIndex);
456
- const key = parameter.substring(dotIndex + 1);
457
- switch (source) {
458
- case "get":
459
- return this.#resolveGet(key);
460
- case "post":
461
- return this.#resolvePost(key);
462
- case "request":
463
- return this.#resolveRequest(key);
464
- case "cookie":
465
- return this.#resolveCookie(key);
466
- case "server":
467
- return this.#resolveServer(key);
468
- case "files":
469
- return this.#resolveFiles(key);
470
- case "response":
471
- return this.#resolveResponse(key);
472
- case "egress":
473
- return this.#resolveEgress(key);
474
- default:
475
- return [];
604
+ if (typeof value !== "object") {
605
+ append(String(value).slice(0, MAX_RAW_CHARS));
606
+ continue;
476
607
  }
477
- }
478
- // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
479
- // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
480
- #resolveResponse(key) {
481
- const resp = this.#req._response;
482
- if (!resp) {
483
- return [];
608
+ if (visited.has(value)) {
609
+ append("[Circular]");
610
+ continue;
484
611
  }
485
- if (key === "status") {
486
- return resp.status !== void 0 ? [String(resp.status)] : [];
612
+ if (task.depth >= MAX_RAW_DEPTH) {
613
+ append('"[DepthLimit]"');
614
+ continue;
487
615
  }
488
- if (key === "body") {
489
- return resp.body != null && resp.body !== "" ? [resp.body] : [];
616
+ visited.add(value);
617
+ if (Array.isArray(value)) {
618
+ const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
619
+ const items = [];
620
+ for (let i = 0; i < length2; i++) {
621
+ if (i > 0) items.push({ kind: "text", text: "," });
622
+ items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
623
+ }
624
+ if (value.length > length2) {
625
+ if (length2 > 0) items.push({ kind: "text", text: "," });
626
+ items.push({ kind: "text", text: '"[EntryLimit]"' });
627
+ }
628
+ stack.push({ kind: "text", text: "]" });
629
+ for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
630
+ stack.push({ kind: "text", text: "[" });
631
+ continue;
490
632
  }
491
- if (key === "headers") {
492
- const headers = resp.headers ?? {};
493
- return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
633
+ let keys;
634
+ try {
635
+ keys = Object.getOwnPropertyNames(value);
636
+ } catch {
637
+ append('"[Unreadable]"');
638
+ continue;
494
639
  }
495
- if (key.startsWith("header.")) {
496
- const name = key.slice("header.".length).toLowerCase();
497
- const headers = resp.headers ?? {};
498
- return Object.hasOwn(headers, name) ? [headers[name]] : [];
640
+ const length = Math.min(keys.length, MAX_RAW_ENTRIES);
641
+ const entries = [];
642
+ for (let i = 0; i < length; i++) {
643
+ const key = keys[i];
644
+ if (i > 0) entries.push({ kind: "text", text: "," });
645
+ entries.push({ kind: "key", value: key });
646
+ entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
647
+ }
648
+ if (keys.length > length) {
649
+ if (length > 0) entries.push({ kind: "text", text: "," });
650
+ entries.push({ kind: "text", text: '"[EntryLimit]":null' });
651
+ }
652
+ stack.push({ kind: "text", text: "}" });
653
+ for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
654
+ stack.push({ kind: "text", text: "{" });
655
+ }
656
+ return output.join("");
657
+ }
658
+ function boundedJsonString(value) {
659
+ return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
660
+ }
661
+ function readOwnValue(value, key) {
662
+ try {
663
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
664
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
665
+ } catch {
666
+ return "[Unreadable]";
667
+ }
668
+ }
669
+ var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
670
+ function requestField(req, key) {
671
+ if (req === null || typeof req !== "object") return void 0;
672
+ if (Object.hasOwn(req, key)) return req[key];
673
+ if (!INHERITED_ACCESSORS.has(key)) return void 0;
674
+ let holder = Object.getPrototypeOf(req);
675
+ while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
676
+ if (holder === null || holder === Object.prototype) return void 0;
677
+ const descriptor = Object.getOwnPropertyDescriptor(holder, key);
678
+ if (typeof descriptor?.get !== "function") return void 0;
679
+ return req[key];
680
+ }
681
+ function normalizeRequest(req, options = {}) {
682
+ const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
683
+ const body = requestField(req, "body");
684
+ const url = requestField(req, "url");
685
+ const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
686
+ const headers = requestField(req, "headers") || {};
687
+ return {
688
+ query: normalizeObject(requestField(req, "query") || {}, options),
689
+ body: normalizeObject(body || {}, options),
690
+ headers: normalizeObject(headers, options),
691
+ // Cookies are split into name/value pairs BEFORE their values are normalised, and normalised the
692
+ // same way whether a framework parsed them or they come from the header — so a rule on a cookie
693
+ // sees one value, independent of which cookie parser (if any) ran first.
694
+ cookies: normalizeObject(cookieValues(requestField(req, "cookies"), headers), options),
695
+ url: normalize(decodeQueryPlus(url || ""), options),
696
+ originalUrl: normalize(decodeQueryPlus(requestField(req, "originalUrl") || url || ""), options),
697
+ _rawBody: rawBody
698
+ };
699
+ }
700
+ function cookieValues(parsed, headers) {
701
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
702
+ return parseCookieHeader(headers?.cookie);
703
+ }
704
+ var MAX_NORMALIZE_DEPTH = 1e3;
705
+ function normalizeObject(value, options = {}, depth = 0) {
706
+ if (typeof value === "string") {
707
+ return normalize(value, options);
708
+ }
709
+ if (value === null || typeof value !== "object") {
710
+ return value;
711
+ }
712
+ if (depth >= MAX_NORMALIZE_DEPTH) {
713
+ options.onLimit?.();
714
+ return value;
715
+ }
716
+ const copies = /* @__PURE__ */ new Map();
717
+ const copyOf = (node) => {
718
+ const copy = Array.isArray(node) ? new Array(node.length) : {};
719
+ copies.set(node, copy);
720
+ return copy;
721
+ };
722
+ const top = copyOf(value);
723
+ const stack = [[value, top, depth]];
724
+ while (stack.length > 0) {
725
+ const [node, copy, level] = stack.pop();
726
+ for (const key of Object.keys(node)) {
727
+ const child = node[key];
728
+ if (typeof child === "string") {
729
+ setOwn(copy, key, normalize(child, options));
730
+ } else if (child === null || typeof child !== "object") {
731
+ setOwn(copy, key, child);
732
+ } else if (copies.has(child)) {
733
+ setOwn(copy, key, copies.get(child));
734
+ } else if (level + 1 >= MAX_NORMALIZE_DEPTH) {
735
+ setOwn(copy, key, child);
736
+ options.onLimit?.();
737
+ } else {
738
+ const childCopy = copyOf(child);
739
+ setOwn(copy, key, childCopy);
740
+ stack.push([child, childCopy, level + 1]);
741
+ }
742
+ }
743
+ }
744
+ return top;
745
+ }
746
+
747
+ // src/protect/engine/request.js
748
+ var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
749
+ var ownValue = (obj, key) => obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
750
+ function fieldSpellings(key) {
751
+ const spellings = [{ key, nested: true }];
752
+ if (key.includes("[")) {
753
+ const dotted = key.replace(/\[\]$/, "").replace(/\[([^\][]*)\]/g, ".$1");
754
+ if (dotted !== key && dotted !== "" && !dotted.includes("[") && !dotted.includes("]")) {
755
+ spellings.push({ key: dotted, nested: true });
756
+ }
757
+ return spellings;
758
+ }
759
+ const [head, ...rest] = key.split(".");
760
+ const bracketed = head + rest.map((part) => `[${part}]`).join("");
761
+ if (bracketed !== key) spellings.push({ key: bracketed, nested: false });
762
+ spellings.push({ key: `${bracketed}[]`, nested: false });
763
+ return spellings;
764
+ }
765
+ var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
766
+ var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
767
+ function base64DecodeUtf8(value) {
768
+ const str = String(value);
769
+ if (typeof Buffer !== "undefined") {
770
+ return Buffer.from(str, "base64").toString("utf-8");
771
+ }
772
+ const binary = atob(str);
773
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
774
+ return new TextDecoder().decode(bytes);
775
+ }
776
+ var TEXT_MUTATIONS = /* @__PURE__ */ new Set(["base64_decode", "urldecode", "htmlentitydecode"]);
777
+ var MAX_MAPPED_NODES = 2e4;
778
+ var MAX_MAPPED_DEPTH = 1e3;
779
+ function mapStringLeaves(root, fn, onLimit) {
780
+ const copies = /* @__PURE__ */ new Map();
781
+ const copyOf = (node) => {
782
+ const copy = Array.isArray(node) ? [] : {};
783
+ copies.set(node, copy);
784
+ return copy;
785
+ };
786
+ const top = copyOf(root);
787
+ const stack = [[root, top, 0]];
788
+ let visited = 0;
789
+ while (stack.length) {
790
+ const [node, copy, depth] = stack.pop();
791
+ visited++;
792
+ for (const key of Object.keys(node)) {
793
+ const child = node[key];
794
+ if (typeof child === "string") {
795
+ setOwn(copy, key, fn(child));
796
+ } else if (child === null || typeof child !== "object") {
797
+ setOwn(copy, key, child);
798
+ } else if (copies.has(child)) {
799
+ setOwn(copy, key, copies.get(child));
800
+ } else if (depth + 1 >= MAX_MAPPED_DEPTH || visited + stack.length >= MAX_MAPPED_NODES) {
801
+ setOwn(copy, key, child);
802
+ onLimit();
803
+ } else {
804
+ const childCopy = copyOf(child);
805
+ setOwn(copy, key, childCopy);
806
+ stack.push([child, childCopy, depth + 1]);
807
+ }
808
+ }
809
+ }
810
+ return top;
811
+ }
812
+ var RequestResolver = class {
813
+ #req;
814
+ #cookies;
815
+ #skips = /* @__PURE__ */ new Set();
816
+ constructor(req) {
817
+ this.#req = req;
818
+ this.#cookies = null;
819
+ }
820
+ /** Record an inspection limit this evaluation reached (reported once per reason). */
821
+ noteSkip(reason) {
822
+ this.#skips.add(reason);
823
+ }
824
+ /** The inspection limits recorded so far, in the order first reached. */
825
+ get skips() {
826
+ return [...this.#skips];
827
+ }
828
+ resolve(parameter) {
829
+ if (!parameter || parameter === "false") {
830
+ return [null];
831
+ }
832
+ if (parameter === "rules") {
833
+ return [null];
834
+ }
835
+ if (parameter === "raw") {
836
+ return this.#resolveRaw();
837
+ }
838
+ if (parameter === "all") {
839
+ return this.#resolveAll();
840
+ }
841
+ const dotIndex = parameter.indexOf(".");
842
+ if (dotIndex === -1) {
843
+ return [];
844
+ }
845
+ const source = parameter.substring(0, dotIndex);
846
+ const key = parameter.substring(dotIndex + 1);
847
+ switch (source) {
848
+ case "get":
849
+ return this.#resolveGet(key);
850
+ case "post":
851
+ return this.#resolvePost(key);
852
+ case "request":
853
+ return this.#resolveRequest(key);
854
+ case "cookie":
855
+ return this.#resolveCookie(key);
856
+ case "server":
857
+ return this.#resolveServer(key);
858
+ case "files":
859
+ return this.#resolveFiles(key);
860
+ case "response":
861
+ return this.#resolveResponse(key);
862
+ case "egress":
863
+ return this.#resolveEgress(key);
864
+ default:
865
+ return [];
866
+ }
867
+ }
868
+ // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
869
+ // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
870
+ #resolveResponse(key) {
871
+ const resp = this.#req._response;
872
+ if (!resp) {
873
+ return [];
874
+ }
875
+ if (key === "status") {
876
+ return resp.status !== void 0 ? [String(resp.status)] : [];
877
+ }
878
+ if (key === "body") {
879
+ return resp.body != null && resp.body !== "" ? [resp.body] : [];
880
+ }
881
+ if (key === "headers") {
882
+ const headers = resp.headers ?? {};
883
+ return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
884
+ }
885
+ if (key.startsWith("header.")) {
886
+ const name = key.slice("header.".length).toLowerCase();
887
+ const headers = resp.headers ?? {};
888
+ return Object.hasOwn(headers, name) ? [headers[name]] : [];
499
889
  }
500
890
  return [];
501
891
  }
@@ -525,6 +915,9 @@ var RequestResolver = class {
525
915
  if (value === null || value === void 0) {
526
916
  return value;
527
917
  }
918
+ if (typeof value === "object" && TEXT_MUTATIONS.has(mutation)) {
919
+ return mapStringLeaves(value, (leaf) => this.#applyMutation(mutation, leaf), () => this.noteSkip("container-cap"));
920
+ }
528
921
  switch (mutation) {
529
922
  case "base64_decode":
530
923
  try {
@@ -545,11 +938,7 @@ var RequestResolver = class {
545
938
  return value;
546
939
  }
547
940
  case "urldecode":
548
- try {
549
- return decodeURIComponent(String(value));
550
- } catch {
551
- return value;
552
- }
941
+ return safeUrlDecode(String(value).replace(/\+/g, " "));
553
942
  case "htmlentitydecode":
554
943
  return decodeHtmlEntities(String(value));
555
944
  case "intval":
@@ -568,16 +957,30 @@ var RequestResolver = class {
568
957
  if (key.endsWith("*")) {
569
958
  return this.#resolveWildcard(query, key);
570
959
  }
571
- const value = this.#getNestedValue(query, key);
572
- return value !== void 0 ? [value] : [];
960
+ return this.#lookup(query, key);
573
961
  }
574
962
  #resolvePost(key) {
575
963
  const body = this.#req.body ?? {};
576
964
  if (key.endsWith("*")) {
577
965
  return this.#resolveWildcard(body, key);
578
966
  }
579
- const value = this.#getNestedValue(body, key);
580
- return value !== void 0 ? [value] : [];
967
+ return this.#lookup(body, key);
968
+ }
969
+ /**
970
+ * Every value a field path names in `obj`, in either spelling a form or query field can take.
971
+ *
972
+ * A parser that expands brackets (`qs`, as Express uses) turns `user[name]=x` into `{ user: { name } }`
973
+ * and `id[]=x` into `{ id: [x] }`; a flat parser (`URLSearchParams`, as the Fetch and Node adapters use)
974
+ * keeps `user[name]` and `id[]` as literal keys. The rule names the field once, so both shapes answer
975
+ * to both spellings: `user.name` and `user[name]`, `id` and `id[]`.
976
+ */
977
+ #lookup(obj, key) {
978
+ const values = [];
979
+ for (const candidate of fieldSpellings(key)) {
980
+ const value = candidate.nested ? this.#getNestedValue(obj, candidate.key) : ownValue(obj, candidate.key);
981
+ if (value !== void 0) values.push(value);
982
+ }
983
+ return values;
581
984
  }
582
985
  #resolveRequest(key) {
583
986
  const query = this.#req.query ?? {};
@@ -590,8 +993,11 @@ var RequestResolver = class {
590
993
  ...this.#resolveWildcard(cookies, key)
591
994
  ];
592
995
  }
593
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
594
- return value !== void 0 ? [value] : [];
996
+ const fromQuery = this.#lookup(query, key);
997
+ if (fromQuery.length > 0) return fromQuery;
998
+ const fromBody = this.#lookup(body, key);
999
+ if (fromBody.length > 0) return fromBody;
1000
+ return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
595
1001
  }
596
1002
  #resolveCookie(key) {
597
1003
  const cookies = this.#parseCookies();
@@ -666,424 +1072,146 @@ var RequestResolver = class {
666
1072
  }
667
1073
  const body = this.#req.body;
668
1074
  if (body === void 0 || body === null) {
669
- return [];
670
- }
671
- if (typeof body === "string") {
672
- return [body];
673
- }
674
- try {
675
- return [JSON.stringify(body)];
676
- } catch {
677
- return [String(body)];
678
- }
679
- }
680
- #resolveAll() {
681
- const parts = [];
682
- const uri = this.#req.originalUrl ?? this.#req.url ?? "/";
683
- parts.push(uri);
684
- const queryString = uri.includes("?") ? uri.split("?")[1] : "";
685
- if (queryString) {
686
- parts.push(queryString);
687
- }
688
- const body = this.#req.body;
689
- if (body) {
690
- parts.push(typeof body === "string" ? body : JSON.stringify(body));
691
- }
692
- if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
693
- parts.push(this.#req._rawBody);
694
- }
695
- const headers = this.#req.headers ?? {};
696
- const excludedHeaders = /* @__PURE__ */ new Set([
697
- "host",
698
- "connection",
699
- "cache-control",
700
- "accept",
701
- "accept-encoding",
702
- "accept-language",
703
- "priority",
704
- "sec-ch-ua",
705
- "sec-ch-ua-mobile",
706
- "sec-ch-ua-platform",
707
- "sec-fetch-dest",
708
- "sec-fetch-mode",
709
- "sec-fetch-site",
710
- "sec-fetch-user",
711
- "upgrade-insecure-requests"
712
- ]);
713
- for (const [name, value] of Object.entries(headers)) {
714
- if (!excludedHeaders.has(name)) {
715
- parts.push(`${name}: ${value}`);
716
- }
717
- }
718
- const cookies = this.#parseCookies();
719
- const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ");
720
- if (cookieStr) {
721
- parts.push(cookieStr);
722
- }
723
- return [parts.join(" ")];
724
- }
725
- #resolveWildcard(obj, pattern) {
726
- if (typeof obj !== "object" || obj === null) {
727
- return [];
728
- }
729
- const prefix = pattern.slice(0, -1);
730
- const values = [];
731
- for (const [key, value] of Object.entries(obj)) {
732
- if (key.startsWith(prefix)) {
733
- values.push(value);
734
- }
735
- }
736
- return values;
737
- }
738
- #getNestedValue(obj, key) {
739
- if (typeof obj !== "object" || obj === null) {
740
- return void 0;
741
- }
742
- const own = (o, k) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
743
- if (own(obj, key)) {
744
- return obj[key];
745
- }
746
- const parts = key.split(".");
747
- let current = obj;
748
- for (const part of parts) {
749
- if (!own(current, part)) {
750
- return void 0;
751
- }
752
- current = current[part];
753
- }
754
- return current;
755
- }
756
- #parseCookies() {
757
- if (this.#cookies !== null) {
758
- return this.#cookies;
759
- }
760
- if (this.#req.cookies) {
761
- this.#cookies = this.#req.cookies;
762
- return this.#cookies;
763
- }
764
- const header = this.#req.headers?.cookie;
765
- if (!header) {
766
- this.#cookies = {};
767
- return this.#cookies;
768
- }
769
- const cookies = {};
770
- for (const pair of header.split(";")) {
771
- const eqIndex = pair.indexOf("=");
772
- if (eqIndex === -1) {
773
- continue;
774
- }
775
- const name = pair.substring(0, eqIndex).trim();
776
- const value = pair.substring(eqIndex + 1).trim();
777
- setOwn(cookies, name, value);
778
- }
779
- this.#cookies = cookies;
780
- return this.#cookies;
781
- }
782
- };
783
-
784
- // src/protect/notify.js
785
- var reported = /* @__PURE__ */ new Set();
786
- function notify(fn, arg, label) {
787
- if (typeof fn !== "function") return false;
788
- try {
789
- const result = fn(arg);
790
- if (result !== null && typeof result === "object" && typeof result.then === "function") {
791
- try {
792
- result.then(void 0, (err) => warnOnce(label, err));
793
- } catch {
794
- }
795
- }
796
- return true;
797
- } catch (err) {
798
- warnOnce(label, err);
799
- return false;
800
- }
801
- }
802
- function warnOnce(label, err) {
803
- if (reported.has(label)) return;
804
- reported.add(label);
805
- try {
806
- console.warn(
807
- `Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
808
- );
809
- } catch {
810
- }
811
- }
812
-
813
- // src/protect/engine/normalizer.js
814
- var HTML_ENTITIES = {
815
- "&amp;": "&",
816
- "&lt;": "<",
817
- "&gt;": ">",
818
- "&quot;": '"',
819
- "&#39;": "'",
820
- "&apos;": "'",
821
- "&nbsp;": " ",
822
- "&#x27;": "'",
823
- "&#x22;": '"',
824
- "&#x3C;": "<",
825
- "&#x3E;": ">",
826
- "&#x26;": "&",
827
- "&#34;": '"',
828
- "&#60;": "<",
829
- "&#62;": ">",
830
- "&#38;": "&"
831
- };
832
- var MAX_DECODE_ITERATIONS = 5;
833
- function normalize(value, options = {}) {
834
- if (typeof value !== "string") {
835
- return value;
836
- }
837
- const opts = {
838
- urlDecode: true,
839
- htmlDecode: true,
840
- sqlComments: true,
841
- nullBytes: true,
842
- whitespace: true,
843
- ...options
844
- };
845
- let result = value;
846
- if (opts.nullBytes) {
847
- result = removeNullBytes(result);
848
- }
849
- if (opts.urlDecode) {
850
- result = urlDecode(result);
851
- }
852
- if (opts.htmlDecode) {
853
- result = htmlEntityDecode(result);
854
- }
855
- if (opts.sqlComments) {
856
- result = removeSqlComments(result);
857
- }
858
- if (opts.whitespace) {
859
- result = normalizeWhitespace(result);
860
- }
861
- return result;
862
- }
863
- function urlDecode(value) {
864
- if (typeof value !== "string") {
865
- return value;
866
- }
867
- let result = value;
868
- let previous = "";
869
- let iterations = 0;
870
- while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
871
- previous = result;
872
- iterations++;
873
- try {
874
- result = decodeURIComponent(result);
875
- } catch {
876
- result = safeUrlDecode(result);
877
- break;
878
- }
879
- }
880
- return result;
881
- }
882
- function safeUrlDecode(value) {
883
- return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
884
- try {
885
- return String.fromCharCode(parseInt(hex, 16));
886
- } catch {
887
- return match;
888
- }
889
- });
890
- }
891
- function htmlEntityDecode(value) {
892
- if (typeof value !== "string") {
893
- return value;
894
- }
895
- let result = value;
896
- for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
897
- result = result.split(entity).join(char);
898
- }
899
- result = result.replace(/&#(\d+);/g, (match, code) => {
900
- const num = parseInt(code, 10);
901
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
902
- });
903
- result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
904
- const num = parseInt(hex, 16);
905
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
906
- });
907
- return result;
908
- }
909
- function removeSqlComments(value) {
910
- if (typeof value !== "string") {
911
- return value;
912
- }
913
- let result = value;
914
- result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
915
- result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
916
- return result;
917
- }
918
- function removeNullBytes(value) {
919
- if (typeof value !== "string") {
920
- return value;
921
- }
922
- let result = value.replace(/\x00/g, "");
923
- result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
924
- return result;
925
- }
926
- function normalizeWhitespace(value) {
927
- if (typeof value !== "string") {
928
- return value;
929
- }
930
- let result = value.replace(/[\t\r\n\f\v]+/g, " ");
931
- result = result.replace(/ {2,}/g, " ");
932
- return result;
933
- }
934
- var MAX_RAW_CHARS = 1024 * 1024;
935
- var MAX_RAW_NODES = 25e3;
936
- var MAX_RAW_DEPTH = 200;
937
- var MAX_RAW_ENTRIES = 2e3;
938
- function serializeForRawDetection(body) {
939
- if (body === null || body === void 0) return "";
940
- if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
941
- const visited = /* @__PURE__ */ new Set();
942
- const output = [];
943
- const stack = [{ kind: "value", value: body, depth: 0, root: true }];
944
- let chars = 0;
945
- let nodes = 0;
946
- const append = (text) => {
947
- if (chars >= MAX_RAW_CHARS) return;
948
- const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
949
- output.push(piece);
950
- chars += piece.length;
951
- };
952
- while (stack.length > 0 && chars < MAX_RAW_CHARS) {
953
- const task = stack.pop();
954
- if (task.kind === "text") {
955
- append(task.text);
956
- continue;
957
- }
958
- if (task.kind === "key") {
959
- append(boundedJsonString(task.value));
960
- append(":");
961
- continue;
962
- }
963
- nodes++;
964
- if (nodes > MAX_RAW_NODES) {
965
- append('"[NodeLimit]"');
966
- continue;
1075
+ return [];
967
1076
  }
968
- const value = task.value;
969
- if (value === null || value === void 0) {
970
- append(task.root ? "" : "null");
971
- continue;
1077
+ if (typeof body === "string") {
1078
+ return [body];
972
1079
  }
973
- if (typeof value === "string") {
974
- append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
975
- continue;
1080
+ try {
1081
+ return [JSON.stringify(body)];
1082
+ } catch {
1083
+ return [String(body)];
976
1084
  }
977
- if (typeof value !== "object") {
978
- append(String(value).slice(0, MAX_RAW_CHARS));
979
- continue;
1085
+ }
1086
+ #resolveAll() {
1087
+ const parts = [];
1088
+ const uri = this.#req.originalUrl ?? this.#req.url ?? "/";
1089
+ parts.push(uri);
1090
+ const queryString = uri.includes("?") ? uri.split("?")[1] : "";
1091
+ if (queryString) {
1092
+ parts.push(queryString);
980
1093
  }
981
- if (visited.has(value)) {
982
- append("[Circular]");
983
- continue;
1094
+ const body = this.#req.body;
1095
+ if (body) {
1096
+ parts.push(typeof body === "string" ? body : JSON.stringify(body));
984
1097
  }
985
- if (task.depth >= MAX_RAW_DEPTH) {
986
- append('"[DepthLimit]"');
987
- continue;
1098
+ if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
1099
+ parts.push(this.#req._rawBody);
988
1100
  }
989
- visited.add(value);
990
- if (Array.isArray(value)) {
991
- const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
992
- const items = [];
993
- for (let i = 0; i < length2; i++) {
994
- if (i > 0) items.push({ kind: "text", text: "," });
995
- items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
1101
+ const headers = this.#req.headers ?? {};
1102
+ const excludedHeaders = /* @__PURE__ */ new Set([
1103
+ "host",
1104
+ "connection",
1105
+ "cache-control",
1106
+ "accept",
1107
+ "accept-encoding",
1108
+ "accept-language",
1109
+ "priority",
1110
+ "sec-ch-ua",
1111
+ "sec-ch-ua-mobile",
1112
+ "sec-ch-ua-platform",
1113
+ "sec-fetch-dest",
1114
+ "sec-fetch-mode",
1115
+ "sec-fetch-site",
1116
+ "sec-fetch-user",
1117
+ "upgrade-insecure-requests"
1118
+ ]);
1119
+ for (const [name, value] of Object.entries(headers)) {
1120
+ if (!excludedHeaders.has(name)) {
1121
+ parts.push(`${name}: ${value}`);
996
1122
  }
997
- if (value.length > length2) {
998
- if (length2 > 0) items.push({ kind: "text", text: "," });
999
- items.push({ kind: "text", text: '"[EntryLimit]"' });
1123
+ }
1124
+ const cookies = this.#parseCookies();
1125
+ const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; ");
1126
+ if (cookieStr) {
1127
+ parts.push(cookieStr);
1128
+ }
1129
+ return [parts.join(" ")];
1130
+ }
1131
+ #resolveWildcard(obj, pattern) {
1132
+ if (typeof obj !== "object" || obj === null) {
1133
+ return [];
1134
+ }
1135
+ const prefix = pattern.slice(0, -1);
1136
+ const values = [];
1137
+ for (const [key, value] of Object.entries(obj)) {
1138
+ if (key.startsWith(prefix)) {
1139
+ values.push(value);
1000
1140
  }
1001
- stack.push({ kind: "text", text: "]" });
1002
- for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
1003
- stack.push({ kind: "text", text: "[" });
1004
- continue;
1005
1141
  }
1006
- let keys;
1007
- try {
1008
- keys = Object.getOwnPropertyNames(value);
1009
- } catch {
1010
- append('"[Unreadable]"');
1011
- continue;
1142
+ return values;
1143
+ }
1144
+ #getNestedValue(obj, key) {
1145
+ if (typeof obj !== "object" || obj === null) {
1146
+ return void 0;
1012
1147
  }
1013
- const length = Math.min(keys.length, MAX_RAW_ENTRIES);
1014
- const entries = [];
1015
- for (let i = 0; i < length; i++) {
1016
- const key = keys[i];
1017
- if (i > 0) entries.push({ kind: "text", text: "," });
1018
- entries.push({ kind: "key", value: key });
1019
- entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
1148
+ const own = (o, k) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
1149
+ if (own(obj, key)) {
1150
+ return obj[key];
1020
1151
  }
1021
- if (keys.length > length) {
1022
- if (length > 0) entries.push({ kind: "text", text: "," });
1023
- entries.push({ kind: "text", text: '"[EntryLimit]":null' });
1152
+ const parts = key.split(".");
1153
+ let current = obj;
1154
+ for (const part of parts) {
1155
+ if (!own(current, part)) {
1156
+ return void 0;
1157
+ }
1158
+ current = current[part];
1024
1159
  }
1025
- stack.push({ kind: "text", text: "}" });
1026
- for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
1027
- stack.push({ kind: "text", text: "{" });
1160
+ return current;
1028
1161
  }
1029
- return output.join("");
1030
- }
1031
- function boundedJsonString(value) {
1032
- return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
1033
- }
1034
- function readOwnValue(value, key) {
1162
+ #parseCookies() {
1163
+ if (this.#cookies !== null) {
1164
+ return this.#cookies;
1165
+ }
1166
+ if (this.#req.cookies) {
1167
+ this.#cookies = this.#req.cookies;
1168
+ return this.#cookies;
1169
+ }
1170
+ this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
1171
+ return this.#cookies;
1172
+ }
1173
+ };
1174
+
1175
+ // src/protect/notify.js
1176
+ var reported = /* @__PURE__ */ new WeakMap();
1177
+ var warnings = 0;
1178
+ var MAX_WARNINGS = 20;
1179
+ function notify(fn, arg, label) {
1180
+ if (typeof fn !== "function") return false;
1035
1181
  try {
1036
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
1037
- return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
1038
- } catch {
1039
- return "[Unreadable]";
1182
+ const result = fn(arg);
1183
+ if (result !== null && typeof result === "object" && typeof result.then === "function") {
1184
+ try {
1185
+ result.then(void 0, (err) => warnOnce(fn, label, err));
1186
+ } catch {
1187
+ }
1188
+ }
1189
+ return true;
1190
+ } catch (err) {
1191
+ warnOnce(fn, label, err);
1192
+ return false;
1040
1193
  }
1041
1194
  }
1042
- var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
1043
- function requestField(req, key) {
1044
- if (req === null || typeof req !== "object") return void 0;
1045
- if (Object.hasOwn(req, key)) return req[key];
1046
- if (!INHERITED_ACCESSORS.has(key)) return void 0;
1047
- let holder = Object.getPrototypeOf(req);
1048
- while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
1049
- if (holder === null || holder === Object.prototype) return void 0;
1050
- const descriptor = Object.getOwnPropertyDescriptor(holder, key);
1051
- if (typeof descriptor?.get !== "function") return void 0;
1052
- return req[key];
1053
- }
1054
- function normalizeRequest(req, options = {}) {
1055
- const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
1056
- const body = requestField(req, "body");
1057
- const url = requestField(req, "url");
1058
- const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
1059
- return {
1060
- query: normalizeObject(requestField(req, "query") || {}, options),
1061
- body: normalizeObject(body || {}, options),
1062
- headers: normalizeObject(requestField(req, "headers") || {}, options),
1063
- url: normalize(url || "", options),
1064
- originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
1065
- _rawBody: rawBody
1066
- };
1067
- }
1068
- var MAX_NORMALIZE_DEPTH = 200;
1069
- function normalizeObject(value, options = {}, depth = 0) {
1070
- if (typeof value === "string") {
1071
- return normalize(value, options);
1072
- }
1073
- if (depth >= MAX_NORMALIZE_DEPTH) {
1074
- return value;
1075
- }
1076
- if (Array.isArray(value)) {
1077
- return value.map((item) => normalizeObject(item, options, depth + 1));
1195
+ function warnOnce(fn, label, err) {
1196
+ let labels = reported.get(fn);
1197
+ if (labels?.has(label)) return;
1198
+ if (!labels) {
1199
+ labels = /* @__PURE__ */ new Set();
1200
+ reported.set(fn, labels);
1078
1201
  }
1079
- if (typeof value === "object" && value !== null) {
1080
- const result = {};
1081
- for (const [key, val] of Object.entries(value)) {
1082
- setOwn(result, key, normalizeObject(val, options, depth + 1));
1202
+ labels.add(label);
1203
+ if (warnings > MAX_WARNINGS) return;
1204
+ warnings++;
1205
+ try {
1206
+ if (warnings > MAX_WARNINGS) {
1207
+ console.warn("Patchstack: further failing callbacks passed to createProtection are not reported in this process.");
1208
+ return;
1083
1209
  }
1084
- return result;
1210
+ console.warn(
1211
+ `Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this callback's failures are reported once. Cause: ${err && err.message ? err.message : String(err)}`
1212
+ );
1213
+ } catch {
1085
1214
  }
1086
- return value;
1087
1215
  }
1088
1216
 
1089
1217
  // src/protect/engine/engine.js
@@ -1185,6 +1313,184 @@ function hasAdjacentUnbounded(body, flags) {
1185
1313
  }
1186
1314
  return false;
1187
1315
  }
1316
+ function parseRepetition(body) {
1317
+ let i = 0;
1318
+ const parseAlternation = () => {
1319
+ const branches = [parseSequence()];
1320
+ while (body[i] === "|") {
1321
+ i++;
1322
+ branches.push(parseSequence());
1323
+ }
1324
+ return branches.length === 1 ? branches[0] : { type: "alt", branches };
1325
+ };
1326
+ const parseSequence = () => {
1327
+ const items = [];
1328
+ while (i < body.length && body[i] !== "|" && body[i] !== ")") {
1329
+ const atom = parseAtom();
1330
+ if (atom === null) return null;
1331
+ const quantified = parseQuantifier(atom);
1332
+ if (quantified === null) return null;
1333
+ items.push(quantified);
1334
+ }
1335
+ return { type: "seq", items };
1336
+ };
1337
+ const parseAtom = () => {
1338
+ const ch = body[i];
1339
+ if (ch === "(") {
1340
+ const start2 = i;
1341
+ i++;
1342
+ let kind = "capture";
1343
+ if (body.startsWith("?:", i)) {
1344
+ kind = "group";
1345
+ i += 2;
1346
+ } else if (body.startsWith("?=", i) || body.startsWith("?!", i)) {
1347
+ kind = "look";
1348
+ i += 2;
1349
+ } else if (body.startsWith("?<=", i) || body.startsWith("?<!", i)) {
1350
+ kind = "look";
1351
+ i += 3;
1352
+ } else if (body.startsWith("?<", i)) {
1353
+ const close = body.indexOf(">", i + 2);
1354
+ if (close === -1) return null;
1355
+ i = close + 1;
1356
+ } else if (body[i] === "?") {
1357
+ return null;
1358
+ }
1359
+ const inner = parseAlternation();
1360
+ if (inner === null || body[i] !== ")") return null;
1361
+ i++;
1362
+ return { type: "group", kind, body: inner, source: body.slice(start2, i) };
1363
+ }
1364
+ const start = i;
1365
+ if (ch === "[") {
1366
+ i++;
1367
+ if (body[i] === "^") i++;
1368
+ if (body[i] === "]") i++;
1369
+ while (i < body.length && body[i] !== "]") i += body[i] === "\\" ? 2 : 1;
1370
+ if (body[i] !== "]") return null;
1371
+ i++;
1372
+ return { type: "atom", source: body.slice(start, i) };
1373
+ }
1374
+ if (ch === "\\") {
1375
+ const next = body[i + 1];
1376
+ if (next === void 0) return null;
1377
+ if ((next === "p" || next === "P" || next === "u") && body[i + 2] === "{") {
1378
+ const close = body.indexOf("}", i + 3);
1379
+ if (close === -1) return null;
1380
+ i = close + 1;
1381
+ } else if (next === "k" && body[i + 2] === "<") {
1382
+ const close = body.indexOf(">", i + 3);
1383
+ if (close === -1) return null;
1384
+ i = close + 1;
1385
+ } else {
1386
+ i += 2;
1387
+ }
1388
+ return { type: /[bB]/.test(next) ? "assert" : "atom", source: body.slice(start, i) };
1389
+ }
1390
+ if (ch === "^" || ch === "$") {
1391
+ i++;
1392
+ return { type: "assert" };
1393
+ }
1394
+ if (ch === "*" || ch === "+" || ch === "?") return null;
1395
+ i++;
1396
+ return { type: "atom", source: body.slice(start, i) };
1397
+ };
1398
+ const parseQuantifier = (atom) => {
1399
+ let min;
1400
+ let max;
1401
+ const ch = body[i];
1402
+ if (ch === "*") [min, max, i] = [0, Infinity, i + 1];
1403
+ else if (ch === "+") [min, max, i] = [1, Infinity, i + 1];
1404
+ else if (ch === "?") [min, max, i] = [0, 1, i + 1];
1405
+ else if (ch === "{") {
1406
+ const bounds = /^\{(\d+)(,(\d*))?\}/.exec(body.slice(i));
1407
+ if (!bounds) return atom;
1408
+ min = Number(bounds[1]);
1409
+ max = bounds[2] === void 0 ? min : bounds[3] === "" ? Infinity : Number(bounds[3]);
1410
+ i += bounds[0].length;
1411
+ } else {
1412
+ return atom;
1413
+ }
1414
+ if (body[i] === "?") i++;
1415
+ if (body[i] === "*" || body[i] === "+" || body[i] === "?" || body[i] === "{") return null;
1416
+ return { type: "quant", min, max, body: atom };
1417
+ };
1418
+ const root = parseAlternation();
1419
+ return root !== null && i === body.length ? root : null;
1420
+ }
1421
+ function* nodesOf(node) {
1422
+ if (!node) return;
1423
+ yield node;
1424
+ if (node.type === "alt") for (const branch of node.branches) yield* nodesOf(branch);
1425
+ else if (node.type === "seq") for (const item of node.items) yield* nodesOf(item);
1426
+ else if (node.type === "group" || node.type === "quant") yield* nodesOf(node.body);
1427
+ }
1428
+ var BOUNDED_NESTING_LIMIT = 100;
1429
+ var BOUNDED_ALTERNATION_LIMIT = 16;
1430
+ var OPTIONAL_REPEAT_LIMIT = 8;
1431
+ function hasFencedRepetitions(quant, flags) {
1432
+ let body = quant.body;
1433
+ if (body.type === "group" && body.kind !== "look") body = body.body;
1434
+ if (body.type !== "seq" || body.items.length < 2) return false;
1435
+ const variable = [...nodesOf(body)].filter((n) => n.type === "quant" && (n.max > 1 || n.min < n.max));
1436
+ if (variable.some((n) => n.body.type !== "atom" || n.body.source === void 0)) return false;
1437
+ const atomFlags = flags.replace(/[^i]/g, "");
1438
+ const unicode = /[uv]/.test(flags);
1439
+ let insides;
1440
+ try {
1441
+ insides = variable.map((n) => new RegExp(`^(?:${n.body.source})$`, `${atomFlags}${unicode ? "u" : ""}`));
1442
+ } catch {
1443
+ return false;
1444
+ }
1445
+ const literalOf = (source) => {
1446
+ if (source.length === 1 && !".[]()|^$\\".includes(source)) return source;
1447
+ if (source.length === 2 && source[0] === "\\" && /[^\w\s]/.test(source[1])) return source[1];
1448
+ return null;
1449
+ };
1450
+ const disjoint = (edge) => {
1451
+ if (edge?.type !== "atom" || edge.source === void 0) return false;
1452
+ const literal = literalOf(edge.source);
1453
+ if (literal !== null) return insides.every((re) => !re.test(literal));
1454
+ if (unicode) return false;
1455
+ let fence;
1456
+ try {
1457
+ fence = new RegExp(`^(?:${edge.source})$`, atomFlags);
1458
+ } catch {
1459
+ return false;
1460
+ }
1461
+ for (let unit = 0; unit <= 65535; unit++) {
1462
+ const ch = String.fromCharCode(unit);
1463
+ if (fence.test(ch) && insides.some((re) => re.test(ch))) return false;
1464
+ }
1465
+ return true;
1466
+ };
1467
+ return disjoint(body.items[0]) || disjoint(body.items[body.items.length - 1]);
1468
+ }
1469
+ function hasCatastrophicShape(body, flags) {
1470
+ const root = parseRepetition(body);
1471
+ if (root === null) return null;
1472
+ for (const node of nodesOf(root)) {
1473
+ if (node.type === "group" && node.kind === "look" && hasAdjacentUnbounded(lookaroundBody(node.source), flags)) return true;
1474
+ if (node.type !== "quant" || node.max <= 1) continue;
1475
+ const fenced = hasFencedRepetitions(node, flags);
1476
+ for (const inner of nodesOf(node.body)) {
1477
+ if (inner === node.body && inner.type === "quant") continue;
1478
+ if (inner.type === "quant" && inner.max > 1 && !fenced) {
1479
+ if (node.max === Infinity || inner.max === Infinity || node.max * inner.max > BOUNDED_NESTING_LIMIT) return true;
1480
+ }
1481
+ if (inner.type === "quant" && inner.min === 0 && inner.max <= 1 && !fenced && (node.max === Infinity || node.max > OPTIONAL_REPEAT_LIMIT)) {
1482
+ return true;
1483
+ }
1484
+ if (inner.type === "alt" && inner.branches.length > 1 && (node.max === Infinity || node.max > BOUNDED_ALTERNATION_LIMIT)) {
1485
+ return true;
1486
+ }
1487
+ }
1488
+ }
1489
+ return false;
1490
+ }
1491
+ function lookaroundBody(source) {
1492
+ return source.slice(source.startsWith("(?<") ? 4 : 3, -1);
1493
+ }
1188
1494
  function groupEnd(body, start) {
1189
1495
  let depth = 0;
1190
1496
  let inClass = false;
@@ -1211,6 +1517,8 @@ function warnRejectedPatternOnce(pattern) {
1211
1517
  );
1212
1518
  }
1213
1519
  var MAX_PATTERN_LENGTH = 1e3;
1520
+ var regexVerdicts = /* @__PURE__ */ new Map();
1521
+ var REGEX_VERDICT_LIMIT = 2048;
1214
1522
  function safeRegExp(pattern) {
1215
1523
  if (!pattern) {
1216
1524
  return null;
@@ -1222,16 +1530,32 @@ function safeRegExp(pattern) {
1222
1530
  if (!match) {
1223
1531
  return null;
1224
1532
  }
1225
- for (const dangerous of REDOS_PATTERNS) {
1226
- if (dangerous.test(match[1])) return null;
1533
+ let safe = regexVerdicts.get(pattern);
1534
+ if (safe === void 0) {
1535
+ safe = isSafeBody(match[1], match[2]);
1536
+ if (regexVerdicts.size >= REGEX_VERDICT_LIMIT) regexVerdicts.clear();
1537
+ regexVerdicts.set(pattern, safe);
1227
1538
  }
1228
- if (hasAdjacentUnbounded(match[1], match[2])) return null;
1539
+ if (!safe) return null;
1229
1540
  try {
1230
1541
  return new RegExp(match[1], match[2]);
1231
1542
  } catch {
1232
1543
  return null;
1233
1544
  }
1234
1545
  }
1546
+ function isSafeBody(body, flags) {
1547
+ for (const dangerous of REDOS_PATTERNS) {
1548
+ if (dangerous.test(body)) return false;
1549
+ }
1550
+ if (hasAdjacentUnbounded(body, flags)) return false;
1551
+ if (hasCatastrophicShape(body, flags) === true) return false;
1552
+ try {
1553
+ new RegExp(body, flags);
1554
+ } catch {
1555
+ return false;
1556
+ }
1557
+ return true;
1558
+ }
1235
1559
  function ctypeResult(strValue, isClass, matchVal) {
1236
1560
  if (strValue === "") {
1237
1561
  return false;
@@ -1275,17 +1599,21 @@ function arrayKeyValue(value, matchObj) {
1275
1599
  }
1276
1600
  var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
1277
1601
  function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
1278
- const out = [];
1602
+ const leaves = [];
1279
1603
  const stack = [[root, 0]];
1280
1604
  let visited = 0;
1605
+ let truncated = false;
1281
1606
  while (stack.length) {
1282
1607
  const [node, depth] = stack.pop();
1283
1608
  if (node === null || node === void 0) continue;
1284
1609
  if (typeof node !== "object") {
1285
- out.push(node);
1610
+ leaves.push(node);
1611
+ continue;
1612
+ }
1613
+ if (depth >= maxDepth || visited >= nodeCap) {
1614
+ truncated = true;
1286
1615
  continue;
1287
1616
  }
1288
- if (depth >= maxDepth || visited >= nodeCap) continue;
1289
1617
  visited++;
1290
1618
  if (Array.isArray(node)) {
1291
1619
  for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
@@ -1293,7 +1621,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
1293
1621
  for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
1294
1622
  }
1295
1623
  }
1296
- return out;
1624
+ return { leaves, truncated };
1625
+ }
1626
+ function skipsOf(resolver) {
1627
+ const skips = resolver?.skips;
1628
+ return skips && skips.length > 0 ? { skips } : {};
1629
+ }
1630
+ function serialisedValue(value) {
1631
+ try {
1632
+ return JSON.stringify(value);
1633
+ } catch {
1634
+ return void 0;
1635
+ }
1297
1636
  }
1298
1637
  var warnedKeys = /* @__PURE__ */ new Set();
1299
1638
  function warnOnce2(key, message) {
@@ -1311,12 +1650,22 @@ function warnUnsupportedMatchType(type) {
1311
1650
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
1312
1651
  );
1313
1652
  }
1653
+ var SPECIAL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ws", "wss", "ftp", "file"]);
1654
+ function trimControls(text) {
1655
+ let start = 0;
1656
+ let end = text.length;
1657
+ while (start < end && text.charCodeAt(start) <= 32) start++;
1658
+ while (end > start && text.charCodeAt(end - 1) <= 32) end--;
1659
+ return text.slice(start, end);
1660
+ }
1314
1661
  function hostFromValue(value) {
1315
- const raw = String(value ?? "").trim();
1662
+ const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
1316
1663
  if (raw === "") return "";
1317
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
1664
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(raw)?.[1]?.toLowerCase();
1665
+ const relative = /^[\\/]{2}/.test(raw);
1666
+ if (relative || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || scheme !== void 0 && SPECIAL_SCHEMES.has(scheme)) {
1318
1667
  try {
1319
- return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
1668
+ return new URL(relative ? `http:${raw}` : raw).hostname;
1320
1669
  } catch {
1321
1670
  return raw;
1322
1671
  }
@@ -1344,10 +1693,18 @@ function isInternalHost(hostname) {
1344
1693
  const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
1345
1694
  if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
1346
1695
  if ((g[0] & 65472) === 65152) return true;
1696
+ if ((g[0] & 65472) === 65216) return true;
1347
1697
  if ((g[0] & 65024) === 64512) return true;
1698
+ if ((g[0] & 65280) === 65280) return true;
1699
+ const low32 = (g[6] << 16 >>> 0 | g[7]) >>> 0;
1348
1700
  if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
1349
- return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
1701
+ return isPrivateV4Int(low32);
1350
1702
  }
1703
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
1704
+ return isPrivateV4Int(low32);
1705
+ }
1706
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 1) return true;
1707
+ if (g[0] === 8194) return isPrivateV4Int((g[1] << 16 >>> 0 | g[2]) >>> 0);
1351
1708
  return false;
1352
1709
  }
1353
1710
  const v4 = parseIPv4ToInt(host);
@@ -1357,6 +1714,10 @@ function isInternalHost(hostname) {
1357
1714
  function isPrivateV4Int(n) {
1358
1715
  const a = n >>> 24 & 255;
1359
1716
  const b = n >>> 16 & 255;
1717
+ const c = n >>> 8 & 255;
1718
+ if (a >= 224) return true;
1719
+ if (a === 192 && b === 0 && c === 0) return true;
1720
+ if (a === 198 && (b === 18 || b === 19)) return true;
1360
1721
  if (a === 127 || a === 10 || a === 0) return true;
1361
1722
  if (a === 169 && b === 254) return true;
1362
1723
  if (a === 172 && b >= 16 && b <= 31) return true;
@@ -1705,8 +2066,12 @@ var RuleEngine = class {
1705
2066
  let normalizedReq;
1706
2067
  let resolver;
1707
2068
  try {
1708
- normalizedReq = { ...req, ...normalizeRequest(req) };
2069
+ let limited = false;
2070
+ normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
2071
+ limited = true;
2072
+ } }) };
1709
2073
  resolver = new RequestResolver(normalizedReq);
2074
+ if (limited) resolver.noteSkip("container-cap");
1710
2075
  } catch (err) {
1711
2076
  this.#reportError(err);
1712
2077
  return { blocked: false, rule: null, message: null };
@@ -1732,14 +2097,15 @@ var RuleEngine = class {
1732
2097
  // that re-read the request instead would be reading it a second time: a getter, a stream or
1733
2098
  // anything else that answers once can give a different value, and evidence that disagrees
1734
2099
  // with the match it belongs to is worse than none.
1735
- resolver
2100
+ resolver,
2101
+ ...skipsOf(resolver)
1736
2102
  };
1737
2103
  }
1738
2104
  } catch (err) {
1739
2105
  this.#reportError(err);
1740
2106
  }
1741
2107
  }
1742
- return { blocked: false, rule: null, message: null };
2108
+ return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
1743
2109
  }
1744
2110
  #evaluateRule(conditions, resolver) {
1745
2111
  const inclusiveConditions = conditions.filter((c) => c.inclusive);
@@ -1795,9 +2161,15 @@ var RuleEngine = class {
1795
2161
  if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
1796
2162
  if (matchValue(match.type, value, match.value, match)) return true;
1797
2163
  } else {
1798
- for (const leaf of collectLeafValues(value)) {
2164
+ const { leaves, truncated } = collectLeafValues(value);
2165
+ for (const leaf of leaves) {
1799
2166
  if (matchValue(match.type, leaf, match.value, match)) return true;
1800
2167
  }
2168
+ if (truncated) {
2169
+ resolver.noteSkip("container-cap");
2170
+ const text = serialisedValue(value);
2171
+ if (text !== void 0 && matchValue(match.type, text, match.value, match)) return true;
2172
+ }
1801
2173
  }
1802
2174
  continue;
1803
2175
  }
@@ -2184,6 +2556,9 @@ function conditionShapeProblem(condition) {
2184
2556
  if (nested && !named) {
2185
2557
  return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2186
2558
  }
2559
+ if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
2560
+ return "inclusive must be true or false";
2561
+ }
2187
2562
  if (named && nested) {
2188
2563
  if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2189
2564
  if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
@@ -2890,7 +3265,7 @@ async function fromFetchRequest(request, options = {}) {
2890
3265
  // that guessed differently would attribute one request to two addresses.
2891
3266
  ip: client.ip ?? "",
2892
3267
  _clientIp: client,
2893
- cookies: parseCookies(headers.cookie),
3268
+ cookies: parseCookieHeader(headers.cookie),
2894
3269
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
2895
3270
  // drops, so prototype-pollution rules on `raw` are robust.
2896
3271
  _rawBody: rawBody,
@@ -2952,7 +3327,7 @@ function concatChunks(chunks, total) {
2952
3327
  }
2953
3328
  function parseBody(rawBody, contentType) {
2954
3329
  const ct = String(contentType || "").toLowerCase();
2955
- const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
3330
+ const isJson2 = ct.includes("application/json") || /\+json\b/.test(ct);
2956
3331
  const isForm = ct.includes("application/x-www-form-urlencoded");
2957
3332
  const isMultipart = ct.includes("multipart/form-data");
2958
3333
  const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
@@ -2966,7 +3341,7 @@ function parseBody(rawBody, contentType) {
2966
3341
  for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
2967
3342
  return { body, files: void 0 };
2968
3343
  }
2969
- if (isJson || isAmbiguous) {
3344
+ if (isJson2 || isAmbiguous) {
2970
3345
  try {
2971
3346
  const parsed = JSON.parse(rawBody);
2972
3347
  if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
@@ -2982,38 +3357,115 @@ function parseMultipart(rawBody, boundary) {
2982
3357
  const sep = /\r?\n\r?\n/.exec(part);
2983
3358
  if (!sep) continue;
2984
3359
  const rawHeaders = part.slice(0, sep.index);
2985
- const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
2986
- if (!disposition) continue;
2987
- const name = /name="([^"]*)"/i.exec(disposition)?.[1];
2988
- if (name == null) continue;
3360
+ const disposition = /content-disposition:([^\r\n]*)/i.exec(rawHeaders)?.[1];
3361
+ if (disposition === void 0) continue;
3362
+ const { names, filename } = dispositionFields(disposition);
3363
+ if (names.length === 0) continue;
2989
3364
  const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
2990
- const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
2991
- if (filename !== void 0) {
2992
- const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2993
- const file = { filename, type: partType, content };
2994
- appendOwn(files, name, file);
2995
- } else {
2996
- appendOwn(body, name, content);
3365
+ const file = filename === void 0 ? void 0 : { filename, type: /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "", content };
3366
+ for (const name of names) {
3367
+ if (file) appendOwn(files, name, file);
3368
+ else appendOwn(body, name, content);
2997
3369
  }
2998
3370
  }
2999
3371
  return { body, files };
3000
3372
  }
3001
- function parseCookies(header) {
3002
- const cookies = {};
3003
- if (!header) {
3004
- return cookies;
3005
- }
3006
- for (const pair of header.split(";")) {
3007
- const idx = pair.indexOf("=");
3008
- if (idx === -1) {
3009
- continue;
3373
+ function dispositionFields(value) {
3374
+ const text = String(value);
3375
+ const names = /* @__PURE__ */ new Set();
3376
+ let filename;
3377
+ let extendedFilename;
3378
+ let i = text.indexOf(";");
3379
+ while (i !== -1 && i < text.length) {
3380
+ i++;
3381
+ while (text[i] === " " || text[i] === " ") i++;
3382
+ let key = "";
3383
+ while (i < text.length && text[i] !== "=" && text[i] !== ";") key += text[i++];
3384
+ key = key.trim().toLowerCase();
3385
+ let val = null;
3386
+ if (text[i] === "=") {
3387
+ i++;
3388
+ while (text[i] === " " || text[i] === " ") i++;
3389
+ if (text[i] === '"') {
3390
+ i++;
3391
+ val = "";
3392
+ while (i < text.length && text[i] !== '"') {
3393
+ if (text[i] === "\\" && i + 1 < text.length) i++;
3394
+ val += text[i++];
3395
+ }
3396
+ i++;
3397
+ while (i < text.length && text[i] !== ";") i++;
3398
+ } else {
3399
+ let bare = "";
3400
+ while (i < text.length && text[i] !== ";") bare += text[i++];
3401
+ val = bare.trim();
3402
+ }
3403
+ }
3404
+ if (val !== null) {
3405
+ if (key === "name") names.add(val);
3406
+ else if (key === "filename") filename = val;
3407
+ else if (key === "filename*") extendedFilename = decodeExtendedValue(val);
3010
3408
  }
3011
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
3409
+ i = i < text.length && text[i] === ";" ? i : -1;
3410
+ }
3411
+ return { names: [...names], filename: extendedFilename ?? filename };
3412
+ }
3413
+ function decodeExtendedValue(value) {
3414
+ const match = /^([^']*)'[^']*'(.*)$/.exec(value);
3415
+ if (!match) return value;
3416
+ try {
3417
+ return decodeURIComponent(match[2]);
3418
+ } catch {
3419
+ return match[2];
3012
3420
  }
3013
- return cookies;
3014
3421
  }
3015
3422
 
3016
3423
  // src/protect/engine/node.js
3424
+ function readBodyPrefix(req, maxBytes, done) {
3425
+ const chunks = [];
3426
+ let retained = 0;
3427
+ let size = 0;
3428
+ let failed = false;
3429
+ let finished = false;
3430
+ const finish = (error, read) => {
3431
+ if (finished) return;
3432
+ finished = true;
3433
+ done(error, read);
3434
+ };
3435
+ req.on("data", (chunk) => {
3436
+ if (failed) return;
3437
+ try {
3438
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, req.readableEncoding || "utf8") : chunk;
3439
+ size += bytes.length;
3440
+ const take = Math.min(bytes.length, maxBytes - retained);
3441
+ if (take <= 0) return;
3442
+ chunks.push(take === bytes.length ? bytes : bytes.subarray(0, take));
3443
+ retained += take;
3444
+ } catch {
3445
+ failed = true;
3446
+ chunks.length = 0;
3447
+ }
3448
+ });
3449
+ req.on("error", (error) => finish(error));
3450
+ req.on("end", () => {
3451
+ if (failed) {
3452
+ finish(null, { text: "", overflow: false, size, failed: true });
3453
+ return;
3454
+ }
3455
+ const prefix = Buffer.concat(chunks);
3456
+ const overflow = size > maxBytes;
3457
+ const text = (overflow ? prefix.subarray(0, completeUtf8Length(prefix)) : prefix).toString("utf8");
3458
+ finish(null, { text, overflow, size, failed: false });
3459
+ });
3460
+ }
3461
+ function completeUtf8Length(bytes) {
3462
+ let start = bytes.length - 1;
3463
+ while (start >= 0 && bytes.length - start <= 3 && (bytes[start] & 192) === 128) start--;
3464
+ if (start < 0) return bytes.length;
3465
+ const lead = bytes[start];
3466
+ const needed = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
3467
+ return bytes.length - start < needed ? start : bytes.length;
3468
+ }
3017
3469
  function fromNodeRequest(req, rawBody = "", options = {}) {
3018
3470
  const method = (req.method || "GET").toUpperCase();
3019
3471
  const headers = {};
@@ -3059,29 +3511,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
3059
3511
  headers,
3060
3512
  ip: client.ip ?? "",
3061
3513
  _clientIp: client,
3062
- cookies: parseCookies2(headers.cookie),
3514
+ cookies: parseCookieHeader(headers.cookie),
3063
3515
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
3064
3516
  _rawBody: rawBody
3065
3517
  };
3066
3518
  }
3067
- function parseCookies2(header) {
3068
- const cookies = {};
3069
- if (!header) {
3070
- return cookies;
3519
+
3520
+ // src/protect/egress.js
3521
+ var REGISTRY = /* @__PURE__ */ Symbol.for("patchstack.connect.egress-guard");
3522
+ function egressRegistry() {
3523
+ const existing = globalThis[REGISTRY];
3524
+ if (existing && existing.screens instanceof Set && existing.surfaces instanceof Map) return existing;
3525
+ const created = { screens: /* @__PURE__ */ new Set(), surfaces: /* @__PURE__ */ new Map() };
3526
+ Object.defineProperty(globalThis, REGISTRY, { value: created, configurable: true, writable: true });
3527
+ return created;
3528
+ }
3529
+ var refusal = (host) => new Error(`Patchstack blocked an outbound request to a disallowed address: ${host}`);
3530
+ function readableDestination(input, init) {
3531
+ try {
3532
+ const raw = typeof input === "string" || input instanceof URL ? String(input) : input?.url ?? input?.href;
3533
+ if (typeof raw !== "string" && !(raw instanceof URL)) return null;
3534
+ const url = new URL(String(raw)).href;
3535
+ const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
3536
+ return { url, method };
3537
+ } catch {
3538
+ return null;
3071
3539
  }
3072
- for (const pair of header.split(";")) {
3073
- const idx = pair.indexOf("=");
3074
- if (idx === -1) {
3075
- continue;
3076
- }
3077
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
3540
+ }
3541
+ function anyRefuses(screens, url, host, method) {
3542
+ let refused = false;
3543
+ for (const screen of screens) {
3544
+ if (screen.block(url, host, method)) refused = true;
3078
3545
  }
3079
- return cookies;
3546
+ return refused;
3080
3547
  }
3081
-
3082
- // src/protect/egress.js
3083
3548
  async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
3084
- const restores = [];
3085
3549
  if (typeof shouldBlock !== "function") return () => {
3086
3550
  };
3087
3551
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
@@ -3126,6 +3590,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3126
3590
  resolve(false);
3127
3591
  }
3128
3592
  });
3593
+ const registry = egressRegistry();
3594
+ const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
3595
+ registry.screens.add(own);
3129
3596
  const originalFetch = globalThis.fetch;
3130
3597
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
3131
3598
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
@@ -3198,15 +3665,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3198
3665
  } catch {
3199
3666
  host = null;
3200
3667
  }
3201
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
3202
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
3203
- }
3668
+ const screens = [...registry.screens];
3669
+ if (anyRefuses(screens, u, host, method)) throw refusal(host ?? u);
3670
+ const resolved = await Promise.all(screens.map((each) => each.prescreen(u, host, method)));
3671
+ if (resolved.includes(true)) throw refusal(host ?? u);
3204
3672
  };
3205
3673
  const guarded = async (input, init) => {
3206
3674
  let cur;
3207
3675
  try {
3208
3676
  cur = new Request(input, { ...init || {}, redirect: "manual" });
3209
3677
  } catch {
3678
+ const destination = readableDestination(input, init);
3679
+ if (destination) await screenUrl(destination.url, destination.method);
3680
+ else for (const each of registry.screens) each.skip("unrecognised-request", {});
3210
3681
  return originalFetch(input, init);
3211
3682
  }
3212
3683
  const callerRedirect = init && init.redirect || input && input.redirect || "follow";
@@ -3217,13 +3688,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3217
3688
  const headers = new Headers(cur.headers);
3218
3689
  const signal = cur.signal;
3219
3690
  const replay = captureReplayBody(cur);
3691
+ const { body: _callerBody, ...callerOptions } = init || {};
3220
3692
  let body;
3221
3693
  for (let hop = 0; ; hop++) {
3222
3694
  let resp;
3223
3695
  try {
3224
- resp = await originalFetch(
3225
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3226
- );
3696
+ resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
3227
3697
  } catch (error) {
3228
3698
  replay.cancel();
3229
3699
  throw error;
@@ -3231,7 +3701,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3231
3701
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
3232
3702
  if (!location) {
3233
3703
  replay.cancel();
3234
- return resp;
3704
+ return hop === 0 ? resp : markRedirected(resp);
3235
3705
  }
3236
3706
  await discardResponseBody(resp);
3237
3707
  if (hop >= MAX_REDIRECTS) {
@@ -3264,43 +3734,84 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3264
3734
  };
3265
3735
  guarded.__patchstackGuarded = true;
3266
3736
  globalThis.fetch = guarded;
3267
- restores.push(() => {
3268
- if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
3737
+ registry.surfaces.set(guarded, {
3738
+ release() {
3739
+ if (globalThis.fetch !== guarded) return false;
3740
+ globalThis.fetch = originalFetch;
3741
+ return true;
3742
+ }
3269
3743
  });
3270
3744
  }
3745
+ let syncBuiltins = () => {
3746
+ };
3747
+ try {
3748
+ const { syncBuiltinESMExports } = await import("module");
3749
+ if (typeof syncBuiltinESMExports === "function") syncBuiltins = syncBuiltinESMExports;
3750
+ } catch {
3751
+ }
3752
+ let patchedAny = false;
3271
3753
  for (const moduleName of ["node:http", "node:https"]) {
3272
3754
  try {
3273
3755
  const mod = await import(moduleName);
3274
- const restore = patchHttpModule(mod.default ?? mod, block, screen, skip);
3275
- if (restore) restores.push(restore);
3756
+ if (registry.surfaces.has(moduleName)) continue;
3757
+ const release = patchHttpModule(mod.default ?? mod, registry);
3758
+ if (release) {
3759
+ patchedAny = true;
3760
+ registry.surfaces.set(moduleName, {
3761
+ release() {
3762
+ if (!release()) return false;
3763
+ syncBuiltins();
3764
+ return true;
3765
+ }
3766
+ });
3767
+ }
3276
3768
  } catch {
3277
3769
  }
3278
3770
  }
3771
+ if (patchedAny) syncBuiltins();
3279
3772
  return () => {
3280
- for (const restore of restores) {
3773
+ registry.screens.delete(own);
3774
+ if (registry.screens.size > 0) return;
3775
+ for (const [name, surface] of registry.surfaces) {
3281
3776
  try {
3282
- restore();
3777
+ if (surface.release()) registry.surfaces.delete(name);
3283
3778
  } catch {
3284
3779
  }
3285
3780
  }
3286
3781
  };
3287
3782
  }
3288
- function patchHttpModule(http, block, screen, skip) {
3783
+ function markRedirected(response) {
3784
+ try {
3785
+ Object.defineProperty(response, "redirected", { value: true, configurable: true });
3786
+ } catch {
3787
+ }
3788
+ return response;
3789
+ }
3790
+ function patchHttpModule(http, registry) {
3289
3791
  if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
3290
3792
  const originalRequest = http.request;
3291
3793
  const originalGet = http.get;
3292
- const wrap = (original) => function(...args) {
3794
+ const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
3795
+ const guardArgs = (args) => {
3293
3796
  const target = extractHttpTarget(args);
3294
- if (target && block(target.url, target.host, target.method)) {
3295
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
3296
- }
3297
- if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
3797
+ if (!target) return args;
3798
+ const screens = [...registry.screens];
3799
+ if (anyRefuses(screens, target.url, target.host, target.method)) throw refusal(target.host ?? target.url);
3800
+ const resolving = target.host ? screens.filter((each) => each.dns && each.dns.isIP(target.host) === 0 && !each.dns.isExempt(target.host)) : [];
3801
+ if (resolving.length > 0) {
3802
+ const block = (url, host, method) => anyRefuses(resolving, url, host, method);
3803
+ const skip = (reason, detail) => {
3804
+ for (const each of resolving) each.skip(reason, detail);
3805
+ };
3298
3806
  try {
3299
- args = withScreeningLookup(args, target, block, screen.lookup, skip);
3807
+ return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
3300
3808
  } catch {
3301
3809
  }
3302
3810
  }
3303
- return original.apply(this, args);
3811
+ return args;
3812
+ };
3813
+ const wrap = (original) => function(...args) {
3814
+ return original.apply(this, guardArgs(args));
3304
3815
  };
3305
3816
  const guardedRequest = wrap(originalRequest);
3306
3817
  http.request = guardedRequest;
@@ -3309,11 +3820,26 @@ function patchHttpModule(http, block, screen, skip) {
3309
3820
  guardedGet = wrap(originalGet);
3310
3821
  http.get = guardedGet;
3311
3822
  }
3823
+ let GuardedClientRequest;
3824
+ if (OriginalClientRequest) {
3825
+ GuardedClientRequest = function ClientRequest(...args) {
3826
+ const screened = guardArgs(args);
3827
+ return new.target ? Reflect.construct(OriginalClientRequest, screened, new.target) : Reflect.construct(OriginalClientRequest, screened);
3828
+ };
3829
+ GuardedClientRequest.prototype = OriginalClientRequest.prototype;
3830
+ Object.setPrototypeOf(GuardedClientRequest, OriginalClientRequest);
3831
+ http.ClientRequest = GuardedClientRequest;
3832
+ }
3312
3833
  http.__patchstackGuarded = true;
3313
3834
  return () => {
3314
- if (http.request === guardedRequest) http.request = originalRequest;
3315
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
3835
+ if (http.request !== guardedRequest) return false;
3836
+ if (guardedGet && http.get !== guardedGet) return false;
3837
+ if (GuardedClientRequest && http.ClientRequest !== GuardedClientRequest) return false;
3838
+ http.request = originalRequest;
3839
+ if (guardedGet) http.get = originalGet;
3840
+ if (GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
3316
3841
  delete http.__patchstackGuarded;
3842
+ return true;
3317
3843
  };
3318
3844
  }
3319
3845
  function extractHttpTarget(args) {
@@ -3322,19 +3848,27 @@ function extractHttpTarget(args) {
3322
3848
  if (typeof first === "string" || first instanceof URL) {
3323
3849
  const url = new URL(String(first));
3324
3850
  const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
3325
- return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
3326
- }
3327
- if (first && typeof first === "object") {
3328
- const host = normalizeHost(first.hostname || first.host) || "localhost";
3329
- const protocol = first.protocol || "http:";
3330
- const port = first.port ? `:${first.port}` : "";
3331
- const path = first.path || "/";
3332
- return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
3851
+ return targetOf({ ...optionsFromUrl(url), ...opts ?? {} });
3333
3852
  }
3853
+ if (first && typeof first === "object") return targetOf(first);
3334
3854
  } catch {
3335
3855
  }
3336
3856
  return null;
3337
3857
  }
3858
+ function optionsFromUrl(url) {
3859
+ const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
3860
+ const options = { protocol: url.protocol, hostname, path: `${url.pathname || "/"}${url.search}` };
3861
+ if (url.port !== "") options.port = Number(url.port);
3862
+ return options;
3863
+ }
3864
+ function targetOf(options) {
3865
+ const host = normalizeHost(options.hostname || options.host) || "localhost";
3866
+ const protocol = options.protocol || "http:";
3867
+ const port = options.port ? `:${options.port}` : "";
3868
+ const path = options.path || "/";
3869
+ const authority = host.includes(":") ? `[${host}]` : host;
3870
+ return { url: `${protocol}//${authority}${port}${path}`, host, method: options.method || "GET" };
3871
+ }
3338
3872
  function normalizeHost(raw) {
3339
3873
  const host = String(raw || "").trim();
3340
3874
  const bracketed = /^\[([^\]]+)\]/.exec(host);
@@ -3715,19 +4249,49 @@ async function loadFs() {
3715
4249
  }
3716
4250
  return fsMod;
3717
4251
  }
4252
+ async function sourceIdentity(options = {}) {
4253
+ if (typeof options.siteUuid === "string" && options.siteUuid.trim() !== "") {
4254
+ const endpoint = typeof options.pulseRulesUrl === "string" ? options.pulseRulesUrl : "";
4255
+ return `site:${options.siteUuid.trim().toLowerCase()}@${endpoint}`;
4256
+ }
4257
+ if (typeof options.token === "string" && options.token !== "") {
4258
+ const digest = await sha256Hex(options.token);
4259
+ if (digest === null) return null;
4260
+ const endpoint = typeof options.baseUrl === "string" ? options.baseUrl : "";
4261
+ return `token:${digest}@${endpoint}`;
4262
+ }
4263
+ return null;
4264
+ }
4265
+ async function sha256Hex(text) {
4266
+ try {
4267
+ const subtle = globalThis.crypto?.subtle;
4268
+ if (!subtle || typeof TextEncoder === "undefined") return null;
4269
+ const digest = await subtle.digest("SHA-256", new TextEncoder().encode(text));
4270
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
4271
+ } catch {
4272
+ return null;
4273
+ }
4274
+ }
3718
4275
  function makeStore(options = {}) {
3719
4276
  let mem = null;
3720
4277
  const durable = durableTier(options);
4278
+ let identity;
4279
+ const identityOf = () => identity ??= sourceIdentity(options);
3721
4280
  return {
3722
4281
  async read() {
3723
4282
  if (mem) return mem;
4283
+ const source = await identityOf();
4284
+ if (source === null) return null;
3724
4285
  const env = await durable.read();
3725
- if (env) mem = env;
4286
+ if (!env || env.source !== source) return null;
4287
+ mem = env;
3726
4288
  return env;
3727
4289
  },
3728
4290
  async write(env) {
3729
- if (env) mem = env;
3730
- await durable.write(env);
4291
+ if (!env) return;
4292
+ const stamped = { ...env, source: await identityOf() };
4293
+ mem = stamped;
4294
+ await durable.write(stamped);
3731
4295
  }
3732
4296
  };
3733
4297
  }
@@ -3849,11 +4413,12 @@ function toEnvelope(value) {
3849
4413
  buildId,
3850
4414
  // A confirmation belongs to the presentation stored beside it. A crossed or partially written
3851
4415
  // envelope confirms nothing, even when one of its fields happens to name the current map.
3852
- matchedBuildId: buildId !== null && matched === buildId ? matched : null
4416
+ matchedBuildId: buildId !== null && matched === buildId ? matched : null,
4417
+ source: typeof value.source === "string" ? value.source : null
3853
4418
  };
3854
4419
  }
3855
4420
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
3856
- return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
4421
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null, source: null };
3857
4422
  }
3858
4423
  return null;
3859
4424
  }
@@ -3920,13 +4485,17 @@ function withScopedDryRun(bundle, confirmed, reason, options = {}) {
3920
4485
  ...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
3921
4486
  ...held > unusable ? [reason] : []
3922
4487
  ];
3923
- notify(
3924
- options.onError,
3925
- new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
3926
- "onError"
3927
- );
4488
+ const message = `${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`;
4489
+ if (typeof options.onError === "function") notify(options.onError, new Error(message), "onError");
4490
+ else warnHeldOnce(message);
3928
4491
  return { ...bundle, firewall };
3929
4492
  }
4493
+ var warnedHeld = /* @__PURE__ */ new Set();
4494
+ function warnHeldOnce(message) {
4495
+ if (warnedHeld.has(message)) return;
4496
+ warnedHeld.add(message);
4497
+ console.warn(`[patchstack] ${message}. Pass { onError } to handle this yourself.`);
4498
+ }
3930
4499
  function confirmedBy(res, presented) {
3931
4500
  if (presented === null) return null;
3932
4501
  const verdict = res?.build;
@@ -4101,6 +4670,60 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
4101
4670
  }
4102
4671
  };
4103
4672
  }
4673
+ var RECOVERY_DELAYS_MS = [5e3, 15e3, 45e3, 12e4, 3e5, 6e5];
4674
+ function startRecovery(tick, { onError } = {}) {
4675
+ let stopped = false;
4676
+ let attempt = 0;
4677
+ let timer = null;
4678
+ const schedule = () => {
4679
+ if (stopped) return;
4680
+ const delay = RECOVERY_DELAYS_MS[Math.min(attempt, RECOVERY_DELAYS_MS.length - 1)];
4681
+ timer = setTimeout(run, delay * (1 - Math.random() * JITTER_FRACTION3));
4682
+ timer?.unref?.();
4683
+ };
4684
+ const run = async () => {
4685
+ if (stopped) return;
4686
+ attempt++;
4687
+ try {
4688
+ const status = await tick();
4689
+ if (!status || status.ok !== false) {
4690
+ stopped = true;
4691
+ return;
4692
+ }
4693
+ } catch (err) {
4694
+ notify(onError, err, "onError");
4695
+ }
4696
+ schedule();
4697
+ };
4698
+ schedule();
4699
+ return {
4700
+ stop() {
4701
+ stopped = true;
4702
+ if (timer) clearTimeout(timer);
4703
+ }
4704
+ };
4705
+ }
4706
+ function serialise(tick) {
4707
+ let running = null;
4708
+ let next = null;
4709
+ const run = () => {
4710
+ if (!running) {
4711
+ running = Promise.resolve().then(() => tick()).finally(() => {
4712
+ running = null;
4713
+ });
4714
+ return running;
4715
+ }
4716
+ if (!next) {
4717
+ next = running.catch(() => {
4718
+ }).then(() => {
4719
+ next = null;
4720
+ return run();
4721
+ });
4722
+ }
4723
+ return next;
4724
+ };
4725
+ return run;
4726
+ }
4104
4727
  function makeRefreshHandler(tick, secret) {
4105
4728
  let inflight = null;
4106
4729
  const runOnce = () => {
@@ -4115,7 +4738,7 @@ function makeRefreshHandler(tick, secret) {
4115
4738
  return async (request) => {
4116
4739
  if (!secret) return new Response("not found", { status: 404 });
4117
4740
  const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
4118
- if (provided !== secret) return new Response("forbidden", { status: 403 });
4741
+ if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
4119
4742
  let refreshed = true;
4120
4743
  try {
4121
4744
  const status = await runOnce();
@@ -4126,6 +4749,33 @@ function makeRefreshHandler(tick, secret) {
4126
4749
  return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
4127
4750
  };
4128
4751
  }
4752
+ async function sameSecret(provided, secret) {
4753
+ if (typeof provided !== "string" || typeof secret !== "string") return false;
4754
+ const subtle = globalThis.crypto?.subtle;
4755
+ if (subtle && typeof TextEncoder === "function") {
4756
+ try {
4757
+ const encoder2 = new TextEncoder();
4758
+ const [a, b] = await Promise.all([
4759
+ subtle.digest("SHA-256", encoder2.encode(provided)),
4760
+ subtle.digest("SHA-256", encoder2.encode(secret))
4761
+ ]);
4762
+ return equalBytes(new Uint8Array(a), new Uint8Array(b));
4763
+ } catch {
4764
+ }
4765
+ }
4766
+ let diff = provided.length ^ secret.length;
4767
+ const length = Math.max(provided.length, secret.length);
4768
+ for (let i = 0; i < length; i++) {
4769
+ diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
4770
+ }
4771
+ return diff === 0;
4772
+ }
4773
+ function equalBytes(a, b) {
4774
+ if (a.length !== b.length) return false;
4775
+ let diff = 0;
4776
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
4777
+ return diff === 0;
4778
+ }
4129
4779
 
4130
4780
  // src/protect/detections.js
4131
4781
  var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
@@ -4859,9 +5509,180 @@ function hardensWithoutBody(rule) {
4859
5509
  if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
4860
5510
  const { parameters, complete } = readRuleParameters(rule);
4861
5511
  if (!complete) return false;
4862
- return parameters.every(
4863
- (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
5512
+ return parameters.every(bodyIndependent);
5513
+ }
5514
+ function readsOnlyResponseHeaders(rule) {
5515
+ if (!rule) return false;
5516
+ const { parameters, complete } = readRuleParameters(rule);
5517
+ if (!complete || !parameters.some(isHeaderParameter)) return false;
5518
+ return parameters.every(bodyIndependent);
5519
+ }
5520
+ function isHeaderParameter(parameter) {
5521
+ return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
5522
+ }
5523
+ function bodyIndependent(parameter) {
5524
+ return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
5525
+ }
5526
+
5527
+ // src/protect/supabase-guard.js
5528
+ var GUARD_PATH = "/_patchstack/guard";
5529
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5530
+ var DEFAULT_TIMEOUT_MS = 3e4;
5531
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5532
+ "connection",
5533
+ "cookie",
5534
+ "cookie2",
5535
+ "forwarded",
5536
+ "host",
5537
+ "keep-alive",
5538
+ "proxy-authenticate",
5539
+ "proxy-authorization",
5540
+ "te",
5541
+ "trailer",
5542
+ "transfer-encoding",
5543
+ "upgrade",
5544
+ "x-forwarded-for",
5545
+ "x-forwarded-host",
5546
+ "x-forwarded-port",
5547
+ "x-forwarded-proto",
5548
+ "x-ps-target",
5549
+ "x-real-ip"
5550
+ ]);
5551
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5552
+ "connection",
5553
+ "content-encoding",
5554
+ "content-length",
5555
+ "keep-alive",
5556
+ "proxy-authenticate",
5557
+ "proxy-authorization",
5558
+ "set-cookie",
5559
+ "set-cookie2",
5560
+ "te",
5561
+ "trailer",
5562
+ "transfer-encoding",
5563
+ "upgrade"
5564
+ ]);
5565
+ var BodyTooLargeError = class extends Error {
5566
+ };
5567
+ async function boundedBody(request, maxBytes) {
5568
+ const stated = Number(request.headers.get("content-length"));
5569
+ if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5570
+ if (!request.body) return void 0;
5571
+ const reader = request.body.getReader();
5572
+ const chunks = [];
5573
+ let total = 0;
5574
+ try {
5575
+ for (; ; ) {
5576
+ const { done, value } = await reader.read();
5577
+ if (done) break;
5578
+ total += value.byteLength;
5579
+ if (total > maxBytes) {
5580
+ void reader.cancel().catch(() => {
5581
+ });
5582
+ throw new BodyTooLargeError();
5583
+ }
5584
+ chunks.push(value);
5585
+ }
5586
+ } finally {
5587
+ reader.releaseLock();
5588
+ }
5589
+ const body = new Uint8Array(total);
5590
+ let offset = 0;
5591
+ for (const chunk of chunks) {
5592
+ body.set(chunk, offset);
5593
+ offset += chunk.byteLength;
5594
+ }
5595
+ return body;
5596
+ }
5597
+ function requestHeaders(input) {
5598
+ const output = new Headers();
5599
+ const connectionTokens = new Set(
5600
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5601
+ );
5602
+ input.forEach((value, key) => {
5603
+ const lower = key.toLowerCase();
5604
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5605
+ });
5606
+ return output;
5607
+ }
5608
+ function responseHeaders(input) {
5609
+ const output = new Headers();
5610
+ const connectionTokens = new Set(
5611
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
4864
5612
  );
5613
+ input.forEach((value, key) => {
5614
+ const lower = key.toLowerCase();
5615
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5616
+ });
5617
+ return output;
5618
+ }
5619
+ var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
5620
+ function createSupabaseGuard({
5621
+ protection,
5622
+ supabaseUrl,
5623
+ fetchImpl = fetch,
5624
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5625
+ timeoutMs = DEFAULT_TIMEOUT_MS
5626
+ }) {
5627
+ const guard = protection.fetchGuard();
5628
+ const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5629
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5630
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
5631
+ return async function handleGuardRequest(request, ...hostArgs) {
5632
+ const target = request.headers.get("x-ps-target");
5633
+ if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
5634
+ let targetUrl;
5635
+ try {
5636
+ targetUrl = new URL(target);
5637
+ } catch {
5638
+ return new Response("patchstack: invalid target", { status: 400 });
5639
+ }
5640
+ if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
5641
+ return new Response("patchstack: target not allowed", { status: 403 });
5642
+ }
5643
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
5644
+ let body;
5645
+ try {
5646
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5647
+ } catch (error) {
5648
+ if (error instanceof BodyTooLargeError) {
5649
+ return new Response("patchstack: request body too large", { status: 413 });
5650
+ }
5651
+ throw error;
5652
+ }
5653
+ const forwardHeaders = requestHeaders(request.headers);
5654
+ const evalReq = new Request(targetUrl.toString(), {
5655
+ method: request.method,
5656
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5657
+ // sanitized at the cross-origin boundary below.
5658
+ headers: request.headers,
5659
+ body
5660
+ });
5661
+ Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
5662
+ const blocked = await guard(evalReq, ...hostArgs);
5663
+ if (blocked) return blocked;
5664
+ let upstream;
5665
+ try {
5666
+ upstream = await fetchImpl(targetUrl.toString(), {
5667
+ method: request.method,
5668
+ headers: forwardHeaders,
5669
+ body,
5670
+ redirect: "manual",
5671
+ signal: AbortSignal.timeout(upstreamTimeout)
5672
+ });
5673
+ } catch (error) {
5674
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5675
+ return new Response("patchstack: upstream timed out", { status: 504 });
5676
+ }
5677
+ throw error;
5678
+ }
5679
+ const forwarded = new Response(upstream.body, {
5680
+ status: upstream.status,
5681
+ statusText: upstream.statusText,
5682
+ headers: responseHeaders(upstream.headers)
5683
+ });
5684
+ return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
5685
+ };
4865
5686
  }
4866
5687
 
4867
5688
  // src/protect/firewall-log.js
@@ -4910,8 +5731,13 @@ function resolveApiBase(pulseOrManifestUrl) {
4910
5731
  function createFirewallLogReporter(opts) {
4911
5732
  const creds = parseApiKey(opts.apiKey);
4912
5733
  if (!creds) {
4913
- return { record() {
4914
- }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
5734
+ return {
5735
+ record() {
5736
+ },
5737
+ flush: () => Promise.resolve(),
5738
+ stop: () => Promise.resolve(),
5739
+ health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
5740
+ };
4915
5741
  }
4916
5742
  const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
4917
5743
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -4924,6 +5750,19 @@ function createFirewallLogReporter(opts) {
4924
5750
  let drainPromise = null;
4925
5751
  let activeController = null;
4926
5752
  let ended = false;
5753
+ let recorded = 0;
5754
+ let delivered = 0;
5755
+ let failed = 0;
5756
+ let dropped = 0;
5757
+ let activeBatch = null;
5758
+ const settle = (sent, outcome) => {
5759
+ if (sent.counted) return;
5760
+ sent.counted = true;
5761
+ if (outcome === "delivered") delivered += sent.size;
5762
+ else if (outcome === "failed") failed += sent.size;
5763
+ else dropped += sent.size;
5764
+ if (activeBatch === sent) activeBatch = null;
5765
+ };
4927
5766
  let cachedToken = null;
4928
5767
  let tokenInflight = null;
4929
5768
  const fetchAccessToken = async (signal) => {
@@ -4977,10 +5816,16 @@ function createFirewallLogReporter(opts) {
4977
5816
  const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
4978
5817
  attemptTimer?.unref?.();
4979
5818
  let entry;
5819
+ const sent = { size: batch.length, counted: false };
5820
+ activeBatch = sent;
4980
5821
  entry = (async () => {
4981
5822
  try {
4982
5823
  const token = await fetchAccessToken(controller?.signal);
4983
- if (!token || ended) return;
5824
+ if (ended) return;
5825
+ if (!token) {
5826
+ settle(sent, "failed");
5827
+ return;
5828
+ }
4984
5829
  const body = new URLSearchParams();
4985
5830
  body.set("type", "firewall");
4986
5831
  body.set("logs", JSON.stringify(batch));
@@ -4997,10 +5842,11 @@ function createFirewallLogReporter(opts) {
4997
5842
  // Both phases carry the attempt controller, so slow transports cannot accumulate work.
4998
5843
  ...controller ? { signal: controller.signal } : {}
4999
5844
  });
5000
- if (p && typeof p.then === "function") await p.catch(() => {
5001
- });
5845
+ const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
5846
+ settle(sent, res && res.ok ? "delivered" : "failed");
5002
5847
  } catch {
5003
5848
  } finally {
5849
+ settle(sent, "failed");
5004
5850
  clearTimeout(attemptTimer);
5005
5851
  if (activeController === controller) activeController = null;
5006
5852
  if (inFlight === entry) inFlight = null;
@@ -5027,234 +5873,85 @@ function createFirewallLogReporter(opts) {
5027
5873
  if (stopped) return;
5028
5874
  const fid = event?.rule?.id;
5029
5875
  if (fid === void 0 || fid === null || fid === "") return;
5030
- if (queue.length >= MAX_QUEUE2) return;
5031
- queue.push({
5032
- fid,
5033
- method: event.method ?? null,
5034
- request_uri: event.path ?? null,
5035
- ip: event.ip ?? null,
5036
- user_agent: event.userAgent ?? null,
5037
- log_date: (/* @__PURE__ */ new Date()).toISOString()
5038
- });
5039
- if (queue.length >= MAX_BATCH2) {
5040
- flush();
5876
+ if (queue.length >= MAX_QUEUE2) {
5877
+ dropped++;
5041
5878
  return;
5042
- }
5043
- if (!timer) timer = setTimeout(flush, flushMs);
5044
- },
5045
- flush,
5046
- /**
5047
- * Stop, and hand back a wait for what was outstanding.
5048
- *
5049
- * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
5050
- * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
5051
- * spinning would be worse than leaving the records where they are.
5052
- */
5053
- stop() {
5054
- if (drainPromise) return drainPromise;
5055
- stopped = true;
5056
- if (timer) {
5057
- clearTimeout(timer);
5058
- timer = null;
5059
- }
5060
- const terminate = () => {
5061
- ended = true;
5062
- activeController?.abort();
5063
- activeController = null;
5064
- queue = [];
5065
- inFlight = null;
5066
- };
5067
- let budget = null;
5068
- const spent = new Promise((resolve) => {
5069
- budget = setTimeout(() => {
5070
- terminate();
5071
- resolve();
5072
- }, STOP_BUDGET_MS2);
5073
- if (typeof budget.unref === "function") budget.unref();
5074
- });
5075
- const work = (async () => {
5076
- while (!ended) {
5077
- if (inFlight) {
5078
- await inFlight;
5079
- continue;
5080
- }
5081
- if (queue.length === 0) break;
5082
- const before = queue.length;
5083
- await flush();
5084
- if (queue.length >= before) break;
5085
- }
5086
- })();
5087
- drainPromise = Promise.race([work, spent]).then(() => {
5088
- if (budget) clearTimeout(budget);
5089
- });
5090
- return drainPromise;
5091
- }
5092
- };
5093
- }
5094
- function telemetryEnabled() {
5095
- const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
5096
- if (v === void 0 || v === "") return true;
5097
- return !/^(0|false|off|no)$/i.test(String(v));
5098
- }
5099
-
5100
- // src/protect/supabase-guard.js
5101
- var GUARD_PATH = "/_patchstack/guard";
5102
- var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5103
- var DEFAULT_TIMEOUT_MS = 3e4;
5104
- var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5105
- "connection",
5106
- "cookie",
5107
- "cookie2",
5108
- "forwarded",
5109
- "host",
5110
- "keep-alive",
5111
- "proxy-authenticate",
5112
- "proxy-authorization",
5113
- "te",
5114
- "trailer",
5115
- "transfer-encoding",
5116
- "upgrade",
5117
- "x-forwarded-for",
5118
- "x-forwarded-host",
5119
- "x-forwarded-port",
5120
- "x-forwarded-proto",
5121
- "x-ps-target",
5122
- "x-real-ip"
5123
- ]);
5124
- var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5125
- "connection",
5126
- "content-encoding",
5127
- "content-length",
5128
- "keep-alive",
5129
- "proxy-authenticate",
5130
- "proxy-authorization",
5131
- "set-cookie",
5132
- "set-cookie2",
5133
- "te",
5134
- "trailer",
5135
- "transfer-encoding",
5136
- "upgrade"
5137
- ]);
5138
- var BodyTooLargeError = class extends Error {
5139
- };
5140
- async function boundedBody(request, maxBytes) {
5141
- const stated = Number(request.headers.get("content-length"));
5142
- if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5143
- if (!request.body) return void 0;
5144
- const reader = request.body.getReader();
5145
- const chunks = [];
5146
- let total = 0;
5147
- try {
5148
- for (; ; ) {
5149
- const { done, value } = await reader.read();
5150
- if (done) break;
5151
- total += value.byteLength;
5152
- if (total > maxBytes) {
5153
- void reader.cancel().catch(() => {
5154
- });
5155
- throw new BodyTooLargeError();
5156
- }
5157
- chunks.push(value);
5158
- }
5159
- } finally {
5160
- reader.releaseLock();
5161
- }
5162
- const body = new Uint8Array(total);
5163
- let offset = 0;
5164
- for (const chunk of chunks) {
5165
- body.set(chunk, offset);
5166
- offset += chunk.byteLength;
5167
- }
5168
- return body;
5169
- }
5170
- function requestHeaders(input) {
5171
- const output = new Headers();
5172
- const connectionTokens = new Set(
5173
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5174
- );
5175
- input.forEach((value, key) => {
5176
- const lower = key.toLowerCase();
5177
- if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5178
- });
5179
- return output;
5180
- }
5181
- function responseHeaders(input) {
5182
- const output = new Headers();
5183
- const connectionTokens = new Set(
5184
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5185
- );
5186
- input.forEach((value, key) => {
5187
- const lower = key.toLowerCase();
5188
- if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5189
- });
5190
- return output;
5191
- }
5192
- function createSupabaseGuard({
5193
- protection,
5194
- supabaseUrl,
5195
- fetchImpl = fetch,
5196
- maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5197
- timeoutMs = DEFAULT_TIMEOUT_MS
5198
- }) {
5199
- const guard = protection.fetchGuard();
5200
- const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5201
- const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5202
- const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
5203
- return async function handleGuardRequest(request) {
5204
- const target = request.headers.get("x-ps-target");
5205
- if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
5206
- let targetUrl;
5207
- try {
5208
- targetUrl = new URL(target);
5209
- } catch {
5210
- return new Response("patchstack: invalid target", { status: 400 });
5211
- }
5212
- if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
5213
- return new Response("patchstack: target not allowed", { status: 403 });
5214
- }
5215
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
5216
- let body;
5217
- try {
5218
- body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5219
- } catch (error) {
5220
- if (error instanceof BodyTooLargeError) {
5221
- return new Response("patchstack: request body too large", { status: 413 });
5222
- }
5223
- throw error;
5224
- }
5225
- const forwardHeaders = requestHeaders(request.headers);
5226
- const evalReq = new Request(targetUrl.toString(), {
5227
- method: request.method,
5228
- // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5229
- // sanitized at the cross-origin boundary below.
5230
- headers: request.headers,
5231
- body
5232
- });
5233
- const blocked = await guard(evalReq);
5234
- if (blocked) return blocked;
5235
- let upstream;
5236
- try {
5237
- upstream = await fetchImpl(targetUrl.toString(), {
5238
- method: request.method,
5239
- headers: forwardHeaders,
5240
- body,
5241
- redirect: "manual",
5242
- signal: AbortSignal.timeout(upstreamTimeout)
5879
+ }
5880
+ recorded++;
5881
+ queue.push({
5882
+ fid,
5883
+ method: event.method ?? null,
5884
+ request_uri: event.path ?? null,
5885
+ ip: event.ip ?? null,
5886
+ user_agent: event.userAgent ?? null,
5887
+ log_date: (/* @__PURE__ */ new Date()).toISOString()
5243
5888
  });
5244
- } catch (error) {
5245
- if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5246
- return new Response("patchstack: upstream timed out", { status: 504 });
5889
+ if (queue.length >= MAX_BATCH2) {
5890
+ flush();
5891
+ return;
5247
5892
  }
5248
- throw error;
5893
+ if (!timer) timer = setTimeout(flush, flushMs);
5894
+ },
5895
+ flush,
5896
+ /** Where the records went so far; see the counters above. `queued` is what is waiting now. */
5897
+ health() {
5898
+ return { recorded, delivered, failed, dropped, queued: queue.length };
5899
+ },
5900
+ /**
5901
+ * Stop, and hand back a wait for what was outstanding.
5902
+ *
5903
+ * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
5904
+ * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
5905
+ * spinning would be worse than leaving the records where they are.
5906
+ */
5907
+ stop() {
5908
+ if (drainPromise) return drainPromise;
5909
+ stopped = true;
5910
+ if (timer) {
5911
+ clearTimeout(timer);
5912
+ timer = null;
5913
+ }
5914
+ const terminate = () => {
5915
+ ended = true;
5916
+ activeController?.abort();
5917
+ activeController = null;
5918
+ if (activeBatch) settle(activeBatch, "dropped");
5919
+ dropped += queue.length;
5920
+ queue = [];
5921
+ inFlight = null;
5922
+ };
5923
+ let budget = null;
5924
+ const spent = new Promise((resolve) => {
5925
+ budget = setTimeout(() => {
5926
+ terminate();
5927
+ resolve();
5928
+ }, STOP_BUDGET_MS2);
5929
+ if (typeof budget.unref === "function") budget.unref();
5930
+ });
5931
+ const work = (async () => {
5932
+ while (!ended) {
5933
+ if (inFlight) {
5934
+ await inFlight;
5935
+ continue;
5936
+ }
5937
+ if (queue.length === 0) break;
5938
+ const before = queue.length;
5939
+ await flush();
5940
+ if (queue.length >= before) break;
5941
+ }
5942
+ })();
5943
+ drainPromise = Promise.race([work, spent]).then(() => {
5944
+ if (budget) clearTimeout(budget);
5945
+ });
5946
+ return drainPromise;
5249
5947
  }
5250
- const forwarded = new Response(upstream.body, {
5251
- status: upstream.status,
5252
- statusText: upstream.statusText,
5253
- headers: responseHeaders(upstream.headers)
5254
- });
5255
- return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
5256
5948
  };
5257
5949
  }
5950
+ function telemetryEnabled() {
5951
+ const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
5952
+ if (v === void 0 || v === "") return true;
5953
+ return !/^(0|false|off|no)$/i.test(String(v));
5954
+ }
5258
5955
 
5259
5956
  // src/protect/verify-sentinel.js
5260
5957
  var VERIFY_HEADER = "x-patchstack-verify";
@@ -5346,6 +6043,7 @@ async function createProtection(options = {}) {
5346
6043
  "onDetect"
5347
6044
  );
5348
6045
  };
6046
+ const nodeHttp = await import("http").then((m) => m.default ?? m, () => null);
5349
6047
  const store = makeStore(options);
5350
6048
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
5351
6049
  const pulseAuth = await resolvePulseAuth(options);
@@ -5371,6 +6069,21 @@ async function createProtection(options = {}) {
5371
6069
  pulseAuth,
5372
6070
  detectionState: preFetchState
5373
6071
  });
6072
+ let ruleSource = { ...bundle.source ?? { ok: true, origin: "empty" } };
6073
+ const warnedSources = /* @__PURE__ */ new Set();
6074
+ const hasLiveSource = Boolean(options.siteUuid || options.token);
6075
+ const noteRuleSource = (source) => {
6076
+ ruleSource = { ...source ?? { ok: true, origin: ruleSource.origin } };
6077
+ if (!hasLiveSource || typeof onError === "function" || ruleSource.ok !== false) return;
6078
+ const cause = `${ruleSource.origin}:${ruleSource.reason ?? ""}`;
6079
+ if (warnedSources.has(cause)) return;
6080
+ warnedSources.add(cause);
6081
+ const running = ruleSource.origin === "cache" ? "the last rules it received" : ruleSource.origin === "bundled" ? "its bundled fallback rules" : "no rules at all";
6082
+ console.warn(
6083
+ `[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.`
6084
+ );
6085
+ };
6086
+ noteRuleSource(bundle.source);
5374
6087
  let detectionReporting = "not-enrolled";
5375
6088
  let currentOrigin;
5376
6089
  const applyReportingState = async (origin) => {
@@ -5426,20 +6139,47 @@ async function createProtection(options = {}) {
5426
6139
  whitelist_keys: delivered.whitelist_keys,
5427
6140
  onError
5428
6141
  });
5429
- responseRuleSet = responseRules.map((rule) => ({
5430
- rule,
5431
- engine: new RuleEngine({ firewall: [rule], onError }),
5432
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
5433
- // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
5434
- // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
5435
- // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
5436
- // fails such a rule CLOSED (block) instead of serving a no-op redaction.
5437
- mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
5438
- // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
5439
- // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
5440
- // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
5441
- prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
5442
- }));
6142
+ responseRuleSet = responseRules.map((rule) => {
6143
+ const unscoped = [];
6144
+ const rewrites = rule.action === "redact" || rule.action === "encode";
6145
+ let derived;
6146
+ try {
6147
+ const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
6148
+ const mutatedSpan = rewrites && hasSpanMutations(rule);
6149
+ derived = {
6150
+ redactors,
6151
+ mutatedSpan,
6152
+ // A redaction that reads response headers only, and masks spans in them, and an explicit block
6153
+ // that reads response headers only, are decided and carried out without the body — so they
6154
+ // still apply to a response whose body was not screened.
6155
+ redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
6156
+ blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
6157
+ };
6158
+ } catch (err) {
6159
+ notify(onError, err, "onError");
6160
+ derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
6161
+ }
6162
+ if (unscoped.length) {
6163
+ notify(
6164
+ onError,
6165
+ new Error(
6166
+ `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`
6167
+ ),
6168
+ "onError"
6169
+ );
6170
+ }
6171
+ return {
6172
+ rule,
6173
+ engine: new RuleEngine({ firewall: [rule], onError }),
6174
+ ...derived,
6175
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
6176
+ // have any chance of matching. Lets screenText skip the full scan on responses with no candidate
6177
+ // — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
6178
+ // Case-insensitive, and checked against the header values as well as the body, since a rule may
6179
+ // read either.
6180
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
6181
+ };
6182
+ });
5443
6183
  egressRuleSet = egressRules.map((rule) => ({
5444
6184
  rule,
5445
6185
  engine: new RuleEngine({ firewall: [rule], onError })
@@ -5453,6 +6193,9 @@ async function createProtection(options = {}) {
5453
6193
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
5454
6194
  notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
5455
6195
  };
6196
+ const recordEvaluationSkips = (result) => {
6197
+ for (const reason of result?.skips ?? []) recordSkip("request", reason);
6198
+ };
5456
6199
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
5457
6200
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
5458
6201
  const planCache = createPlanCache();
@@ -5495,15 +6238,19 @@ async function createProtection(options = {}) {
5495
6238
  });
5496
6239
  return effectiveMode === "block" ? block() : allow2();
5497
6240
  };
6241
+ const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
6242
+ const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
5498
6243
  const screenText = (text, meta, reqCtx, only) => {
5499
6244
  let blockRule = null;
5500
6245
  const redactions = [];
5501
6246
  const headerMutations = [];
5502
6247
  let lowerText = null;
5503
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
5504
- if (only && !only(rule)) continue;
6248
+ const responseSkips = /* @__PURE__ */ new Set();
6249
+ for (const entry of responseRuleSet) {
6250
+ const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
6251
+ if (only && !only(rule, entry)) continue;
5505
6252
  if (prefilter) {
5506
- if (lowerText === null) lowerText = text.toLowerCase();
6253
+ if (lowerText === null) lowerText = prefilterText(text, meta.headers);
5507
6254
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
5508
6255
  }
5509
6256
  let result;
@@ -5513,6 +6260,11 @@ async function createProtection(options = {}) {
5513
6260
  notify(onError, err, "onError");
5514
6261
  continue;
5515
6262
  }
6263
+ for (const reason of result.skips ?? []) {
6264
+ if (responseSkips.has(reason)) continue;
6265
+ responseSkips.add(reason);
6266
+ recordSkip("response", reason);
6267
+ }
5516
6268
  if (!result.blocked) continue;
5517
6269
  const responseMode = ruleMode(rule);
5518
6270
  onDetect({
@@ -5542,6 +6294,8 @@ async function createProtection(options = {}) {
5542
6294
  if (blockRule) return { verdict: "block" };
5543
6295
  let body = text;
5544
6296
  const headers = { ...meta.headers || {} };
6297
+ let responseIsJson;
6298
+ let structure;
5545
6299
  for (const { rule, redactors } of redactions) {
5546
6300
  const mask = maskFn(rule.category);
5547
6301
  const transform = rule.action === "encode" ? htmlEscape : null;
@@ -5549,17 +6303,29 @@ async function createProtection(options = {}) {
5549
6303
  const spanRedactors = redactors.filter((r) => !r.jsonPath);
5550
6304
  if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
5551
6305
  if (!spanRedactors.length) continue;
5552
- body = applyRedactors(body, spanRedactors, mask, transform);
6306
+ const beforeSpan = body;
6307
+ const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
6308
+ if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
6309
+ if (body !== beforeSpan) {
6310
+ responseIsJson ??= isJson(text);
6311
+ if (responseIsJson) {
6312
+ if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
6313
+ const tokens = lexJson(body);
6314
+ if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
6315
+ structure = { text: body, tokens };
6316
+ }
6317
+ }
5553
6318
  if (transform) continue;
5554
6319
  for (const name of Object.keys(headers)) {
5555
6320
  const value = headers[name];
6321
+ const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
5556
6322
  if (typeof value === "string") {
5557
- setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
6323
+ setOwn(headers, name, applyRedactors(value, headerSpans, mask));
5558
6324
  } else if (Array.isArray(value)) {
5559
6325
  setOwn(
5560
6326
  headers,
5561
6327
  name,
5562
- value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
6328
+ value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
5563
6329
  );
5564
6330
  }
5565
6331
  }
@@ -5606,18 +6372,53 @@ async function createProtection(options = {}) {
5606
6372
  }
5607
6373
  };
5608
6374
  };
5609
- const screenFetchRequest = async (request) => {
6375
+ const peerOf = (request, hostArgs) => {
6376
+ if (typeof options.peerAddress !== "function") return void 0;
6377
+ try {
6378
+ return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
6379
+ } catch (err) {
6380
+ notify(onError, err, "onError");
6381
+ return void 0;
6382
+ }
6383
+ };
6384
+ let warnedNoPeer = false;
6385
+ const warnNoPeer = () => {
6386
+ if (warnedNoPeer) return;
6387
+ warnedNoPeer = true;
6388
+ 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.";
6389
+ if (typeof onError === "function") notify(onError, new Error(message), "onError");
6390
+ else console.warn(message);
6391
+ };
6392
+ const clientByRequest = /* @__PURE__ */ new WeakMap();
6393
+ const clientForResponse = (request, hostArgs) => {
6394
+ const known = clientByRequest.get(request);
6395
+ if (known) return known;
6396
+ const client = resolveClientIp({
6397
+ peer: peerOf(request, hostArgs),
6398
+ headers: headerObject(request.headers),
6399
+ trustedProxy: options.trustedProxy
6400
+ });
6401
+ if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
6402
+ return client;
6403
+ };
6404
+ const screenFetchRequest = async (request, hostArgs = []) => {
5610
6405
  let result;
5611
6406
  let shaped;
5612
6407
  try {
5613
6408
  shaped = await fromFetchRequest(request, {
6409
+ peer: peerOf(request, hostArgs),
5614
6410
  trustedProxy: options.trustedProxy,
5615
6411
  maxBodyBytes: options.maxBodyBytes
5616
6412
  });
6413
+ if (shaped?._clientIp) {
6414
+ clientByRequest.set(request, shaped._clientIp);
6415
+ if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
6416
+ }
5617
6417
  if (shaped?._bodyInspectionSkip) {
5618
6418
  recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
5619
6419
  }
5620
6420
  result = engine.evaluate(shaped);
6421
+ recordEvaluationSkips(result);
5621
6422
  } catch (err) {
5622
6423
  notify(onError, err, "onError");
5623
6424
  return { blocked: null, client: void 0 };
@@ -5666,14 +6467,20 @@ async function createProtection(options = {}) {
5666
6467
  const screenResp = async (response, reqCtx) => {
5667
6468
  const read = await readTextResponse(response, screenCap);
5668
6469
  if (read.skip) {
5669
- if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
6470
+ if (read.skip !== "not-a-response") {
6471
+ recordSkip("response", read.skip, {
6472
+ status: response?.status,
6473
+ ...read.encoding ? { encoding: read.encoding } : {},
6474
+ ...read.charset ? { charset: read.charset } : {}
6475
+ });
6476
+ }
5670
6477
  if (read.skip === "not-a-response") return response;
5671
6478
  return hardenHeadersOnly(response, reqCtx);
5672
6479
  }
5673
- const text = read.text;
6480
+ const text = readJsonEscapes(read.text);
5674
6481
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
5675
- if (r.verdict === "block") return leakResponse();
5676
- if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
6482
+ if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
6483
+ if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
5677
6484
  return response;
5678
6485
  };
5679
6486
  const headerValueChanged = (was, value) => {
@@ -5735,7 +6542,8 @@ async function createProtection(options = {}) {
5735
6542
  const hardenHeadersOnly = (response, reqCtx) => {
5736
6543
  try {
5737
6544
  const meta = { status: response.status, headers: headerObject(response.headers) };
5738
- const r = screenText("", meta, reqCtx, hardensWithoutBody);
6545
+ const r = screenText("", meta, reqCtx, decidedWithoutBody);
6546
+ if (r.verdict === "block") return leakResponse();
5739
6547
  if (r.verdict !== "redact" || !r.headers) return response;
5740
6548
  if (!headersChanged(meta.headers, r.headers)) return response;
5741
6549
  return rebuildResponse(response, response.body, r.headers);
@@ -5747,9 +6555,16 @@ async function createProtection(options = {}) {
5747
6555
  const wrapNodeResponse = (res, reqCtx) => {
5748
6556
  const origWrite = res.write.bind(res);
5749
6557
  const origEnd = res.end.bind(res);
6558
+ const setHeaderNow = typeof res.setHeader === "function" ? res.setHeader.bind(res) : null;
6559
+ const removeHeaderNow = typeof res.removeHeader === "function" ? res.removeHeader.bind(res) : null;
6560
+ const sentAccessor = accessorOf(res, "headersSent");
6561
+ let sentValue = sentAccessor ? void 0 : res.headersSent;
6562
+ const reallySent = () => Boolean(sentAccessor ? sentAccessor.call(res) : sentValue);
5750
6563
  const chunks = [];
5751
6564
  let size = 0;
5752
6565
  let overflow = false;
6566
+ let withheld = false;
6567
+ let headScreened = false;
5753
6568
  const MAX = screenCap;
5754
6569
  let hardened = false;
5755
6570
  let answeredWithoutBody = false;
@@ -5757,7 +6572,7 @@ async function createProtection(options = {}) {
5757
6572
  if (hardened) return null;
5758
6573
  hardened = true;
5759
6574
  try {
5760
- if (res.headersSent || typeof res.setHeader !== "function") return null;
6575
+ if (reallySent() || !setHeaderNow) return null;
5761
6576
  const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
5762
6577
  const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
5763
6578
  const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
@@ -5769,8 +6584,8 @@ async function createProtection(options = {}) {
5769
6584
  if (!headerValueChanged(headers[name], value)) continue;
5770
6585
  changed.set(name.toLowerCase(), value);
5771
6586
  try {
5772
- if (value === null || value === void 0) res.removeHeader?.(name);
5773
- else res.setHeader(name, value);
6587
+ if (value === null || value === void 0) removeHeaderNow?.(name);
6588
+ else setHeaderNow(name, value);
5774
6589
  } catch {
5775
6590
  }
5776
6591
  }
@@ -5781,26 +6596,187 @@ async function createProtection(options = {}) {
5781
6596
  }
5782
6597
  };
5783
6598
  const stillToAnswer = (rule) => !hardensWithoutBody(rule);
5784
- if (typeof res.writeHead === "function") {
5785
- const origWriteHead = res.writeHead.bind(res);
6599
+ const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
6600
+ let pendingHead = null;
6601
+ let sending = false;
6602
+ const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
6603
+ if (origWriteHead) {
5786
6604
  res.writeHead = function(...args) {
5787
- const at = writeHeadHeaderIndex(args);
5788
- const given = at === -1 ? null : args[at];
5789
- const changed = hardenBeforeFlush({
5790
- status: typeof args[0] === "number" ? args[0] : void 0,
5791
- headers: given ? writeHeadHeaderObject(given) : null
5792
- });
5793
- if (!changed || !given) return origWriteHead(...args);
5794
- const rewritten = [...args];
5795
- rewritten[at] = rewriteWriteHeadHeaders(given, changed);
5796
- return origWriteHead(...rewritten);
6605
+ if (sending || reallySent()) return origWriteHead(...args);
6606
+ if (pendingHead) throw headersSentError("write");
6607
+ const judged = judgeWriteHead(args);
6608
+ if (judged) {
6609
+ res.statusCode = judged.statusCode;
6610
+ res.statusMessage = judged.statusMessage;
6611
+ mirrorHeaders(judged.headers);
6612
+ pendingHead = args;
6613
+ } else {
6614
+ pendingHead = args;
6615
+ if (typeof args[0] === "number") res.statusCode = args[0];
6616
+ if (typeof args[1] === "string") res.statusMessage = args[1];
6617
+ }
6618
+ return res;
6619
+ };
6620
+ }
6621
+ function judgeWriteHead(args) {
6622
+ const ServerResponse = nodeHttp?.ServerResponse;
6623
+ if (typeof ServerResponse !== "function") return null;
6624
+ const detached = new ServerResponse({ method: res.req?.method ?? "GET", httpVersionMajor: 1, httpVersionMinor: 1, headers: {} });
6625
+ if (res.statusMessage !== void 0) detached.statusMessage = res.statusMessage;
6626
+ for (const [name, value] of headerEntries(res)) detached.setHeader(name, value);
6627
+ detached.writeHead(...args);
6628
+ return { statusCode: detached.statusCode, statusMessage: detached.statusMessage, headers: headerEntries(detached) };
6629
+ }
6630
+ function headerEntries(target) {
6631
+ const names = typeof target.getRawHeaderNames === "function" ? target.getRawHeaderNames() : typeof target.getHeaderNames === "function" ? target.getHeaderNames() : [];
6632
+ return names.map((name) => [name, target.getHeader(name)]);
6633
+ }
6634
+ function mirrorHeaders(entries) {
6635
+ if (!setHeaderNow) return;
6636
+ for (const [name, value] of entries) {
6637
+ const current = res.getHeader?.(name);
6638
+ if (current === void 0 || headerValueChanged(current, value)) setHeaderNow(name, value);
6639
+ }
6640
+ }
6641
+ Object.defineProperty(res, "headersSent", {
6642
+ configurable: true,
6643
+ enumerable: true,
6644
+ get: () => pendingHead !== null || reallySent(),
6645
+ // A response-like object that tracks the flag itself keeps doing so.
6646
+ set: (value) => {
6647
+ if (!sentAccessor) sentValue = value;
6648
+ }
6649
+ });
6650
+ for (const [method, verb] of [["setHeader", "set"], ["removeHeader", "remove"], ["appendHeader", "append"]]) {
6651
+ const original = typeof res[method] === "function" ? res[method].bind(res) : null;
6652
+ if (!original) continue;
6653
+ res[method] = function(...args) {
6654
+ if (pendingHead) throw headersSentError(verb);
6655
+ return original(...args);
6656
+ };
6657
+ }
6658
+ if (typeof res.flushHeaders === "function") {
6659
+ const origFlushHeaders = res.flushHeaders.bind(res);
6660
+ res.flushHeaders = function() {
6661
+ const unread = screenUnreadHead();
6662
+ if (unread?.withhold) {
6663
+ sendWithheld();
6664
+ return void 0;
6665
+ }
6666
+ sendHead(unread);
6667
+ return origFlushHeaders();
5797
6668
  };
5798
6669
  }
6670
+ let hardenDone = false;
6671
+ let hardenedValues = null;
6672
+ const hardenOnce = () => {
6673
+ if (hardenDone) return hardenedValues;
6674
+ hardenDone = true;
6675
+ if (!pendingHead) {
6676
+ hardenedValues = hardenBeforeFlush();
6677
+ return hardenedValues;
6678
+ }
6679
+ const at = writeHeadHeaderIndex(pendingHead);
6680
+ hardenedValues = hardenBeforeFlush({
6681
+ status: typeof pendingHead[0] === "number" ? pendingHead[0] : void 0,
6682
+ headers: at === -1 ? null : writeHeadHeaderObject(pendingHead[at])
6683
+ });
6684
+ return hardenedValues;
6685
+ };
6686
+ const effectiveHead = () => {
6687
+ const set = typeof res.getHeaders === "function" ? { ...res.getHeaders() } : {};
6688
+ if (typeof res.getHeaders !== "function" && typeof res.getHeader === "function") {
6689
+ for (const name of ["content-type", "content-length"]) {
6690
+ const value = res.getHeader(name);
6691
+ if (value !== void 0) set[name] = value;
6692
+ }
6693
+ }
6694
+ if (!pendingHead) return { status: res.statusCode, headers: set };
6695
+ const at = writeHeadHeaderIndex(pendingHead);
6696
+ return {
6697
+ status: typeof pendingHead[0] === "number" ? pendingHead[0] : res.statusCode,
6698
+ headers: at === -1 ? set : { ...set, ...writeHeadHeaderObject(pendingHead[at]) }
6699
+ };
6700
+ };
6701
+ const screenUnreadHead = () => {
6702
+ if (headScreened) return void 0;
6703
+ headScreened = true;
6704
+ try {
6705
+ const head = effectiveHead();
6706
+ const r = screenText("", head, reqCtx, decidedByHeaders);
6707
+ if (r.verdict === "block") {
6708
+ if (!reallySent()) return { withhold: true };
6709
+ recordSkip("response", "headers-sent", { action: "block" });
6710
+ return void 0;
6711
+ }
6712
+ if (r.verdict !== "redact" || !r.headers) return void 0;
6713
+ const changed = /* @__PURE__ */ new Map();
6714
+ for (const [name, value] of Object.entries(r.headers)) {
6715
+ if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
6716
+ }
6717
+ if (changed.size && reallySent()) {
6718
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
6719
+ return void 0;
6720
+ }
6721
+ return changed.size ? { headers: changed } : void 0;
6722
+ } catch (err) {
6723
+ notify(onError, err, "onError");
6724
+ return void 0;
6725
+ }
6726
+ };
6727
+ const sendHead = (changes) => {
6728
+ if (sending) return;
6729
+ const hardened2 = hardenOnce();
6730
+ sending = true;
6731
+ if (!pendingHead) {
6732
+ if (changes) applyToResponse(changes);
6733
+ return;
6734
+ }
6735
+ const args = [...pendingHead];
6736
+ pendingHead = null;
6737
+ const merged = new Map(hardened2 ?? []);
6738
+ for (const [name, value] of changes?.headers ?? []) merged.set(name, value);
6739
+ if (changes?.status !== void 0) {
6740
+ args[0] = changes.status;
6741
+ if (typeof args[1] === "string") args.splice(1, 1);
6742
+ }
6743
+ const headerAt = writeHeadHeaderIndex(args);
6744
+ if (headerAt !== -1 && merged.size) args[headerAt] = rewriteWriteHeadHeaders(args[headerAt], merged);
6745
+ applyToResponse({ headers: merged });
6746
+ origWriteHead(...args);
6747
+ };
6748
+ const sendWithheld = (cb) => {
6749
+ const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
6750
+ const headers = /* @__PURE__ */ new Map();
6751
+ for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
6752
+ headers.set("content-type", "application/json");
6753
+ headers.set("content-length", String(Buffer.byteLength(body)));
6754
+ headers.set("transfer-encoding", null);
6755
+ withheld = true;
6756
+ sendHead({ status: 500, headers });
6757
+ return origEnd(body, cb);
6758
+ };
6759
+ const applyToResponse = (changes) => {
6760
+ if (changes.status !== void 0) res.statusCode = changes.status;
6761
+ for (const [name, value] of changes.headers ?? []) {
6762
+ try {
6763
+ if (value === null || value === void 0) removeHeaderNow?.(name);
6764
+ else setHeaderNow?.(name, value);
6765
+ } catch {
6766
+ }
6767
+ }
6768
+ };
5799
6769
  const collect = (chunk, enc) => {
5800
6770
  if (chunk == null) return;
5801
6771
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
5802
6772
  size += buf.length;
5803
6773
  if (size > MAX) {
6774
+ const unread = screenUnreadHead();
6775
+ if (unread?.withhold) {
6776
+ sendWithheld();
6777
+ return;
6778
+ }
6779
+ sendHead(unread);
5804
6780
  for (const c of chunks) origWrite(c);
5805
6781
  chunks.length = 0;
5806
6782
  origWrite(buf);
@@ -5811,7 +6787,11 @@ async function createProtection(options = {}) {
5811
6787
  chunks.push(buf);
5812
6788
  };
5813
6789
  res.write = function(chunk, enc, cb) {
5814
- hardenBeforeFlush();
6790
+ if (withheld) {
6791
+ if (typeof enc === "function") enc();
6792
+ else if (typeof cb === "function") cb();
6793
+ return true;
6794
+ }
5815
6795
  if (overflow) return origWrite(chunk, enc, cb);
5816
6796
  collect(chunk, enc);
5817
6797
  if (typeof enc === "function") enc();
@@ -5819,7 +6799,6 @@ async function createProtection(options = {}) {
5819
6799
  return true;
5820
6800
  };
5821
6801
  res.end = function(chunk, enc, cb) {
5822
- hardenBeforeFlush();
5823
6802
  if (typeof chunk === "function") {
5824
6803
  cb = chunk;
5825
6804
  chunk = void 0;
@@ -5828,74 +6807,90 @@ async function createProtection(options = {}) {
5828
6807
  cb = enc;
5829
6808
  enc = void 0;
5830
6809
  }
6810
+ if (withheld) {
6811
+ if (typeof cb === "function") cb();
6812
+ return res;
6813
+ }
5831
6814
  if (overflow) {
5832
6815
  if (chunk != null) origWrite(chunk, enc);
5833
6816
  return origEnd(cb);
5834
6817
  }
5835
6818
  collect(chunk, enc);
6819
+ if (withheld) {
6820
+ if (typeof cb === "function") cb();
6821
+ return res;
6822
+ }
5836
6823
  if (overflow) return origEnd(cb);
6824
+ hardenOnce();
6825
+ const passThrough = (changes) => {
6826
+ if (changes?.withhold) return sendWithheld(cb);
6827
+ sendHead(changes);
6828
+ for (const c of chunks) origWrite(c);
6829
+ return origEnd(cb);
6830
+ };
5837
6831
  const buffer = Buffer.concat(chunks);
5838
- let ct = res.getHeader ? res.getHeader("content-type") : void 0;
6832
+ const head = effectiveHead();
6833
+ let ct = head.headers["content-type"];
5839
6834
  if (Array.isArray(ct)) ct = ct[0];
5840
6835
  const kind = screenableContentType(ct);
5841
6836
  if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
5842
6837
  recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
5843
- for (const c of chunks) origWrite(c);
5844
- return origEnd(cb);
6838
+ return passThrough(screenUnreadHead());
5845
6839
  }
5846
- const text = buffer.toString("utf8");
6840
+ const codings = declaredCodings(head.headers["content-encoding"]);
6841
+ if (codings.length > 0 && stillEncoded(buffer)) {
6842
+ recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
6843
+ return passThrough(screenUnreadHead());
6844
+ }
6845
+ const reading = charsetReading(ct, buffer);
6846
+ if (reading.skip) {
6847
+ recordSkip("response", "unsupported-charset", { charset: reading.skip });
6848
+ return passThrough(screenUnreadHead());
6849
+ }
6850
+ const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
5847
6851
  let r;
5848
6852
  try {
5849
- r = screenText(
5850
- text,
5851
- { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
5852
- reqCtx,
5853
- answeredWithoutBody ? stillToAnswer : void 0
5854
- );
6853
+ r = screenText(text, head, reqCtx, notYetAnswered);
5855
6854
  } catch (err) {
5856
6855
  notify(onError, err, "onError");
5857
- for (const c of chunks) origWrite(c);
5858
- return origEnd(cb);
6856
+ return passThrough();
6857
+ }
6858
+ if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
6859
+ if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
6860
+ if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
6861
+ if (reallySent() && res.chunkedEncoding !== true) {
6862
+ notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
6863
+ res.destroy?.();
6864
+ return res;
5859
6865
  }
5860
6866
  if (r.verdict === "block") {
5861
- res.statusCode = 500;
5862
- try {
5863
- res.setHeader("content-type", "application/json");
5864
- } catch {
6867
+ if (reallySent()) {
6868
+ res.destroy?.();
6869
+ return res;
5865
6870
  }
5866
- return origEnd(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), cb);
6871
+ return sendWithheld(cb);
5867
6872
  }
5868
- if (r.verdict === "redact") {
5869
- try {
5870
- res.removeHeader && res.removeHeader("content-length");
5871
- } catch {
6873
+ const changed = /* @__PURE__ */ new Map();
6874
+ if (r.headers) {
6875
+ for (const [name, value] of Object.entries(r.headers)) {
6876
+ if (name.toLowerCase() === "content-length") continue;
6877
+ if (!headerValueChanged(head.headers[name], value)) continue;
6878
+ changed.set(name.toLowerCase(), value);
5872
6879
  }
5873
- if (r.headers && res.setHeader) {
5874
- const current = res.getHeaders ? res.getHeaders() : {};
5875
- for (const [name, value] of Object.entries(r.headers)) {
5876
- if (name.toLowerCase() === "content-length") continue;
5877
- if (value === null || value === void 0) {
5878
- try {
5879
- res.removeHeader && res.removeHeader(name);
5880
- } catch {
5881
- }
5882
- } else if (Array.isArray(value)) {
5883
- try {
5884
- res.setHeader(name, value);
5885
- } catch {
5886
- }
5887
- } else if (typeof value === "string" && current[name] !== value) {
5888
- try {
5889
- res.setHeader(name, value);
5890
- } catch {
5891
- }
5892
- }
5893
- }
6880
+ }
6881
+ if (!reallySent()) {
6882
+ const declared = head.headers["content-length"];
6883
+ if (declared === void 0) {
6884
+ changed.set("content-length", null);
6885
+ } else {
6886
+ changed.set("content-length", String(Buffer.byteLength(r.body)));
6887
+ changed.set("transfer-encoding", null);
5894
6888
  }
5895
- return origEnd(r.body, cb);
6889
+ sendHead({ headers: changed });
6890
+ } else if (changed.size) {
6891
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
5896
6892
  }
5897
- for (const c of chunks) origWrite(c);
5898
- return origEnd(cb);
6893
+ return origEnd(r.body, cb);
5899
6894
  };
5900
6895
  };
5901
6896
  const allow = new Set(allowHosts.map((h) => String(h).toLowerCase()));
@@ -5969,21 +6964,15 @@ async function createProtection(options = {}) {
5969
6964
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
5970
6965
  // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
5971
6966
  // upstream response. It resolves once here, which is the only resolution for this call.
5972
- screenResponse: (response, request) => screenResp(
5973
- response,
5974
- request ? reqContextFromFetch(
5975
- request,
5976
- resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
5977
- ) : void 0
5978
- ),
6967
+ screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
5979
6968
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
5980
6969
  fetchGuard() {
5981
- return async (request) => (await screenFetchRequest(request)).blocked;
6970
+ return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
5982
6971
  },
5983
6972
  // Wrap a fetch handler: screens the request, then the response (redact/block).
5984
6973
  fetch(handler) {
5985
6974
  return async (request, ...rest) => {
5986
- const { blocked, client } = await screenFetchRequest(request);
6975
+ const { blocked, client } = await screenFetchRequest(request, rest);
5987
6976
  if (blocked) return blocked;
5988
6977
  const response = await handler(request, ...rest);
5989
6978
  return screenResp(response, reqContextFromFetch(request, client));
@@ -5997,6 +6986,7 @@ async function createProtection(options = {}) {
5997
6986
  const { shaped, client } = shapeExpressRequest(req);
5998
6987
  try {
5999
6988
  result = engine.evaluate(shaped);
6989
+ recordEvaluationSkips(result);
6000
6990
  } catch (err) {
6001
6991
  notify(onError, err, "onError");
6002
6992
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
@@ -6031,33 +7021,25 @@ async function createProtection(options = {}) {
6031
7021
  screenNodeRequest(req, res, next, "", req.body);
6032
7022
  return;
6033
7023
  }
6034
- const chunks = [];
6035
- let size = 0;
6036
- let overflow = false;
6037
- req.on("data", (chunk) => {
6038
- size += chunk.length;
6039
- if (size > maxBytes) {
6040
- overflow = true;
7024
+ readBodyPrefix(req, maxBytes, (err, read) => {
7025
+ if (err) {
7026
+ notify(onError, err, "onError");
7027
+ next();
6041
7028
  return;
6042
7029
  }
6043
- chunks.push(chunk);
6044
- });
6045
- req.on("error", (err) => {
6046
- notify(onError, err, "onError");
6047
- next();
6048
- });
6049
- req.on("end", () => {
6050
- if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
6051
- screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
7030
+ if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
7031
+ if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
7032
+ screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
6052
7033
  });
6053
7034
  };
6054
- function screenNodeRequest(req, res, next, rawBody, parsedBody) {
7035
+ function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
6055
7036
  let shaped;
6056
7037
  let result;
6057
7038
  try {
6058
7039
  shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
6059
7040
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
6060
7041
  result = engine.evaluate(shaped);
7042
+ recordEvaluationSkips(result);
6061
7043
  } catch (err) {
6062
7044
  notify(onError, err, "onError");
6063
7045
  return next();
@@ -6076,7 +7058,7 @@ async function createProtection(options = {}) {
6076
7058
  }
6077
7059
  },
6078
7060
  () => {
6079
- if (req.body === void 0) req.body = shaped.body;
7061
+ if (req.body === void 0 && !truncated) req.body = shaped.body;
6080
7062
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
6081
7063
  next();
6082
7064
  },
@@ -6103,11 +7085,12 @@ async function createProtection(options = {}) {
6103
7085
  let reporter = null;
6104
7086
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
6105
7087
  try {
6106
- ({ reportManifest: reporter } = await import("./refresh-manifest-47HJRCDX.js"));
7088
+ ({ reportManifest: reporter } = await import("./refresh-manifest-DZQOEO3Q.js"));
6107
7089
  } catch (err) {
6108
7090
  notify(onError, err, "onError");
6109
7091
  }
6110
7092
  }
7093
+ let recovery = null;
6111
7094
  const runRefreshTick = async () => {
6112
7095
  if (reporter) {
6113
7096
  try {
@@ -6124,23 +7107,38 @@ async function createProtection(options = {}) {
6124
7107
  });
6125
7108
  mode = resolveMode(options, next);
6126
7109
  applyBundle(next);
7110
+ noteRuleSource(next.source);
7111
+ if (next.source?.ok !== false) recovery?.stop();
6127
7112
  await applyAndAcknowledge(next.source?.origin, declaredState);
6128
7113
  if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
6129
7114
  return next.source ?? { ok: true };
6130
7115
  };
7116
+ const refreshTick = serialise(runRefreshTick);
6131
7117
  if (live) {
6132
- protection.refresh = () => runRefreshTick();
6133
- protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
7118
+ protection.refresh = () => refreshTick();
7119
+ protection.refreshHandler = () => makeRefreshHandler(refreshTick, refreshSecret);
6134
7120
  }
6135
- const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
7121
+ const loop = options.refreshMs > 0 && live ? startRefresh(refreshTick, { refreshMs: options.refreshMs, onError }) : null;
7122
+ const canAsk = Boolean(options.token || pulseAuth);
7123
+ recovery = live && canAsk && !loop && ruleSource.ok === false ? startRecovery(refreshTick, { onError }) : null;
6136
7124
  protection.stop = () => {
6137
7125
  loop?.stop();
7126
+ recovery?.stop();
7127
+ protection.uninstallEgress?.();
6138
7128
  const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
6139
7129
  (wait) => wait && typeof wait.then === "function"
6140
7130
  );
6141
7131
  return Promise.all(outstanding).then(() => void 0);
6142
7132
  };
6143
- protection.stopRefresh = protection.stop;
7133
+ protection.stopRefresh = () => {
7134
+ loop?.stop();
7135
+ recovery?.stop();
7136
+ return Promise.resolve();
7137
+ };
7138
+ Object.defineProperty(protection, "ruleSource", {
7139
+ get: () => ({ ...ruleSource }),
7140
+ enumerable: true
7141
+ });
6144
7142
  Object.defineProperty(protection, "detectionReporting", {
6145
7143
  get: () => detectionReporting,
6146
7144
  enumerable: true
@@ -6149,8 +7147,22 @@ async function createProtection(options = {}) {
6149
7147
  get: () => detections ? () => detections.health() : void 0,
6150
7148
  enumerable: true
6151
7149
  });
7150
+ Object.defineProperty(protection, "blockLogHealth", {
7151
+ get: () => firewallLog ? () => firewallLog.health() : void 0,
7152
+ enumerable: true
7153
+ });
6152
7154
  return protection;
6153
7155
  }
7156
+ function accessorOf(obj, name) {
7157
+ for (let o = obj; o; o = Object.getPrototypeOf(o)) {
7158
+ const d = Object.getOwnPropertyDescriptor(o, name);
7159
+ if (d) return typeof d.get === "function" ? d.get : null;
7160
+ }
7161
+ return null;
7162
+ }
7163
+ function headersSentError(verb) {
7164
+ return Object.assign(new Error(`Cannot ${verb} headers after they are sent to the client`), { code: "ERR_HTTP_HEADERS_SENT" });
7165
+ }
6154
7166
  function resolveMode(options, bundle) {
6155
7167
  const env = typeof process !== "undefined" ? process.env?.PATCHSTACK_MODE : void 0;
6156
7168
  if (env === "block" || env === "dry-run") return env;
@@ -6213,6 +7225,14 @@ function screenableContentType(ct) {
6213
7225
  if (base === "application/octet-stream") return "sniff";
6214
7226
  return "skip";
6215
7227
  }
7228
+ function prefilterText(body, headers) {
7229
+ const values = [];
7230
+ for (const value of Object.values(headers ?? {})) {
7231
+ if (Array.isArray(value)) values.push(...value.map(String));
7232
+ else if (value !== void 0 && value !== null) values.push(String(value));
7233
+ }
7234
+ return [body, ...values].join("\n").toLowerCase();
7235
+ }
6216
7236
  function looksBinary(bytes) {
6217
7237
  const n = Math.min(bytes.length, 512);
6218
7238
  let ctrl = 0;
@@ -6223,11 +7243,72 @@ function looksBinary(bytes) {
6223
7243
  }
6224
7244
  return n > 0 && ctrl / n > 0.1;
6225
7245
  }
7246
+ function declaredCodings(value) {
7247
+ const raw = Array.isArray(value) ? value.join(",") : String(value ?? "");
7248
+ return raw.toLowerCase().split(",").map((c) => c.trim()).filter((c) => c !== "" && c !== "identity");
7249
+ }
7250
+ function stillEncoded(bytes) {
7251
+ if (looksBinary(bytes)) return true;
7252
+ try {
7253
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
7254
+ return false;
7255
+ } catch {
7256
+ return true;
7257
+ }
7258
+ }
7259
+ var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
7260
+ var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
7261
+ function declaredCharset(ct) {
7262
+ const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
7263
+ const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
7264
+ return label || null;
7265
+ }
7266
+ function charsetReading(ct, bytes) {
7267
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
7268
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
7269
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
7270
+ const charset = declaredCharset(ct);
7271
+ if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
7272
+ if (WIDE_CHARSET.test(charset)) return { skip: charset };
7273
+ for (let i = 0; i < bytes.length; i++) {
7274
+ const b = bytes[i];
7275
+ if (b >= 128 || b === 0 || b === 27) return { skip: charset };
7276
+ }
7277
+ return { ascii: true };
7278
+ }
7279
+ function decodeForScreening(ct, bytes) {
7280
+ const reading = charsetReading(ct, bytes);
7281
+ if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
7282
+ return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
7283
+ }
7284
+ function withBom(bom, body) {
7285
+ return bom ? "\uFEFF" + body : body;
7286
+ }
7287
+ function outgrowsCharset(ascii, body) {
7288
+ return ascii === true && /[^\x00-\x7f]/.test(body);
7289
+ }
7290
+ var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
7291
+ function readJsonEscapes(text) {
7292
+ if (!text.includes("\\u") && !text.includes("\\/")) return text;
7293
+ try {
7294
+ JSON.parse(text);
7295
+ } catch {
7296
+ return text;
7297
+ }
7298
+ return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
7299
+ if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
7300
+ const code = parseInt(hex, 16);
7301
+ if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
7302
+ return String.fromCharCode(code);
7303
+ });
7304
+ }
6226
7305
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6227
7306
  if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
6228
7307
  const ct = response.headers?.get?.("content-type") || "";
6229
7308
  const kind = screenableContentType(ct);
6230
7309
  if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
7310
+ const codings = declaredCodings(response.headers?.get?.("content-encoding"));
7311
+ const encodedSkip = () => ({ skip: "encoded-body", encoding: codings.join(", ") });
6231
7312
  const sniff = kind === "sniff";
6232
7313
  const len = Number(response.headers?.get?.("content-length") || 0);
6233
7314
  if (len && len > cap) return { skip: "body-cap" };
@@ -6271,13 +7352,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6271
7352
  } finally {
6272
7353
  reader.releaseLock();
6273
7354
  }
7355
+ const bytes = concatBytes(chunks, size);
7356
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
6274
7357
  try {
6275
- return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
7358
+ return decodeForScreening(ct, bytes);
6276
7359
  } catch {
6277
7360
  return { skip: "decode-failed" };
6278
7361
  }
6279
7362
  }
6280
7363
  try {
7364
+ if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
7365
+ const bytes = new Uint8Array(await clone.arrayBuffer());
7366
+ if (bytes.byteLength > cap) return { skip: "body-cap" };
7367
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
7368
+ if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
7369
+ return decodeForScreening(ct, bytes);
7370
+ }
6281
7371
  const text = await clone.text();
6282
7372
  if (text.length > cap) return { skip: "body-cap" };
6283
7373
  if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
@@ -6307,8 +7397,9 @@ function headerObject(headers) {
6307
7397
  function hasSpanMutations(rule) {
6308
7398
  let found = false;
6309
7399
  const walk = (conds) => {
6310
- for (const c of conds ?? []) {
7400
+ for (const c of Array.isArray(conds) ? conds : []) {
6311
7401
  if (found) return;
7402
+ if (!c || typeof c !== "object") continue;
6312
7403
  if (Array.isArray(c.rules)) walk(c.rules);
6313
7404
  const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
6314
7405
  if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
@@ -6317,10 +7408,34 @@ function hasSpanMutations(rule) {
6317
7408
  walk(rule.rule_v2);
6318
7409
  return found;
6319
7410
  }
6320
- function extractRedactors(rule) {
7411
+ function redactorScope(parameter) {
7412
+ if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
7413
+ const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
7414
+ for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
7415
+ if (typeof name !== "string" || name === "") return null;
7416
+ if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
7417
+ else if (name === "response.headers") scope.allHeaders = true;
7418
+ else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
7419
+ scope.body = true;
7420
+ scope.allHeaders = true;
7421
+ } else return null;
7422
+ }
7423
+ return scope;
7424
+ }
7425
+ var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
7426
+ function masksHeader(redactor, name) {
7427
+ return redactor.scope.allHeaders || redactor.scope.headers.has(name);
7428
+ }
7429
+ function extractRedactors(rule, onUnscoped) {
6321
7430
  const out = [];
7431
+ const scoped = (c) => {
7432
+ const scope = redactorScope(c.parameter);
7433
+ if (scope === null) onUnscoped?.(c.parameter);
7434
+ return scope;
7435
+ };
6322
7436
  const walk = (conds) => {
6323
- for (const c of conds ?? []) {
7437
+ for (const c of Array.isArray(conds) ? conds : []) {
7438
+ if (!c || typeof c !== "object") continue;
6324
7439
  if (Array.isArray(c.rules)) walk(c.rules);
6325
7440
  const m = c.match;
6326
7441
  if (!m) continue;
@@ -6328,16 +7443,21 @@ function extractRedactors(rule) {
6328
7443
  const safe = safeRegExp(m.value);
6329
7444
  if (safe) {
6330
7445
  const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
7446
+ const scope = scoped(c);
6331
7447
  try {
6332
- out.push({ re: new RegExp(safe.source, flags) });
7448
+ if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
6333
7449
  } catch {
6334
7450
  }
6335
7451
  }
6336
7452
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
6337
- out.push({ literal: String(m.value) });
7453
+ const scope = scoped(c);
7454
+ if (scope) out.push({ literal: String(m.value), scope });
6338
7455
  } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
6339
- out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
6340
- } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
7456
+ const scope = scoped(c);
7457
+ if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
7458
+ } else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
7459
+ onUnscoped?.(c.parameter);
7460
+ } else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
6341
7461
  const keys = Array.isArray(m.key) ? m.key : [m.key];
6342
7462
  for (const key of keys) {
6343
7463
  out.push({ jsonPath: String(key).split("."), condition: m.match });
@@ -6351,6 +7471,110 @@ function extractRedactors(rule) {
6351
7471
  function htmlEscape(str) {
6352
7472
  return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
6353
7473
  }
7474
+ function isJson(text) {
7475
+ try {
7476
+ JSON.parse(text);
7477
+ return true;
7478
+ } catch {
7479
+ return false;
7480
+ }
7481
+ }
7482
+ var JSON_STRING = 1;
7483
+ var JSON_NUMBER = 2;
7484
+ var JSON_LITERAL = 3;
7485
+ var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
7486
+ function isHexCode(c) {
7487
+ return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
7488
+ }
7489
+ var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
7490
+ function lexJson(text) {
7491
+ let kinds = new Int32Array(1024);
7492
+ let spans = new Int32Array(2048);
7493
+ let count = 0;
7494
+ const n = text.length;
7495
+ let i = 0;
7496
+ while (i < n) {
7497
+ const c = text.charCodeAt(i);
7498
+ if (c === 32 || c === 9 || c === 10 || c === 13) {
7499
+ i++;
7500
+ continue;
7501
+ }
7502
+ const start = i;
7503
+ let kind;
7504
+ if (c === 34) {
7505
+ i++;
7506
+ const close = text.indexOf('"', i);
7507
+ if (close === -1) return null;
7508
+ if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
7509
+ i = close;
7510
+ } else {
7511
+ for (; ; ) {
7512
+ if (i >= n) return null;
7513
+ const d = text.charCodeAt(i);
7514
+ if (d === 34) break;
7515
+ if (d < 32) return null;
7516
+ if (d !== 92) {
7517
+ i++;
7518
+ continue;
7519
+ }
7520
+ const e = text.charCodeAt(i + 1);
7521
+ if (e === 117) {
7522
+ for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
7523
+ i += 6;
7524
+ } else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
7525
+ i += 2;
7526
+ } else {
7527
+ return null;
7528
+ }
7529
+ }
7530
+ }
7531
+ i++;
7532
+ kind = JSON_STRING;
7533
+ } else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
7534
+ i++;
7535
+ kind = c;
7536
+ } else if (text.startsWith("true", i) || text.startsWith("null", i)) {
7537
+ i += 4;
7538
+ kind = JSON_LITERAL;
7539
+ } else if (text.startsWith("false", i)) {
7540
+ i += 5;
7541
+ kind = JSON_LITERAL;
7542
+ } else {
7543
+ JSON_NUMBER_TOKEN.lastIndex = i;
7544
+ if (!JSON_NUMBER_TOKEN.test(text)) return null;
7545
+ i = JSON_NUMBER_TOKEN.lastIndex;
7546
+ kind = JSON_NUMBER;
7547
+ }
7548
+ if (count === kinds.length) {
7549
+ const grownKinds = new Int32Array(count * 2);
7550
+ grownKinds.set(kinds);
7551
+ kinds = grownKinds;
7552
+ const grownSpans = new Int32Array(count * 4);
7553
+ grownSpans.set(spans);
7554
+ spans = grownSpans;
7555
+ }
7556
+ kinds[count] = kind;
7557
+ spans[2 * count] = start;
7558
+ spans[2 * count + 1] = i;
7559
+ count++;
7560
+ }
7561
+ return { kinds, spans, count };
7562
+ }
7563
+ function sameJsonStructure(beforeText, before, afterText, after) {
7564
+ if (!before || !after || before.count !== after.count) return false;
7565
+ const { kinds, spans, count } = before;
7566
+ for (let k = 0; k < count; k++) {
7567
+ const kind = kinds[k];
7568
+ if (after.kinds[k] !== kind) return false;
7569
+ if (kind > JSON_LITERAL) continue;
7570
+ if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
7571
+ const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
7572
+ const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
7573
+ if (was === now) continue;
7574
+ if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
7575
+ }
7576
+ return true;
7577
+ }
6354
7578
  function applyRedactors(body, redactors, mask, transform) {
6355
7579
  let out = body;
6356
7580
  for (const r of redactors) {
@@ -6366,6 +7590,11 @@ function applyRedactors(body, redactors, mask, transform) {
6366
7590
  }
6367
7591
  return out;
6368
7592
  }
7593
+ function readsBody(parameter) {
7594
+ if (!Array.isArray(parameter)) return isBodyParam(parameter);
7595
+ const names = parameter.every((member) => typeof member === "string" && member !== "");
7596
+ return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
7597
+ }
6369
7598
  function isBodyParam(parameter) {
6370
7599
  return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
6371
7600
  }
@@ -6393,10 +7622,12 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
6393
7622
  if (!pathRedactors.length || typeof text !== "string" || text.length > cap) return text;
6394
7623
  const head = text.trimStart()[0];
6395
7624
  if (head !== "{" && head !== "[") return text;
6396
- const preserved = preserveBigInts(text);
7625
+ let preserved;
6397
7626
  let obj;
6398
7627
  try {
6399
- obj = JSON.parse(preserved);
7628
+ JSON.parse(text);
7629
+ preserved = preserveNumbers(text, mask);
7630
+ obj = JSON.parse(preserved.text);
6400
7631
  } catch {
6401
7632
  return text;
6402
7633
  }
@@ -6405,65 +7636,44 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
6405
7636
  const pred = conditionPredicate(r.condition);
6406
7637
  walkLeaves(obj, r.jsonPath, (loc) => {
6407
7638
  try {
6408
- if (pred(loc.value)) {
6409
- loc.parent[loc.key] = transform ? transform(String(loc.value)) : mask;
7639
+ const number = preserved.numbers.get(loc.value);
7640
+ if (pred(number === void 0 ? loc.value : Number(number))) {
7641
+ setOwn(loc.parent, loc.key, transform ? transform(number ?? String(loc.value)) : mask);
6410
7642
  changed = true;
6411
7643
  }
6412
7644
  } catch {
6413
7645
  }
6414
7646
  });
6415
7647
  }
6416
- return changed ? restoreBigInts(JSON.stringify(obj)) : text;
6417
- }
6418
- var BIGINT_OPEN = "__PSBIGINT_9c2f__";
6419
- var BIGINT_CLOSE = "__DNEGIB__";
6420
- function preserveBigInts(text) {
6421
- let out = "";
6422
- let inStr = false;
6423
- for (let i = 0; i < text.length; ) {
6424
- const ch = text[i];
6425
- if (inStr) {
6426
- out += ch;
6427
- if (ch === "\\") {
6428
- out += text[i + 1] ?? "";
6429
- i += 2;
6430
- continue;
6431
- }
6432
- if (ch === '"') inStr = false;
6433
- i++;
6434
- continue;
6435
- }
6436
- if (ch === '"') {
6437
- inStr = true;
6438
- out += ch;
6439
- i++;
6440
- continue;
6441
- }
6442
- if (ch === "-" || ch >= "0" && ch <= "9") {
6443
- let j = ch === "-" ? i + 1 : i;
6444
- let digits = 0;
6445
- while (j < text.length && text[j] >= "0" && text[j] <= "9") {
6446
- digits++;
6447
- j++;
6448
- }
6449
- const next = text[j];
6450
- const isIntToken = digits > 0 && next !== "." && next !== "e" && next !== "E";
6451
- if (isIntToken && digits >= 16) {
6452
- out += `"${BIGINT_OPEN}${text.slice(i, j)}${BIGINT_CLOSE}"`;
6453
- } else {
6454
- out += text.slice(i, j || i + 1);
6455
- }
6456
- i = j > i ? j : i + 1;
6457
- continue;
6458
- }
6459
- out += ch;
6460
- i++;
6461
- }
6462
- return out;
7648
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
6463
7649
  }
6464
- function restoreBigInts(text) {
6465
- if (!text.includes(BIGINT_OPEN)) return text;
6466
- return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
7650
+ function preserveNumbers(text, mask) {
7651
+ const occupied = /* @__PURE__ */ new Set([mask]);
7652
+ const found = [];
7653
+ const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
7654
+ for (const match of text.matchAll(tokens)) {
7655
+ const token = match[0];
7656
+ if (token[0] === '"') occupied.add(JSON.parse(token));
7657
+ else found.push({ start: match.index, token });
7658
+ }
7659
+ const numbers = /* @__PURE__ */ new Map();
7660
+ const chunks = [];
7661
+ let offset = 0;
7662
+ let index = 0;
7663
+ for (const { start, token } of found) {
7664
+ let marker;
7665
+ do {
7666
+ marker = "__PSNUMBER_" + index++ + "__";
7667
+ } while (occupied.has(marker));
7668
+ numbers.set(marker, token);
7669
+ chunks.push(text.slice(offset, start), JSON.stringify(marker));
7670
+ offset = start + token.length;
7671
+ }
7672
+ chunks.push(text.slice(offset));
7673
+ return { text: chunks.join(""), numbers };
7674
+ }
7675
+ function restoreNumbers(text, numbers) {
7676
+ return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
6467
7677
  }
6468
7678
  function isHeaderMutation(action) {
6469
7679
  return action === "set-header" || action === "remove-header" || action === "harden-cookie";