@patchstack/connect 0.5.15 → 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();
@@ -761,329 +1167,51 @@ var RequestResolver = class {
761
1167
  this.#cookies = this.#req.cookies;
762
1168
  return this.#cookies;
763
1169
  }
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;
1170
+ this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
780
1171
  return this.#cookies;
781
1172
  }
782
1173
  };
783
1174
 
784
1175
  // src/protect/notify.js
785
- var reported = /* @__PURE__ */ new Set();
1176
+ var reported = /* @__PURE__ */ new WeakMap();
1177
+ var warnings = 0;
1178
+ var MAX_WARNINGS = 20;
786
1179
  function notify(fn, arg, label) {
787
1180
  if (typeof fn !== "function") return false;
788
1181
  try {
789
1182
  const result = fn(arg);
790
1183
  if (result !== null && typeof result === "object" && typeof result.then === "function") {
791
1184
  try {
792
- result.then(void 0, (err) => warnOnce(label, err));
1185
+ result.then(void 0, (err) => warnOnce(fn, label, err));
793
1186
  } catch {
794
- }
795
- }
796
- return true;
797
- } catch (err) {
798
- warnOnce(label, err);
799
- return false;
800
- }
801
- }
802
- function warnOnce(label, err) {
803
- if (reported.has(label)) return;
804
- reported.add(label);
805
- try {
806
- console.warn(
807
- `Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
808
- );
809
- } catch {
810
- }
811
- }
812
-
813
- // src/protect/engine/normalizer.js
814
- var HTML_ENTITIES = {
815
- "&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;
967
- }
968
- const value = task.value;
969
- if (value === null || value === void 0) {
970
- append(task.root ? "" : "null");
971
- continue;
972
- }
973
- if (typeof value === "string") {
974
- append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
975
- continue;
976
- }
977
- if (typeof value !== "object") {
978
- append(String(value).slice(0, MAX_RAW_CHARS));
979
- continue;
980
- }
981
- if (visited.has(value)) {
982
- append("[Circular]");
983
- continue;
984
- }
985
- if (task.depth >= MAX_RAW_DEPTH) {
986
- append('"[DepthLimit]"');
987
- continue;
988
- }
989
- visited.add(value);
990
- if (Array.isArray(value)) {
991
- const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
992
- const items = [];
993
- for (let i = 0; i < length2; i++) {
994
- if (i > 0) items.push({ kind: "text", text: "," });
995
- items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
996
- }
997
- if (value.length > length2) {
998
- if (length2 > 0) items.push({ kind: "text", text: "," });
999
- items.push({ kind: "text", text: '"[EntryLimit]"' });
1000
- }
1001
- stack.push({ kind: "text", text: "]" });
1002
- for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
1003
- stack.push({ kind: "text", text: "[" });
1004
- continue;
1005
- }
1006
- let keys;
1007
- try {
1008
- keys = Object.getOwnPropertyNames(value);
1009
- } catch {
1010
- append('"[Unreadable]"');
1011
- continue;
1012
- }
1013
- const length = Math.min(keys.length, MAX_RAW_ENTRIES);
1014
- const entries = [];
1015
- for (let i = 0; i < length; i++) {
1016
- const key = keys[i];
1017
- if (i > 0) entries.push({ kind: "text", text: "," });
1018
- entries.push({ kind: "key", value: key });
1019
- entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
1020
- }
1021
- if (keys.length > length) {
1022
- if (length > 0) entries.push({ kind: "text", text: "," });
1023
- entries.push({ kind: "text", text: '"[EntryLimit]":null' });
1024
- }
1025
- stack.push({ kind: "text", text: "}" });
1026
- for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
1027
- stack.push({ kind: "text", text: "{" });
1028
- }
1029
- return output.join("");
1030
- }
1031
- function boundedJsonString(value) {
1032
- return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
1033
- }
1034
- function readOwnValue(value, key) {
1035
- try {
1036
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
1037
- return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
1038
- } catch {
1039
- return "[Unreadable]";
1040
- }
1041
- }
1042
- var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
1043
- function requestField(req, key) {
1044
- if (req === null || typeof req !== "object") return void 0;
1045
- if (Object.hasOwn(req, key)) return req[key];
1046
- if (!INHERITED_ACCESSORS.has(key)) return void 0;
1047
- let holder = Object.getPrototypeOf(req);
1048
- while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
1049
- if (holder === null || holder === Object.prototype) return void 0;
1050
- const descriptor = Object.getOwnPropertyDescriptor(holder, key);
1051
- if (typeof descriptor?.get !== "function") return void 0;
1052
- return req[key];
1053
- }
1054
- function normalizeRequest(req, options = {}) {
1055
- const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
1056
- const body = requestField(req, "body");
1057
- const url = requestField(req, "url");
1058
- const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
1059
- return {
1060
- query: normalizeObject(requestField(req, "query") || {}, options),
1061
- body: normalizeObject(body || {}, options),
1062
- headers: normalizeObject(requestField(req, "headers") || {}, options),
1063
- url: normalize(url || "", options),
1064
- originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
1065
- _rawBody: rawBody
1066
- };
1067
- }
1068
- var MAX_NORMALIZE_DEPTH = 200;
1069
- function normalizeObject(value, options = {}, depth = 0) {
1070
- if (typeof value === "string") {
1071
- return normalize(value, options);
1072
- }
1073
- if (depth >= MAX_NORMALIZE_DEPTH) {
1074
- return value;
1187
+ }
1188
+ }
1189
+ return true;
1190
+ } catch (err) {
1191
+ warnOnce(fn, label, err);
1192
+ return false;
1075
1193
  }
1076
- if (Array.isArray(value)) {
1077
- return value.map((item) => normalizeObject(item, options, depth + 1));
1194
+ }
1195
+ function warnOnce(fn, label, err) {
1196
+ let labels = reported.get(fn);
1197
+ if (labels?.has(label)) return;
1198
+ if (!labels) {
1199
+ labels = /* @__PURE__ */ new Set();
1200
+ reported.set(fn, labels);
1078
1201
  }
1079
- 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
@@ -1471,17 +1599,21 @@ function arrayKeyValue(value, matchObj) {
1471
1599
  }
1472
1600
  var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
1473
1601
  function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
1474
- const out = [];
1602
+ const leaves = [];
1475
1603
  const stack = [[root, 0]];
1476
1604
  let visited = 0;
1605
+ let truncated = false;
1477
1606
  while (stack.length) {
1478
1607
  const [node, depth] = stack.pop();
1479
1608
  if (node === null || node === void 0) continue;
1480
1609
  if (typeof node !== "object") {
1481
- out.push(node);
1610
+ leaves.push(node);
1611
+ continue;
1612
+ }
1613
+ if (depth >= maxDepth || visited >= nodeCap) {
1614
+ truncated = true;
1482
1615
  continue;
1483
1616
  }
1484
- if (depth >= maxDepth || visited >= nodeCap) continue;
1485
1617
  visited++;
1486
1618
  if (Array.isArray(node)) {
1487
1619
  for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
@@ -1489,7 +1621,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
1489
1621
  for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
1490
1622
  }
1491
1623
  }
1492
- return 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
+ }
1493
1636
  }
1494
1637
  var warnedKeys = /* @__PURE__ */ new Set();
1495
1638
  function warnOnce2(key, message) {
@@ -1507,12 +1650,22 @@ function warnUnsupportedMatchType(type) {
1507
1650
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
1508
1651
  );
1509
1652
  }
1653
+ var SPECIAL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ws", "wss", "ftp", "file"]);
1654
+ function trimControls(text) {
1655
+ let start = 0;
1656
+ let end = text.length;
1657
+ while (start < end && text.charCodeAt(start) <= 32) start++;
1658
+ while (end > start && text.charCodeAt(end - 1) <= 32) end--;
1659
+ return text.slice(start, end);
1660
+ }
1510
1661
  function hostFromValue(value) {
1511
- const raw = String(value ?? "").trim();
1662
+ const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
1512
1663
  if (raw === "") return "";
1513
- 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)) {
1514
1667
  try {
1515
- return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
1668
+ return new URL(relative ? `http:${raw}` : raw).hostname;
1516
1669
  } catch {
1517
1670
  return raw;
1518
1671
  }
@@ -1540,10 +1693,18 @@ function isInternalHost(hostname) {
1540
1693
  const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
1541
1694
  if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
1542
1695
  if ((g[0] & 65472) === 65152) return true;
1696
+ if ((g[0] & 65472) === 65216) return true;
1543
1697
  if ((g[0] & 65024) === 64512) return true;
1698
+ if ((g[0] & 65280) === 65280) return true;
1699
+ const low32 = (g[6] << 16 >>> 0 | g[7]) >>> 0;
1544
1700
  if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
1545
- return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
1701
+ return isPrivateV4Int(low32);
1702
+ }
1703
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
1704
+ return isPrivateV4Int(low32);
1546
1705
  }
1706
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 1) return true;
1707
+ if (g[0] === 8194) return isPrivateV4Int((g[1] << 16 >>> 0 | g[2]) >>> 0);
1547
1708
  return false;
1548
1709
  }
1549
1710
  const v4 = parseIPv4ToInt(host);
@@ -1553,6 +1714,10 @@ function isInternalHost(hostname) {
1553
1714
  function isPrivateV4Int(n) {
1554
1715
  const a = n >>> 24 & 255;
1555
1716
  const b = n >>> 16 & 255;
1717
+ const c = n >>> 8 & 255;
1718
+ if (a >= 224) return true;
1719
+ if (a === 192 && b === 0 && c === 0) return true;
1720
+ if (a === 198 && (b === 18 || b === 19)) return true;
1556
1721
  if (a === 127 || a === 10 || a === 0) return true;
1557
1722
  if (a === 169 && b === 254) return true;
1558
1723
  if (a === 172 && b >= 16 && b <= 31) return true;
@@ -1901,8 +2066,12 @@ var RuleEngine = class {
1901
2066
  let normalizedReq;
1902
2067
  let resolver;
1903
2068
  try {
1904
- normalizedReq = { ...req, ...normalizeRequest(req) };
2069
+ let limited = false;
2070
+ normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
2071
+ limited = true;
2072
+ } }) };
1905
2073
  resolver = new RequestResolver(normalizedReq);
2074
+ if (limited) resolver.noteSkip("container-cap");
1906
2075
  } catch (err) {
1907
2076
  this.#reportError(err);
1908
2077
  return { blocked: false, rule: null, message: null };
@@ -1928,14 +2097,15 @@ var RuleEngine = class {
1928
2097
  // that re-read the request instead would be reading it a second time: a getter, a stream or
1929
2098
  // anything else that answers once can give a different value, and evidence that disagrees
1930
2099
  // with the match it belongs to is worse than none.
1931
- resolver
2100
+ resolver,
2101
+ ...skipsOf(resolver)
1932
2102
  };
1933
2103
  }
1934
2104
  } catch (err) {
1935
2105
  this.#reportError(err);
1936
2106
  }
1937
2107
  }
1938
- return { blocked: false, rule: null, message: null };
2108
+ return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
1939
2109
  }
1940
2110
  #evaluateRule(conditions, resolver) {
1941
2111
  const inclusiveConditions = conditions.filter((c) => c.inclusive);
@@ -1991,9 +2161,15 @@ var RuleEngine = class {
1991
2161
  if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
1992
2162
  if (matchValue(match.type, value, match.value, match)) return true;
1993
2163
  } else {
1994
- for (const leaf of collectLeafValues(value)) {
2164
+ const { leaves, truncated } = collectLeafValues(value);
2165
+ for (const leaf of leaves) {
1995
2166
  if (matchValue(match.type, leaf, match.value, match)) return true;
1996
2167
  }
2168
+ if (truncated) {
2169
+ resolver.noteSkip("container-cap");
2170
+ const text = serialisedValue(value);
2171
+ if (text !== void 0 && matchValue(match.type, text, match.value, match)) return true;
2172
+ }
1997
2173
  }
1998
2174
  continue;
1999
2175
  }
@@ -2380,6 +2556,9 @@ function conditionShapeProblem(condition) {
2380
2556
  if (nested && !named) {
2381
2557
  return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2382
2558
  }
2559
+ if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
2560
+ return "inclusive must be true or false";
2561
+ }
2383
2562
  if (named && nested) {
2384
2563
  if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2385
2564
  if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
@@ -3086,7 +3265,7 @@ async function fromFetchRequest(request, options = {}) {
3086
3265
  // that guessed differently would attribute one request to two addresses.
3087
3266
  ip: client.ip ?? "",
3088
3267
  _clientIp: client,
3089
- cookies: parseCookies(headers.cookie),
3268
+ cookies: parseCookieHeader(headers.cookie),
3090
3269
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
3091
3270
  // drops, so prototype-pollution rules on `raw` are robust.
3092
3271
  _rawBody: rawBody,
@@ -3240,22 +3419,53 @@ function decodeExtendedValue(value) {
3240
3419
  return match[2];
3241
3420
  }
3242
3421
  }
3243
- function parseCookies(header) {
3244
- const cookies = {};
3245
- if (!header) {
3246
- return cookies;
3247
- }
3248
- for (const pair of header.split(";")) {
3249
- const idx = pair.indexOf("=");
3250
- if (idx === -1) {
3251
- continue;
3252
- }
3253
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
3254
- }
3255
- return cookies;
3256
- }
3257
3422
 
3258
3423
  // src/protect/engine/node.js
3424
+ function readBodyPrefix(req, maxBytes, done) {
3425
+ const chunks = [];
3426
+ let retained = 0;
3427
+ let size = 0;
3428
+ let failed = false;
3429
+ let finished = false;
3430
+ const finish = (error, read) => {
3431
+ if (finished) return;
3432
+ finished = true;
3433
+ done(error, read);
3434
+ };
3435
+ req.on("data", (chunk) => {
3436
+ if (failed) return;
3437
+ try {
3438
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, req.readableEncoding || "utf8") : chunk;
3439
+ size += bytes.length;
3440
+ const take = Math.min(bytes.length, maxBytes - retained);
3441
+ if (take <= 0) return;
3442
+ chunks.push(take === bytes.length ? bytes : bytes.subarray(0, take));
3443
+ retained += take;
3444
+ } catch {
3445
+ failed = true;
3446
+ chunks.length = 0;
3447
+ }
3448
+ });
3449
+ req.on("error", (error) => finish(error));
3450
+ req.on("end", () => {
3451
+ if (failed) {
3452
+ finish(null, { text: "", overflow: false, size, failed: true });
3453
+ return;
3454
+ }
3455
+ const prefix = Buffer.concat(chunks);
3456
+ const overflow = size > maxBytes;
3457
+ const text = (overflow ? prefix.subarray(0, completeUtf8Length(prefix)) : prefix).toString("utf8");
3458
+ finish(null, { text, overflow, size, failed: false });
3459
+ });
3460
+ }
3461
+ function completeUtf8Length(bytes) {
3462
+ let start = bytes.length - 1;
3463
+ while (start >= 0 && bytes.length - start <= 3 && (bytes[start] & 192) === 128) start--;
3464
+ if (start < 0) return bytes.length;
3465
+ const lead = bytes[start];
3466
+ const needed = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
3467
+ return bytes.length - start < needed ? start : bytes.length;
3468
+ }
3259
3469
  function fromNodeRequest(req, rawBody = "", options = {}) {
3260
3470
  const method = (req.method || "GET").toUpperCase();
3261
3471
  const headers = {};
@@ -3301,29 +3511,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
3301
3511
  headers,
3302
3512
  ip: client.ip ?? "",
3303
3513
  _clientIp: client,
3304
- cookies: parseCookies2(headers.cookie),
3514
+ cookies: parseCookieHeader(headers.cookie),
3305
3515
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
3306
3516
  _rawBody: rawBody
3307
3517
  };
3308
3518
  }
3309
- function parseCookies2(header) {
3310
- const cookies = {};
3311
- if (!header) {
3312
- 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;
3313
3539
  }
3314
- for (const pair of header.split(";")) {
3315
- const idx = pair.indexOf("=");
3316
- if (idx === -1) {
3317
- continue;
3318
- }
3319
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
3540
+ }
3541
+ function anyRefuses(screens, url, host, method) {
3542
+ let refused = false;
3543
+ for (const screen of screens) {
3544
+ if (screen.block(url, host, method)) refused = true;
3320
3545
  }
3321
- return cookies;
3546
+ return refused;
3322
3547
  }
3323
-
3324
- // src/protect/egress.js
3325
3548
  async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
3326
- const restores = [];
3327
3549
  if (typeof shouldBlock !== "function") return () => {
3328
3550
  };
3329
3551
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
@@ -3368,6 +3590,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3368
3590
  resolve(false);
3369
3591
  }
3370
3592
  });
3593
+ const registry = egressRegistry();
3594
+ const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
3595
+ registry.screens.add(own);
3371
3596
  const originalFetch = globalThis.fetch;
3372
3597
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
3373
3598
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
@@ -3440,15 +3665,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3440
3665
  } catch {
3441
3666
  host = null;
3442
3667
  }
3443
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
3444
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
3445
- }
3668
+ const screens = [...registry.screens];
3669
+ if (anyRefuses(screens, u, host, method)) throw refusal(host ?? u);
3670
+ const resolved = await Promise.all(screens.map((each) => each.prescreen(u, host, method)));
3671
+ if (resolved.includes(true)) throw refusal(host ?? u);
3446
3672
  };
3447
3673
  const guarded = async (input, init) => {
3448
3674
  let cur;
3449
3675
  try {
3450
3676
  cur = new Request(input, { ...init || {}, redirect: "manual" });
3451
3677
  } catch {
3678
+ const destination = readableDestination(input, init);
3679
+ if (destination) await screenUrl(destination.url, destination.method);
3680
+ else for (const each of registry.screens) each.skip("unrecognised-request", {});
3452
3681
  return originalFetch(input, init);
3453
3682
  }
3454
3683
  const callerRedirect = init && init.redirect || input && input.redirect || "follow";
@@ -3459,13 +3688,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3459
3688
  const headers = new Headers(cur.headers);
3460
3689
  const signal = cur.signal;
3461
3690
  const replay = captureReplayBody(cur);
3691
+ const { body: _callerBody, ...callerOptions } = init || {};
3462
3692
  let body;
3463
3693
  for (let hop = 0; ; hop++) {
3464
3694
  let resp;
3465
3695
  try {
3466
- resp = await originalFetch(
3467
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3468
- );
3696
+ resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
3469
3697
  } catch (error) {
3470
3698
  replay.cancel();
3471
3699
  throw error;
@@ -3473,7 +3701,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3473
3701
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
3474
3702
  if (!location) {
3475
3703
  replay.cancel();
3476
- return resp;
3704
+ return hop === 0 ? resp : markRedirected(resp);
3477
3705
  }
3478
3706
  await discardResponseBody(resp);
3479
3707
  if (hop >= MAX_REDIRECTS) {
@@ -3506,8 +3734,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3506
3734
  };
3507
3735
  guarded.__patchstackGuarded = true;
3508
3736
  globalThis.fetch = guarded;
3509
- restores.push(() => {
3510
- 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
+ }
3511
3743
  });
3512
3744
  }
3513
3745
  let syncBuiltins = () => {
@@ -3521,12 +3753,16 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3521
3753
  for (const moduleName of ["node:http", "node:https"]) {
3522
3754
  try {
3523
3755
  const mod = await import(moduleName);
3524
- const restore = patchHttpModule(mod.default ?? mod, block, screen, skip);
3525
- if (restore) {
3756
+ if (registry.surfaces.has(moduleName)) continue;
3757
+ const release = patchHttpModule(mod.default ?? mod, registry);
3758
+ if (release) {
3526
3759
  patchedAny = true;
3527
- restores.push(() => {
3528
- restore();
3529
- syncBuiltins();
3760
+ registry.surfaces.set(moduleName, {
3761
+ release() {
3762
+ if (!release()) return false;
3763
+ syncBuiltins();
3764
+ return true;
3765
+ }
3530
3766
  });
3531
3767
  }
3532
3768
  } catch {
@@ -3534,27 +3770,41 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
3534
3770
  }
3535
3771
  if (patchedAny) syncBuiltins();
3536
3772
  return () => {
3537
- 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) {
3538
3776
  try {
3539
- restore();
3777
+ if (surface.release()) registry.surfaces.delete(name);
3540
3778
  } catch {
3541
3779
  }
3542
3780
  }
3543
3781
  };
3544
3782
  }
3545
- 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) {
3546
3791
  if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
3547
3792
  const originalRequest = http.request;
3548
3793
  const originalGet = http.get;
3549
3794
  const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
3550
3795
  const guardArgs = (args) => {
3551
3796
  const target = extractHttpTarget(args);
3552
- if (target && block(target.url, target.host, target.method)) {
3553
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
3554
- }
3555
- 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
+ };
3556
3806
  try {
3557
- return withScreeningLookup(args, target, block, screen.lookup, skip);
3807
+ return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
3558
3808
  } catch {
3559
3809
  }
3560
3810
  }
@@ -3582,10 +3832,14 @@ function patchHttpModule(http, block, screen, skip) {
3582
3832
  }
3583
3833
  http.__patchstackGuarded = true;
3584
3834
  return () => {
3585
- if (http.request === guardedRequest) http.request = originalRequest;
3586
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
3587
- if (GuardedClientRequest && http.ClientRequest === GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
3835
+ if (http.request !== guardedRequest) return false;
3836
+ if (guardedGet && http.get !== guardedGet) return false;
3837
+ if (GuardedClientRequest && http.ClientRequest !== GuardedClientRequest) return false;
3838
+ http.request = originalRequest;
3839
+ if (guardedGet) http.get = originalGet;
3840
+ if (GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
3588
3841
  delete http.__patchstackGuarded;
3842
+ return true;
3589
3843
  };
3590
3844
  }
3591
3845
  function extractHttpTarget(args) {
@@ -4484,7 +4738,7 @@ function makeRefreshHandler(tick, secret) {
4484
4738
  return async (request) => {
4485
4739
  if (!secret) return new Response("not found", { status: 404 });
4486
4740
  const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
4487
- if (provided !== secret) return new Response("forbidden", { status: 403 });
4741
+ if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
4488
4742
  let refreshed = true;
4489
4743
  try {
4490
4744
  const status = await runOnce();
@@ -4495,6 +4749,33 @@ function makeRefreshHandler(tick, secret) {
4495
4749
  return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
4496
4750
  };
4497
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
+ }
4498
4779
 
4499
4780
  // src/protect/detections.js
4500
4781
  var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
@@ -5228,9 +5509,180 @@ function hardensWithoutBody(rule) {
5228
5509
  if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
5229
5510
  const { parameters, complete } = readRuleParameters(rule);
5230
5511
  if (!complete) return false;
5231
- return parameters.every(
5232
- (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)
5233
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)
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
+ };
5234
5686
  }
5235
5687
 
5236
5688
  // src/protect/firewall-log.js
@@ -5279,8 +5731,13 @@ function resolveApiBase(pulseOrManifestUrl) {
5279
5731
  function createFirewallLogReporter(opts) {
5280
5732
  const creds = parseApiKey(opts.apiKey);
5281
5733
  if (!creds) {
5282
- return { record() {
5283
- }, 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
+ };
5284
5741
  }
5285
5742
  const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
5286
5743
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -5293,6 +5750,19 @@ function createFirewallLogReporter(opts) {
5293
5750
  let drainPromise = null;
5294
5751
  let activeController = null;
5295
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
+ };
5296
5766
  let cachedToken = null;
5297
5767
  let tokenInflight = null;
5298
5768
  const fetchAccessToken = async (signal) => {
@@ -5346,10 +5816,16 @@ function createFirewallLogReporter(opts) {
5346
5816
  const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
5347
5817
  attemptTimer?.unref?.();
5348
5818
  let entry;
5819
+ const sent = { size: batch.length, counted: false };
5820
+ activeBatch = sent;
5349
5821
  entry = (async () => {
5350
5822
  try {
5351
5823
  const token = await fetchAccessToken(controller?.signal);
5352
- if (!token || ended) return;
5824
+ if (ended) return;
5825
+ if (!token) {
5826
+ settle(sent, "failed");
5827
+ return;
5828
+ }
5353
5829
  const body = new URLSearchParams();
5354
5830
  body.set("type", "firewall");
5355
5831
  body.set("logs", JSON.stringify(batch));
@@ -5366,10 +5842,11 @@ function createFirewallLogReporter(opts) {
5366
5842
  // Both phases carry the attempt controller, so slow transports cannot accumulate work.
5367
5843
  ...controller ? { signal: controller.signal } : {}
5368
5844
  });
5369
- if (p && typeof p.then === "function") await p.catch(() => {
5370
- });
5845
+ const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
5846
+ settle(sent, res && res.ok ? "delivered" : "failed");
5371
5847
  } catch {
5372
5848
  } finally {
5849
+ settle(sent, "failed");
5373
5850
  clearTimeout(attemptTimer);
5374
5851
  if (activeController === controller) activeController = null;
5375
5852
  if (inFlight === entry) inFlight = null;
@@ -5396,7 +5873,11 @@ function createFirewallLogReporter(opts) {
5396
5873
  if (stopped) return;
5397
5874
  const fid = event?.rule?.id;
5398
5875
  if (fid === void 0 || fid === null || fid === "") return;
5399
- if (queue.length >= MAX_QUEUE2) return;
5876
+ if (queue.length >= MAX_QUEUE2) {
5877
+ dropped++;
5878
+ return;
5879
+ }
5880
+ recorded++;
5400
5881
  queue.push({
5401
5882
  fid,
5402
5883
  method: event.method ?? null,
@@ -5412,6 +5893,10 @@ function createFirewallLogReporter(opts) {
5412
5893
  if (!timer) timer = setTimeout(flush, flushMs);
5413
5894
  },
5414
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
+ },
5415
5900
  /**
5416
5901
  * Stop, and hand back a wait for what was outstanding.
5417
5902
  *
@@ -5430,6 +5915,8 @@ function createFirewallLogReporter(opts) {
5430
5915
  ended = true;
5431
5916
  activeController?.abort();
5432
5917
  activeController = null;
5918
+ if (activeBatch) settle(activeBatch, "dropped");
5919
+ dropped += queue.length;
5433
5920
  queue = [];
5434
5921
  inFlight = null;
5435
5922
  };
@@ -5466,165 +5953,6 @@ function telemetryEnabled() {
5466
5953
  return !/^(0|false|off|no)$/i.test(String(v));
5467
5954
  }
5468
5955
 
5469
- // src/protect/supabase-guard.js
5470
- var GUARD_PATH = "/_patchstack/guard";
5471
- var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5472
- var DEFAULT_TIMEOUT_MS = 3e4;
5473
- var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5474
- "connection",
5475
- "cookie",
5476
- "cookie2",
5477
- "forwarded",
5478
- "host",
5479
- "keep-alive",
5480
- "proxy-authenticate",
5481
- "proxy-authorization",
5482
- "te",
5483
- "trailer",
5484
- "transfer-encoding",
5485
- "upgrade",
5486
- "x-forwarded-for",
5487
- "x-forwarded-host",
5488
- "x-forwarded-port",
5489
- "x-forwarded-proto",
5490
- "x-ps-target",
5491
- "x-real-ip"
5492
- ]);
5493
- var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5494
- "connection",
5495
- "content-encoding",
5496
- "content-length",
5497
- "keep-alive",
5498
- "proxy-authenticate",
5499
- "proxy-authorization",
5500
- "set-cookie",
5501
- "set-cookie2",
5502
- "te",
5503
- "trailer",
5504
- "transfer-encoding",
5505
- "upgrade"
5506
- ]);
5507
- var BodyTooLargeError = class extends Error {
5508
- };
5509
- async function boundedBody(request, maxBytes) {
5510
- const stated = Number(request.headers.get("content-length"));
5511
- if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5512
- if (!request.body) return void 0;
5513
- const reader = request.body.getReader();
5514
- const chunks = [];
5515
- let total = 0;
5516
- try {
5517
- for (; ; ) {
5518
- const { done, value } = await reader.read();
5519
- if (done) break;
5520
- total += value.byteLength;
5521
- if (total > maxBytes) {
5522
- void reader.cancel().catch(() => {
5523
- });
5524
- throw new BodyTooLargeError();
5525
- }
5526
- chunks.push(value);
5527
- }
5528
- } finally {
5529
- reader.releaseLock();
5530
- }
5531
- const body = new Uint8Array(total);
5532
- let offset = 0;
5533
- for (const chunk of chunks) {
5534
- body.set(chunk, offset);
5535
- offset += chunk.byteLength;
5536
- }
5537
- return body;
5538
- }
5539
- function requestHeaders(input) {
5540
- const output = new Headers();
5541
- const connectionTokens = new Set(
5542
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5543
- );
5544
- input.forEach((value, key) => {
5545
- const lower = key.toLowerCase();
5546
- if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5547
- });
5548
- return output;
5549
- }
5550
- function responseHeaders(input) {
5551
- const output = new Headers();
5552
- const connectionTokens = new Set(
5553
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5554
- );
5555
- input.forEach((value, key) => {
5556
- const lower = key.toLowerCase();
5557
- if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5558
- });
5559
- return output;
5560
- }
5561
- function createSupabaseGuard({
5562
- protection,
5563
- supabaseUrl,
5564
- fetchImpl = fetch,
5565
- maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5566
- timeoutMs = DEFAULT_TIMEOUT_MS
5567
- }) {
5568
- const guard = protection.fetchGuard();
5569
- const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5570
- const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5571
- const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
5572
- return async function handleGuardRequest(request) {
5573
- const target = request.headers.get("x-ps-target");
5574
- if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
5575
- let targetUrl;
5576
- try {
5577
- targetUrl = new URL(target);
5578
- } catch {
5579
- return new Response("patchstack: invalid target", { status: 400 });
5580
- }
5581
- if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
5582
- return new Response("patchstack: target not allowed", { status: 403 });
5583
- }
5584
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
5585
- let body;
5586
- try {
5587
- body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5588
- } catch (error) {
5589
- if (error instanceof BodyTooLargeError) {
5590
- return new Response("patchstack: request body too large", { status: 413 });
5591
- }
5592
- throw error;
5593
- }
5594
- const forwardHeaders = requestHeaders(request.headers);
5595
- const evalReq = new Request(targetUrl.toString(), {
5596
- method: request.method,
5597
- // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5598
- // sanitized at the cross-origin boundary below.
5599
- headers: request.headers,
5600
- body
5601
- });
5602
- const blocked = await guard(evalReq);
5603
- if (blocked) return blocked;
5604
- let upstream;
5605
- try {
5606
- upstream = await fetchImpl(targetUrl.toString(), {
5607
- method: request.method,
5608
- headers: forwardHeaders,
5609
- body,
5610
- redirect: "manual",
5611
- signal: AbortSignal.timeout(upstreamTimeout)
5612
- });
5613
- } catch (error) {
5614
- if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5615
- return new Response("patchstack: upstream timed out", { status: 504 });
5616
- }
5617
- throw error;
5618
- }
5619
- const forwarded = new Response(upstream.body, {
5620
- status: upstream.status,
5621
- statusText: upstream.statusText,
5622
- headers: responseHeaders(upstream.headers)
5623
- });
5624
- return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
5625
- };
5626
- }
5627
-
5628
5956
  // src/protect/verify-sentinel.js
5629
5957
  var VERIFY_HEADER = "x-patchstack-verify";
5630
5958
  var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
@@ -5811,20 +6139,47 @@ async function createProtection(options = {}) {
5811
6139
  whitelist_keys: delivered.whitelist_keys,
5812
6140
  onError
5813
6141
  });
5814
- responseRuleSet = responseRules.map((rule) => ({
5815
- rule,
5816
- engine: new RuleEngine({ firewall: [rule], onError }),
5817
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
5818
- // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
5819
- // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
5820
- // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
5821
- // fails such a rule CLOSED (block) instead of serving a no-op redaction.
5822
- mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
5823
- // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
5824
- // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
5825
- // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
5826
- prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
5827
- }));
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
+ });
5828
6183
  egressRuleSet = egressRules.map((rule) => ({
5829
6184
  rule,
5830
6185
  engine: new RuleEngine({ firewall: [rule], onError })
@@ -5838,6 +6193,9 @@ async function createProtection(options = {}) {
5838
6193
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
5839
6194
  notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
5840
6195
  };
6196
+ const recordEvaluationSkips = (result) => {
6197
+ for (const reason of result?.skips ?? []) recordSkip("request", reason);
6198
+ };
5841
6199
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
5842
6200
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
5843
6201
  const planCache = createPlanCache();
@@ -5880,15 +6238,19 @@ async function createProtection(options = {}) {
5880
6238
  });
5881
6239
  return effectiveMode === "block" ? block() : allow2();
5882
6240
  };
6241
+ const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
6242
+ const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
5883
6243
  const screenText = (text, meta, reqCtx, only) => {
5884
6244
  let blockRule = null;
5885
6245
  const redactions = [];
5886
6246
  const headerMutations = [];
5887
6247
  let lowerText = null;
5888
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
5889
- 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;
5890
6252
  if (prefilter) {
5891
- if (lowerText === null) lowerText = text.toLowerCase();
6253
+ if (lowerText === null) lowerText = prefilterText(text, meta.headers);
5892
6254
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
5893
6255
  }
5894
6256
  let result;
@@ -5898,6 +6260,11 @@ async function createProtection(options = {}) {
5898
6260
  notify(onError, err, "onError");
5899
6261
  continue;
5900
6262
  }
6263
+ for (const reason of result.skips ?? []) {
6264
+ if (responseSkips.has(reason)) continue;
6265
+ responseSkips.add(reason);
6266
+ recordSkip("response", reason);
6267
+ }
5901
6268
  if (!result.blocked) continue;
5902
6269
  const responseMode = ruleMode(rule);
5903
6270
  onDetect({
@@ -5927,6 +6294,8 @@ async function createProtection(options = {}) {
5927
6294
  if (blockRule) return { verdict: "block" };
5928
6295
  let body = text;
5929
6296
  const headers = { ...meta.headers || {} };
6297
+ let responseIsJson;
6298
+ let structure;
5930
6299
  for (const { rule, redactors } of redactions) {
5931
6300
  const mask = maskFn(rule.category);
5932
6301
  const transform = rule.action === "encode" ? htmlEscape : null;
@@ -5935,23 +6304,32 @@ async function createProtection(options = {}) {
5935
6304
  if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
5936
6305
  if (!spanRedactors.length) continue;
5937
6306
  const beforeSpan = body;
5938
- body = applyRedactors(body, spanRedactors, mask, transform);
5939
- if (body !== beforeSpan && !preservesJsonStructure(beforeSpan, body)) return { verdict: "block" };
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
+ }
5940
6318
  if (transform) continue;
5941
6319
  for (const name of Object.keys(headers)) {
5942
6320
  const value = headers[name];
6321
+ const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
5943
6322
  if (typeof value === "string") {
5944
- setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
6323
+ setOwn(headers, name, applyRedactors(value, headerSpans, mask));
5945
6324
  } else if (Array.isArray(value)) {
5946
6325
  setOwn(
5947
6326
  headers,
5948
6327
  name,
5949
- value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
6328
+ value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
5950
6329
  );
5951
6330
  }
5952
6331
  }
5953
6332
  }
5954
- if (body !== text && isJson(text) && !isJson(body)) return { verdict: "block" };
5955
6333
  for (const rule of headerMutations) applyHeaderMutation(headers, rule);
5956
6334
  return { verdict: "redact", body, headers };
5957
6335
  };
@@ -5994,18 +6372,53 @@ async function createProtection(options = {}) {
5994
6372
  }
5995
6373
  };
5996
6374
  };
5997
- 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 = []) => {
5998
6405
  let result;
5999
6406
  let shaped;
6000
6407
  try {
6001
6408
  shaped = await fromFetchRequest(request, {
6409
+ peer: peerOf(request, hostArgs),
6002
6410
  trustedProxy: options.trustedProxy,
6003
6411
  maxBodyBytes: options.maxBodyBytes
6004
6412
  });
6413
+ if (shaped?._clientIp) {
6414
+ clientByRequest.set(request, shaped._clientIp);
6415
+ if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
6416
+ }
6005
6417
  if (shaped?._bodyInspectionSkip) {
6006
6418
  recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
6007
6419
  }
6008
6420
  result = engine.evaluate(shaped);
6421
+ recordEvaluationSkips(result);
6009
6422
  } catch (err) {
6010
6423
  notify(onError, err, "onError");
6011
6424
  return { blocked: null, client: void 0 };
@@ -6055,15 +6468,19 @@ async function createProtection(options = {}) {
6055
6468
  const read = await readTextResponse(response, screenCap);
6056
6469
  if (read.skip) {
6057
6470
  if (read.skip !== "not-a-response") {
6058
- recordSkip("response", read.skip, { status: response?.status, ...read.encoding ? { encoding: read.encoding } : {} });
6471
+ recordSkip("response", read.skip, {
6472
+ status: response?.status,
6473
+ ...read.encoding ? { encoding: read.encoding } : {},
6474
+ ...read.charset ? { charset: read.charset } : {}
6475
+ });
6059
6476
  }
6060
6477
  if (read.skip === "not-a-response") return response;
6061
6478
  return hardenHeadersOnly(response, reqCtx);
6062
6479
  }
6063
- const text = read.text;
6480
+ const text = readJsonEscapes(read.text);
6064
6481
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
6065
- if (r.verdict === "block") return leakResponse();
6066
- if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
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);
6067
6484
  return response;
6068
6485
  };
6069
6486
  const headerValueChanged = (was, value) => {
@@ -6125,7 +6542,8 @@ async function createProtection(options = {}) {
6125
6542
  const hardenHeadersOnly = (response, reqCtx) => {
6126
6543
  try {
6127
6544
  const meta = { status: response.status, headers: headerObject(response.headers) };
6128
- const r = screenText("", meta, reqCtx, hardensWithoutBody);
6545
+ const r = screenText("", meta, reqCtx, decidedWithoutBody);
6546
+ if (r.verdict === "block") return leakResponse();
6129
6547
  if (r.verdict !== "redact" || !r.headers) return response;
6130
6548
  if (!headersChanged(meta.headers, r.headers)) return response;
6131
6549
  return rebuildResponse(response, response.body, r.headers);
@@ -6145,6 +6563,8 @@ async function createProtection(options = {}) {
6145
6563
  const chunks = [];
6146
6564
  let size = 0;
6147
6565
  let overflow = false;
6566
+ let withheld = false;
6567
+ let headScreened = false;
6148
6568
  const MAX = screenCap;
6149
6569
  let hardened = false;
6150
6570
  let answeredWithoutBody = false;
@@ -6176,6 +6596,7 @@ async function createProtection(options = {}) {
6176
6596
  }
6177
6597
  };
6178
6598
  const stillToAnswer = (rule) => !hardensWithoutBody(rule);
6599
+ const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
6179
6600
  let pendingHead = null;
6180
6601
  let sending = false;
6181
6602
  const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
@@ -6237,7 +6658,12 @@ async function createProtection(options = {}) {
6237
6658
  if (typeof res.flushHeaders === "function") {
6238
6659
  const origFlushHeaders = res.flushHeaders.bind(res);
6239
6660
  res.flushHeaders = function() {
6240
- sendHead();
6661
+ const unread = screenUnreadHead();
6662
+ if (unread?.withhold) {
6663
+ sendWithheld();
6664
+ return void 0;
6665
+ }
6666
+ sendHead(unread);
6241
6667
  return origFlushHeaders();
6242
6668
  };
6243
6669
  }
@@ -6272,6 +6698,32 @@ async function createProtection(options = {}) {
6272
6698
  headers: at === -1 ? set : { ...set, ...writeHeadHeaderObject(pendingHead[at]) }
6273
6699
  };
6274
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
+ };
6275
6727
  const sendHead = (changes) => {
6276
6728
  if (sending) return;
6277
6729
  const hardened2 = hardenOnce();
@@ -6293,6 +6745,17 @@ async function createProtection(options = {}) {
6293
6745
  applyToResponse({ headers: merged });
6294
6746
  origWriteHead(...args);
6295
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
+ };
6296
6759
  const applyToResponse = (changes) => {
6297
6760
  if (changes.status !== void 0) res.statusCode = changes.status;
6298
6761
  for (const [name, value] of changes.headers ?? []) {
@@ -6308,7 +6771,12 @@ async function createProtection(options = {}) {
6308
6771
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
6309
6772
  size += buf.length;
6310
6773
  if (size > MAX) {
6311
- sendHead();
6774
+ const unread = screenUnreadHead();
6775
+ if (unread?.withhold) {
6776
+ sendWithheld();
6777
+ return;
6778
+ }
6779
+ sendHead(unread);
6312
6780
  for (const c of chunks) origWrite(c);
6313
6781
  chunks.length = 0;
6314
6782
  origWrite(buf);
@@ -6319,6 +6787,11 @@ async function createProtection(options = {}) {
6319
6787
  chunks.push(buf);
6320
6788
  };
6321
6789
  res.write = function(chunk, enc, cb) {
6790
+ if (withheld) {
6791
+ if (typeof enc === "function") enc();
6792
+ else if (typeof cb === "function") cb();
6793
+ return true;
6794
+ }
6322
6795
  if (overflow) return origWrite(chunk, enc, cb);
6323
6796
  collect(chunk, enc);
6324
6797
  if (typeof enc === "function") enc();
@@ -6334,15 +6807,24 @@ async function createProtection(options = {}) {
6334
6807
  cb = enc;
6335
6808
  enc = void 0;
6336
6809
  }
6810
+ if (withheld) {
6811
+ if (typeof cb === "function") cb();
6812
+ return res;
6813
+ }
6337
6814
  if (overflow) {
6338
6815
  if (chunk != null) origWrite(chunk, enc);
6339
6816
  return origEnd(cb);
6340
6817
  }
6341
6818
  collect(chunk, enc);
6819
+ if (withheld) {
6820
+ if (typeof cb === "function") cb();
6821
+ return res;
6822
+ }
6342
6823
  if (overflow) return origEnd(cb);
6343
6824
  hardenOnce();
6344
- const passThrough = () => {
6345
- sendHead();
6825
+ const passThrough = (changes) => {
6826
+ if (changes?.withhold) return sendWithheld(cb);
6827
+ sendHead(changes);
6346
6828
  for (const c of chunks) origWrite(c);
6347
6829
  return origEnd(cb);
6348
6830
  };
@@ -6353,21 +6835,28 @@ async function createProtection(options = {}) {
6353
6835
  const kind = screenableContentType(ct);
6354
6836
  if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
6355
6837
  recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
6356
- return passThrough();
6838
+ return passThrough(screenUnreadHead());
6357
6839
  }
6358
6840
  const codings = declaredCodings(head.headers["content-encoding"]);
6359
6841
  if (codings.length > 0 && stillEncoded(buffer)) {
6360
6842
  recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
6361
- return passThrough();
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());
6362
6849
  }
6363
- const text = buffer.toString("utf8");
6850
+ const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
6364
6851
  let r;
6365
6852
  try {
6366
- r = screenText(text, head, reqCtx, answeredWithoutBody ? stillToAnswer : void 0);
6853
+ r = screenText(text, head, reqCtx, notYetAnswered);
6367
6854
  } catch (err) {
6368
6855
  notify(onError, err, "onError");
6369
6856
  return passThrough();
6370
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) };
6371
6860
  if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
6372
6861
  if (reallySent() && res.chunkedEncoding !== true) {
6373
6862
  notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
@@ -6375,20 +6864,11 @@ async function createProtection(options = {}) {
6375
6864
  return res;
6376
6865
  }
6377
6866
  if (r.verdict === "block") {
6378
- const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
6379
6867
  if (reallySent()) {
6380
6868
  res.destroy?.();
6381
6869
  return res;
6382
6870
  }
6383
- sendHead({
6384
- status: 500,
6385
- headers: /* @__PURE__ */ new Map([
6386
- ["content-type", "application/json"],
6387
- ["content-length", String(Buffer.byteLength(body))],
6388
- ["transfer-encoding", null]
6389
- ])
6390
- });
6391
- return origEnd(body, cb);
6871
+ return sendWithheld(cb);
6392
6872
  }
6393
6873
  const changed = /* @__PURE__ */ new Map();
6394
6874
  if (r.headers) {
@@ -6484,21 +6964,15 @@ async function createProtection(options = {}) {
6484
6964
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
6485
6965
  // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
6486
6966
  // upstream response. It resolves once here, which is the only resolution for this call.
6487
- screenResponse: (response, request) => screenResp(
6488
- response,
6489
- request ? reqContextFromFetch(
6490
- request,
6491
- resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
6492
- ) : void 0
6493
- ),
6967
+ screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
6494
6968
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
6495
6969
  fetchGuard() {
6496
- return async (request) => (await screenFetchRequest(request)).blocked;
6970
+ return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
6497
6971
  },
6498
6972
  // Wrap a fetch handler: screens the request, then the response (redact/block).
6499
6973
  fetch(handler) {
6500
6974
  return async (request, ...rest) => {
6501
- const { blocked, client } = await screenFetchRequest(request);
6975
+ const { blocked, client } = await screenFetchRequest(request, rest);
6502
6976
  if (blocked) return blocked;
6503
6977
  const response = await handler(request, ...rest);
6504
6978
  return screenResp(response, reqContextFromFetch(request, client));
@@ -6512,6 +6986,7 @@ async function createProtection(options = {}) {
6512
6986
  const { shaped, client } = shapeExpressRequest(req);
6513
6987
  try {
6514
6988
  result = engine.evaluate(shaped);
6989
+ recordEvaluationSkips(result);
6515
6990
  } catch (err) {
6516
6991
  notify(onError, err, "onError");
6517
6992
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
@@ -6546,33 +7021,25 @@ async function createProtection(options = {}) {
6546
7021
  screenNodeRequest(req, res, next, "", req.body);
6547
7022
  return;
6548
7023
  }
6549
- const chunks = [];
6550
- let size = 0;
6551
- let overflow = false;
6552
- req.on("data", (chunk) => {
6553
- size += chunk.length;
6554
- if (size > maxBytes) {
6555
- overflow = true;
7024
+ readBodyPrefix(req, maxBytes, (err, read) => {
7025
+ if (err) {
7026
+ notify(onError, err, "onError");
7027
+ next();
6556
7028
  return;
6557
7029
  }
6558
- chunks.push(chunk);
6559
- });
6560
- req.on("error", (err) => {
6561
- notify(onError, err, "onError");
6562
- next();
6563
- });
6564
- req.on("end", () => {
6565
- if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
6566
- screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
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);
6567
7033
  });
6568
7034
  };
6569
- function screenNodeRequest(req, res, next, rawBody, parsedBody) {
7035
+ function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
6570
7036
  let shaped;
6571
7037
  let result;
6572
7038
  try {
6573
7039
  shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
6574
7040
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
6575
7041
  result = engine.evaluate(shaped);
7042
+ recordEvaluationSkips(result);
6576
7043
  } catch (err) {
6577
7044
  notify(onError, err, "onError");
6578
7045
  return next();
@@ -6591,7 +7058,7 @@ async function createProtection(options = {}) {
6591
7058
  }
6592
7059
  },
6593
7060
  () => {
6594
- if (req.body === void 0) req.body = shaped.body;
7061
+ if (req.body === void 0 && !truncated) req.body = shaped.body;
6595
7062
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
6596
7063
  next();
6597
7064
  },
@@ -6657,12 +7124,17 @@ async function createProtection(options = {}) {
6657
7124
  protection.stop = () => {
6658
7125
  loop?.stop();
6659
7126
  recovery?.stop();
7127
+ protection.uninstallEgress?.();
6660
7128
  const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
6661
7129
  (wait) => wait && typeof wait.then === "function"
6662
7130
  );
6663
7131
  return Promise.all(outstanding).then(() => void 0);
6664
7132
  };
6665
- protection.stopRefresh = protection.stop;
7133
+ protection.stopRefresh = () => {
7134
+ loop?.stop();
7135
+ recovery?.stop();
7136
+ return Promise.resolve();
7137
+ };
6666
7138
  Object.defineProperty(protection, "ruleSource", {
6667
7139
  get: () => ({ ...ruleSource }),
6668
7140
  enumerable: true
@@ -6675,6 +7147,10 @@ async function createProtection(options = {}) {
6675
7147
  get: () => detections ? () => detections.health() : void 0,
6676
7148
  enumerable: true
6677
7149
  });
7150
+ Object.defineProperty(protection, "blockLogHealth", {
7151
+ get: () => firewallLog ? () => firewallLog.health() : void 0,
7152
+ enumerable: true
7153
+ });
6678
7154
  return protection;
6679
7155
  }
6680
7156
  function accessorOf(obj, name) {
@@ -6749,6 +7225,14 @@ function screenableContentType(ct) {
6749
7225
  if (base === "application/octet-stream") return "sniff";
6750
7226
  return "skip";
6751
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
+ }
6752
7236
  function looksBinary(bytes) {
6753
7237
  const n = Math.min(bytes.length, 512);
6754
7238
  let ctrl = 0;
@@ -6772,6 +7256,52 @@ function stillEncoded(bytes) {
6772
7256
  return true;
6773
7257
  }
6774
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
+ }
6775
7305
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6776
7306
  if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
6777
7307
  const ct = response.headers?.get?.("content-type") || "";
@@ -6825,17 +7355,18 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6825
7355
  const bytes = concatBytes(chunks, size);
6826
7356
  if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
6827
7357
  try {
6828
- return { text: new TextDecoder().decode(bytes) };
7358
+ return decodeForScreening(ct, bytes);
6829
7359
  } catch {
6830
7360
  return { skip: "decode-failed" };
6831
7361
  }
6832
7362
  }
6833
7363
  try {
6834
- if (codings.length > 0) {
7364
+ if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
6835
7365
  const bytes = new Uint8Array(await clone.arrayBuffer());
6836
7366
  if (bytes.byteLength > cap) return { skip: "body-cap" };
6837
- if (stillEncoded(bytes)) return encodedSkip();
6838
- return { text: new TextDecoder().decode(bytes) };
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);
6839
7370
  }
6840
7371
  const text = await clone.text();
6841
7372
  if (text.length > cap) return { skip: "body-cap" };
@@ -6866,8 +7397,9 @@ function headerObject(headers) {
6866
7397
  function hasSpanMutations(rule) {
6867
7398
  let found = false;
6868
7399
  const walk = (conds) => {
6869
- for (const c of conds ?? []) {
7400
+ for (const c of Array.isArray(conds) ? conds : []) {
6870
7401
  if (found) return;
7402
+ if (!c || typeof c !== "object") continue;
6871
7403
  if (Array.isArray(c.rules)) walk(c.rules);
6872
7404
  const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
6873
7405
  if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
@@ -6876,10 +7408,34 @@ function hasSpanMutations(rule) {
6876
7408
  walk(rule.rule_v2);
6877
7409
  return found;
6878
7410
  }
6879
- 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) {
6880
7430
  const out = [];
7431
+ const scoped = (c) => {
7432
+ const scope = redactorScope(c.parameter);
7433
+ if (scope === null) onUnscoped?.(c.parameter);
7434
+ return scope;
7435
+ };
6881
7436
  const walk = (conds) => {
6882
- for (const c of conds ?? []) {
7437
+ for (const c of Array.isArray(conds) ? conds : []) {
7438
+ if (!c || typeof c !== "object") continue;
6883
7439
  if (Array.isArray(c.rules)) walk(c.rules);
6884
7440
  const m = c.match;
6885
7441
  if (!m) continue;
@@ -6887,16 +7443,21 @@ function extractRedactors(rule) {
6887
7443
  const safe = safeRegExp(m.value);
6888
7444
  if (safe) {
6889
7445
  const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
7446
+ const scope = scoped(c);
6890
7447
  try {
6891
- out.push({ re: new RegExp(safe.source, flags) });
7448
+ if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
6892
7449
  } catch {
6893
7450
  }
6894
7451
  }
6895
7452
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
6896
- out.push({ literal: String(m.value) });
7453
+ const scope = scoped(c);
7454
+ if (scope) out.push({ literal: String(m.value), scope });
6897
7455
  } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
6898
- out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
6899
- } 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)) {
6900
7461
  const keys = Array.isArray(m.key) ? m.key : [m.key];
6901
7462
  for (const key of keys) {
6902
7463
  out.push({ jsonPath: String(key).split("."), condition: m.match });
@@ -6918,30 +7479,101 @@ function isJson(text) {
6918
7479
  return false;
6919
7480
  }
6920
7481
  }
6921
- function preservesJsonStructure(before, after) {
6922
- if (!isJson(before)) return true;
6923
- if (!isJson(after)) return false;
6924
- const expected = jsonStructure(before);
6925
- const actual = jsonStructure(after);
6926
- for (; ; ) {
6927
- const left = expected.next();
6928
- const right = actual.next();
6929
- if (left.done || right.done) return left.done === right.done;
6930
- if (left.value !== right.value) return false;
6931
- }
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;
6932
7488
  }
6933
- function* jsonStructure(text) {
6934
- const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|[^\s]/g;
6935
- for (const match of text.matchAll(tokens)) {
6936
- const token = match[0];
6937
- if (token[0] !== '"') {
6938
- yield "token:" + token;
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++;
6939
7500
  continue;
6940
7501
  }
6941
- let next = match.index + token.length;
6942
- while (text[next] === " " || text[next] === " " || text[next] === "\r" || text[next] === "\n") next++;
6943
- yield text[next] === ":" ? "key:" + JSON.parse(token) : "string";
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;
6944
7575
  }
7576
+ return true;
6945
7577
  }
6946
7578
  function applyRedactors(body, redactors, mask, transform) {
6947
7579
  let out = body;
@@ -6958,6 +7590,11 @@ function applyRedactors(body, redactors, mask, transform) {
6958
7590
  }
6959
7591
  return out;
6960
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
+ }
6961
7598
  function isBodyParam(parameter) {
6962
7599
  return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
6963
7600
  }
@@ -6989,7 +7626,7 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
6989
7626
  let obj;
6990
7627
  try {
6991
7628
  JSON.parse(text);
6992
- preserved = preserveBigInts(text, mask);
7629
+ preserved = preserveNumbers(text, mask);
6993
7630
  obj = JSON.parse(preserved.text);
6994
7631
  } catch {
6995
7632
  return text;
@@ -7008,22 +7645,22 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
7008
7645
  }
7009
7646
  });
7010
7647
  }
7011
- return changed ? restoreBigInts(JSON.stringify(obj), preserved.numbers) : text;
7648
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
7012
7649
  }
7013
- function preserveBigInts(text, mask) {
7650
+ function preserveNumbers(text, mask) {
7014
7651
  const occupied = /* @__PURE__ */ new Set([mask]);
7015
- const integers = [];
7652
+ const found = [];
7016
7653
  const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
7017
7654
  for (const match of text.matchAll(tokens)) {
7018
7655
  const token = match[0];
7019
7656
  if (token[0] === '"') occupied.add(JSON.parse(token));
7020
- else if (/^-?\d{16,}$/.test(token)) integers.push({ start: match.index, token });
7657
+ else found.push({ start: match.index, token });
7021
7658
  }
7022
7659
  const numbers = /* @__PURE__ */ new Map();
7023
7660
  const chunks = [];
7024
7661
  let offset = 0;
7025
7662
  let index = 0;
7026
- for (const { start, token } of integers) {
7663
+ for (const { start, token } of found) {
7027
7664
  let marker;
7028
7665
  do {
7029
7666
  marker = "__PSNUMBER_" + index++ + "__";
@@ -7035,7 +7672,7 @@ function preserveBigInts(text, mask) {
7035
7672
  chunks.push(text.slice(offset));
7036
7673
  return { text: chunks.join(""), numbers };
7037
7674
  }
7038
- function restoreBigInts(text, numbers) {
7675
+ function restoreNumbers(text, numbers) {
7039
7676
  return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
7040
7677
  }
7041
7678
  function isHeaderMutation(action) {