@patchstack/connect 0.5.15 → 0.5.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {
@@ -3854,7 +4108,10 @@ var DEFAULT_RESPONSE_RULES = [
3854
4108
  // the constraint name, the column and the offending value discloses the schema anyway.
3855
4109
  action: "block",
3856
4110
  prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
3857
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
4111
+ // Case-sensitive, because every signature is emitted in one fixed case. `ER_` is the one that needs
4112
+ // it: MySQL error codes are whole upper-case words (`ER_DUP_ENTRY`), and matched without case or a
4113
+ // word boundary it finds the end of ordinary identifiers such as a `cover__background` class name.
4114
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|\\bER_[A-Z][A-Z0-9_]*|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/" } }]
3858
4115
  },
3859
4116
  {
3860
4117
  id: "resp-exception-trace",
@@ -4484,7 +4741,7 @@ function makeRefreshHandler(tick, secret) {
4484
4741
  return async (request) => {
4485
4742
  if (!secret) return new Response("not found", { status: 404 });
4486
4743
  const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
4487
- if (provided !== secret) return new Response("forbidden", { status: 403 });
4744
+ if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
4488
4745
  let refreshed = true;
4489
4746
  try {
4490
4747
  const status = await runOnce();
@@ -4495,6 +4752,33 @@ function makeRefreshHandler(tick, secret) {
4495
4752
  return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
4496
4753
  };
4497
4754
  }
4755
+ async function sameSecret(provided, secret) {
4756
+ if (typeof provided !== "string" || typeof secret !== "string") return false;
4757
+ const subtle = globalThis.crypto?.subtle;
4758
+ if (subtle && typeof TextEncoder === "function") {
4759
+ try {
4760
+ const encoder2 = new TextEncoder();
4761
+ const [a, b] = await Promise.all([
4762
+ subtle.digest("SHA-256", encoder2.encode(provided)),
4763
+ subtle.digest("SHA-256", encoder2.encode(secret))
4764
+ ]);
4765
+ return equalBytes(new Uint8Array(a), new Uint8Array(b));
4766
+ } catch {
4767
+ }
4768
+ }
4769
+ let diff = provided.length ^ secret.length;
4770
+ const length = Math.max(provided.length, secret.length);
4771
+ for (let i = 0; i < length; i++) {
4772
+ diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
4773
+ }
4774
+ return diff === 0;
4775
+ }
4776
+ function equalBytes(a, b) {
4777
+ if (a.length !== b.length) return false;
4778
+ let diff = 0;
4779
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
4780
+ return diff === 0;
4781
+ }
4498
4782
 
4499
4783
  // src/protect/detections.js
4500
4784
  var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
@@ -5228,9 +5512,180 @@ function hardensWithoutBody(rule) {
5228
5512
  if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
5229
5513
  const { parameters, complete } = readRuleParameters(rule);
5230
5514
  if (!complete) return false;
5231
- return parameters.every(
5232
- (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
5515
+ return parameters.every(bodyIndependent);
5516
+ }
5517
+ function readsOnlyResponseHeaders(rule) {
5518
+ if (!rule) return false;
5519
+ const { parameters, complete } = readRuleParameters(rule);
5520
+ if (!complete || !parameters.some(isHeaderParameter)) return false;
5521
+ return parameters.every(bodyIndependent);
5522
+ }
5523
+ function isHeaderParameter(parameter) {
5524
+ return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
5525
+ }
5526
+ function bodyIndependent(parameter) {
5527
+ return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
5528
+ }
5529
+
5530
+ // src/protect/supabase-guard.js
5531
+ var GUARD_PATH = "/_patchstack/guard";
5532
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5533
+ var DEFAULT_TIMEOUT_MS = 3e4;
5534
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5535
+ "connection",
5536
+ "cookie",
5537
+ "cookie2",
5538
+ "forwarded",
5539
+ "host",
5540
+ "keep-alive",
5541
+ "proxy-authenticate",
5542
+ "proxy-authorization",
5543
+ "te",
5544
+ "trailer",
5545
+ "transfer-encoding",
5546
+ "upgrade",
5547
+ "x-forwarded-for",
5548
+ "x-forwarded-host",
5549
+ "x-forwarded-port",
5550
+ "x-forwarded-proto",
5551
+ "x-ps-target",
5552
+ "x-real-ip"
5553
+ ]);
5554
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5555
+ "connection",
5556
+ "content-encoding",
5557
+ "content-length",
5558
+ "keep-alive",
5559
+ "proxy-authenticate",
5560
+ "proxy-authorization",
5561
+ "set-cookie",
5562
+ "set-cookie2",
5563
+ "te",
5564
+ "trailer",
5565
+ "transfer-encoding",
5566
+ "upgrade"
5567
+ ]);
5568
+ var BodyTooLargeError = class extends Error {
5569
+ };
5570
+ async function boundedBody(request, maxBytes) {
5571
+ const stated = Number(request.headers.get("content-length"));
5572
+ if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5573
+ if (!request.body) return void 0;
5574
+ const reader = request.body.getReader();
5575
+ const chunks = [];
5576
+ let total = 0;
5577
+ try {
5578
+ for (; ; ) {
5579
+ const { done, value } = await reader.read();
5580
+ if (done) break;
5581
+ total += value.byteLength;
5582
+ if (total > maxBytes) {
5583
+ void reader.cancel().catch(() => {
5584
+ });
5585
+ throw new BodyTooLargeError();
5586
+ }
5587
+ chunks.push(value);
5588
+ }
5589
+ } finally {
5590
+ reader.releaseLock();
5591
+ }
5592
+ const body = new Uint8Array(total);
5593
+ let offset = 0;
5594
+ for (const chunk of chunks) {
5595
+ body.set(chunk, offset);
5596
+ offset += chunk.byteLength;
5597
+ }
5598
+ return body;
5599
+ }
5600
+ function requestHeaders(input) {
5601
+ const output = new Headers();
5602
+ const connectionTokens = new Set(
5603
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5233
5604
  );
5605
+ input.forEach((value, key) => {
5606
+ const lower = key.toLowerCase();
5607
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5608
+ });
5609
+ return output;
5610
+ }
5611
+ function responseHeaders(input) {
5612
+ const output = new Headers();
5613
+ const connectionTokens = new Set(
5614
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5615
+ );
5616
+ input.forEach((value, key) => {
5617
+ const lower = key.toLowerCase();
5618
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5619
+ });
5620
+ return output;
5621
+ }
5622
+ var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
5623
+ function createSupabaseGuard({
5624
+ protection,
5625
+ supabaseUrl,
5626
+ fetchImpl = fetch,
5627
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5628
+ timeoutMs = DEFAULT_TIMEOUT_MS
5629
+ }) {
5630
+ const guard = protection.fetchGuard();
5631
+ const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5632
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5633
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
5634
+ return async function handleGuardRequest(request, ...hostArgs) {
5635
+ const target = request.headers.get("x-ps-target");
5636
+ if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
5637
+ let targetUrl;
5638
+ try {
5639
+ targetUrl = new URL(target);
5640
+ } catch {
5641
+ return new Response("patchstack: invalid target", { status: 400 });
5642
+ }
5643
+ if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
5644
+ return new Response("patchstack: target not allowed", { status: 403 });
5645
+ }
5646
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
5647
+ let body;
5648
+ try {
5649
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5650
+ } catch (error) {
5651
+ if (error instanceof BodyTooLargeError) {
5652
+ return new Response("patchstack: request body too large", { status: 413 });
5653
+ }
5654
+ throw error;
5655
+ }
5656
+ const forwardHeaders = requestHeaders(request.headers);
5657
+ const evalReq = new Request(targetUrl.toString(), {
5658
+ method: request.method,
5659
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5660
+ // sanitized at the cross-origin boundary below.
5661
+ headers: request.headers,
5662
+ body
5663
+ });
5664
+ Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
5665
+ const blocked = await guard(evalReq, ...hostArgs);
5666
+ if (blocked) return blocked;
5667
+ let upstream;
5668
+ try {
5669
+ upstream = await fetchImpl(targetUrl.toString(), {
5670
+ method: request.method,
5671
+ headers: forwardHeaders,
5672
+ body,
5673
+ redirect: "manual",
5674
+ signal: AbortSignal.timeout(upstreamTimeout)
5675
+ });
5676
+ } catch (error) {
5677
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5678
+ return new Response("patchstack: upstream timed out", { status: 504 });
5679
+ }
5680
+ throw error;
5681
+ }
5682
+ const forwarded = new Response(upstream.body, {
5683
+ status: upstream.status,
5684
+ statusText: upstream.statusText,
5685
+ headers: responseHeaders(upstream.headers)
5686
+ });
5687
+ return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
5688
+ };
5234
5689
  }
5235
5690
 
5236
5691
  // src/protect/firewall-log.js
@@ -5279,8 +5734,13 @@ function resolveApiBase(pulseOrManifestUrl) {
5279
5734
  function createFirewallLogReporter(opts) {
5280
5735
  const creds = parseApiKey(opts.apiKey);
5281
5736
  if (!creds) {
5282
- return { record() {
5283
- }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
5737
+ return {
5738
+ record() {
5739
+ },
5740
+ flush: () => Promise.resolve(),
5741
+ stop: () => Promise.resolve(),
5742
+ health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
5743
+ };
5284
5744
  }
5285
5745
  const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
5286
5746
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -5293,6 +5753,19 @@ function createFirewallLogReporter(opts) {
5293
5753
  let drainPromise = null;
5294
5754
  let activeController = null;
5295
5755
  let ended = false;
5756
+ let recorded = 0;
5757
+ let delivered = 0;
5758
+ let failed = 0;
5759
+ let dropped = 0;
5760
+ let activeBatch = null;
5761
+ const settle = (sent, outcome) => {
5762
+ if (sent.counted) return;
5763
+ sent.counted = true;
5764
+ if (outcome === "delivered") delivered += sent.size;
5765
+ else if (outcome === "failed") failed += sent.size;
5766
+ else dropped += sent.size;
5767
+ if (activeBatch === sent) activeBatch = null;
5768
+ };
5296
5769
  let cachedToken = null;
5297
5770
  let tokenInflight = null;
5298
5771
  const fetchAccessToken = async (signal) => {
@@ -5346,10 +5819,16 @@ function createFirewallLogReporter(opts) {
5346
5819
  const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
5347
5820
  attemptTimer?.unref?.();
5348
5821
  let entry;
5822
+ const sent = { size: batch.length, counted: false };
5823
+ activeBatch = sent;
5349
5824
  entry = (async () => {
5350
5825
  try {
5351
5826
  const token = await fetchAccessToken(controller?.signal);
5352
- if (!token || ended) return;
5827
+ if (ended) return;
5828
+ if (!token) {
5829
+ settle(sent, "failed");
5830
+ return;
5831
+ }
5353
5832
  const body = new URLSearchParams();
5354
5833
  body.set("type", "firewall");
5355
5834
  body.set("logs", JSON.stringify(batch));
@@ -5366,10 +5845,11 @@ function createFirewallLogReporter(opts) {
5366
5845
  // Both phases carry the attempt controller, so slow transports cannot accumulate work.
5367
5846
  ...controller ? { signal: controller.signal } : {}
5368
5847
  });
5369
- if (p && typeof p.then === "function") await p.catch(() => {
5370
- });
5848
+ const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
5849
+ settle(sent, res && res.ok ? "delivered" : "failed");
5371
5850
  } catch {
5372
5851
  } finally {
5852
+ settle(sent, "failed");
5373
5853
  clearTimeout(attemptTimer);
5374
5854
  if (activeController === controller) activeController = null;
5375
5855
  if (inFlight === entry) inFlight = null;
@@ -5396,7 +5876,11 @@ function createFirewallLogReporter(opts) {
5396
5876
  if (stopped) return;
5397
5877
  const fid = event?.rule?.id;
5398
5878
  if (fid === void 0 || fid === null || fid === "") return;
5399
- if (queue.length >= MAX_QUEUE2) return;
5879
+ if (queue.length >= MAX_QUEUE2) {
5880
+ dropped++;
5881
+ return;
5882
+ }
5883
+ recorded++;
5400
5884
  queue.push({
5401
5885
  fid,
5402
5886
  method: event.method ?? null,
@@ -5412,6 +5896,10 @@ function createFirewallLogReporter(opts) {
5412
5896
  if (!timer) timer = setTimeout(flush, flushMs);
5413
5897
  },
5414
5898
  flush,
5899
+ /** Where the records went so far; see the counters above. `queued` is what is waiting now. */
5900
+ health() {
5901
+ return { recorded, delivered, failed, dropped, queued: queue.length };
5902
+ },
5415
5903
  /**
5416
5904
  * Stop, and hand back a wait for what was outstanding.
5417
5905
  *
@@ -5430,6 +5918,8 @@ function createFirewallLogReporter(opts) {
5430
5918
  ended = true;
5431
5919
  activeController?.abort();
5432
5920
  activeController = null;
5921
+ if (activeBatch) settle(activeBatch, "dropped");
5922
+ dropped += queue.length;
5433
5923
  queue = [];
5434
5924
  inFlight = null;
5435
5925
  };
@@ -5466,165 +5956,6 @@ function telemetryEnabled() {
5466
5956
  return !/^(0|false|off|no)$/i.test(String(v));
5467
5957
  }
5468
5958
 
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
5959
  // src/protect/verify-sentinel.js
5629
5960
  var VERIFY_HEADER = "x-patchstack-verify";
5630
5961
  var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
@@ -5811,20 +6142,47 @@ async function createProtection(options = {}) {
5811
6142
  whitelist_keys: delivered.whitelist_keys,
5812
6143
  onError
5813
6144
  });
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
- }));
6145
+ responseRuleSet = responseRules.map((rule) => {
6146
+ const unscoped = [];
6147
+ const rewrites = rule.action === "redact" || rule.action === "encode";
6148
+ let derived;
6149
+ try {
6150
+ const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
6151
+ const mutatedSpan = rewrites && hasSpanMutations(rule);
6152
+ derived = {
6153
+ redactors,
6154
+ mutatedSpan,
6155
+ // A redaction that reads response headers only, and masks spans in them, and an explicit block
6156
+ // that reads response headers only, are decided and carried out without the body — so they
6157
+ // still apply to a response whose body was not screened.
6158
+ redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
6159
+ blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
6160
+ };
6161
+ } catch (err) {
6162
+ notify(onError, err, "onError");
6163
+ derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
6164
+ }
6165
+ if (unscoped.length) {
6166
+ notify(
6167
+ onError,
6168
+ new Error(
6169
+ `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`
6170
+ ),
6171
+ "onError"
6172
+ );
6173
+ }
6174
+ return {
6175
+ rule,
6176
+ engine: new RuleEngine({ firewall: [rule], onError }),
6177
+ ...derived,
6178
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
6179
+ // have any chance of matching. Lets screenText skip the full scan on responses with no candidate
6180
+ // — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
6181
+ // Case-insensitive, and checked against the header values as well as the body, since a rule may
6182
+ // read either.
6183
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
6184
+ };
6185
+ });
5828
6186
  egressRuleSet = egressRules.map((rule) => ({
5829
6187
  rule,
5830
6188
  engine: new RuleEngine({ firewall: [rule], onError })
@@ -5838,6 +6196,9 @@ async function createProtection(options = {}) {
5838
6196
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
5839
6197
  notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
5840
6198
  };
6199
+ const recordEvaluationSkips = (result) => {
6200
+ for (const reason of result?.skips ?? []) recordSkip("request", reason);
6201
+ };
5841
6202
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
5842
6203
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
5843
6204
  const planCache = createPlanCache();
@@ -5880,15 +6241,19 @@ async function createProtection(options = {}) {
5880
6241
  });
5881
6242
  return effectiveMode === "block" ? block() : allow2();
5882
6243
  };
6244
+ const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
6245
+ const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
5883
6246
  const screenText = (text, meta, reqCtx, only) => {
5884
6247
  let blockRule = null;
5885
6248
  const redactions = [];
5886
6249
  const headerMutations = [];
5887
6250
  let lowerText = null;
5888
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
5889
- if (only && !only(rule)) continue;
6251
+ const responseSkips = /* @__PURE__ */ new Set();
6252
+ for (const entry of responseRuleSet) {
6253
+ const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
6254
+ if (only && !only(rule, entry)) continue;
5890
6255
  if (prefilter) {
5891
- if (lowerText === null) lowerText = text.toLowerCase();
6256
+ if (lowerText === null) lowerText = prefilterText(text, meta.headers);
5892
6257
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
5893
6258
  }
5894
6259
  let result;
@@ -5898,6 +6263,11 @@ async function createProtection(options = {}) {
5898
6263
  notify(onError, err, "onError");
5899
6264
  continue;
5900
6265
  }
6266
+ for (const reason of result.skips ?? []) {
6267
+ if (responseSkips.has(reason)) continue;
6268
+ responseSkips.add(reason);
6269
+ recordSkip("response", reason);
6270
+ }
5901
6271
  if (!result.blocked) continue;
5902
6272
  const responseMode = ruleMode(rule);
5903
6273
  onDetect({
@@ -5927,6 +6297,8 @@ async function createProtection(options = {}) {
5927
6297
  if (blockRule) return { verdict: "block" };
5928
6298
  let body = text;
5929
6299
  const headers = { ...meta.headers || {} };
6300
+ let responseIsJson;
6301
+ let structure;
5930
6302
  for (const { rule, redactors } of redactions) {
5931
6303
  const mask = maskFn(rule.category);
5932
6304
  const transform = rule.action === "encode" ? htmlEscape : null;
@@ -5935,23 +6307,32 @@ async function createProtection(options = {}) {
5935
6307
  if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
5936
6308
  if (!spanRedactors.length) continue;
5937
6309
  const beforeSpan = body;
5938
- body = applyRedactors(body, spanRedactors, mask, transform);
5939
- if (body !== beforeSpan && !preservesJsonStructure(beforeSpan, body)) return { verdict: "block" };
6310
+ const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
6311
+ if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
6312
+ if (body !== beforeSpan) {
6313
+ responseIsJson ??= isJson(text);
6314
+ if (responseIsJson) {
6315
+ if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
6316
+ const tokens = lexJson(body);
6317
+ if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
6318
+ structure = { text: body, tokens };
6319
+ }
6320
+ }
5940
6321
  if (transform) continue;
5941
6322
  for (const name of Object.keys(headers)) {
5942
6323
  const value = headers[name];
6324
+ const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
5943
6325
  if (typeof value === "string") {
5944
- setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
6326
+ setOwn(headers, name, applyRedactors(value, headerSpans, mask));
5945
6327
  } else if (Array.isArray(value)) {
5946
6328
  setOwn(
5947
6329
  headers,
5948
6330
  name,
5949
- value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
6331
+ value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
5950
6332
  );
5951
6333
  }
5952
6334
  }
5953
6335
  }
5954
- if (body !== text && isJson(text) && !isJson(body)) return { verdict: "block" };
5955
6336
  for (const rule of headerMutations) applyHeaderMutation(headers, rule);
5956
6337
  return { verdict: "redact", body, headers };
5957
6338
  };
@@ -5994,18 +6375,53 @@ async function createProtection(options = {}) {
5994
6375
  }
5995
6376
  };
5996
6377
  };
5997
- const screenFetchRequest = async (request) => {
6378
+ const peerOf = (request, hostArgs) => {
6379
+ if (typeof options.peerAddress !== "function") return void 0;
6380
+ try {
6381
+ return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
6382
+ } catch (err) {
6383
+ notify(onError, err, "onError");
6384
+ return void 0;
6385
+ }
6386
+ };
6387
+ let warnedNoPeer = false;
6388
+ const warnNoPeer = () => {
6389
+ if (warnedNoPeer) return;
6390
+ warnedNoPeer = true;
6391
+ 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.";
6392
+ if (typeof onError === "function") notify(onError, new Error(message), "onError");
6393
+ else console.warn(message);
6394
+ };
6395
+ const clientByRequest = /* @__PURE__ */ new WeakMap();
6396
+ const clientForResponse = (request, hostArgs) => {
6397
+ const known = clientByRequest.get(request);
6398
+ if (known) return known;
6399
+ const client = resolveClientIp({
6400
+ peer: peerOf(request, hostArgs),
6401
+ headers: headerObject(request.headers),
6402
+ trustedProxy: options.trustedProxy
6403
+ });
6404
+ if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
6405
+ return client;
6406
+ };
6407
+ const screenFetchRequest = async (request, hostArgs = []) => {
5998
6408
  let result;
5999
6409
  let shaped;
6000
6410
  try {
6001
6411
  shaped = await fromFetchRequest(request, {
6412
+ peer: peerOf(request, hostArgs),
6002
6413
  trustedProxy: options.trustedProxy,
6003
6414
  maxBodyBytes: options.maxBodyBytes
6004
6415
  });
6416
+ if (shaped?._clientIp) {
6417
+ clientByRequest.set(request, shaped._clientIp);
6418
+ if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
6419
+ }
6005
6420
  if (shaped?._bodyInspectionSkip) {
6006
6421
  recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
6007
6422
  }
6008
6423
  result = engine.evaluate(shaped);
6424
+ recordEvaluationSkips(result);
6009
6425
  } catch (err) {
6010
6426
  notify(onError, err, "onError");
6011
6427
  return { blocked: null, client: void 0 };
@@ -6055,15 +6471,19 @@ async function createProtection(options = {}) {
6055
6471
  const read = await readTextResponse(response, screenCap);
6056
6472
  if (read.skip) {
6057
6473
  if (read.skip !== "not-a-response") {
6058
- recordSkip("response", read.skip, { status: response?.status, ...read.encoding ? { encoding: read.encoding } : {} });
6474
+ recordSkip("response", read.skip, {
6475
+ status: response?.status,
6476
+ ...read.encoding ? { encoding: read.encoding } : {},
6477
+ ...read.charset ? { charset: read.charset } : {}
6478
+ });
6059
6479
  }
6060
6480
  if (read.skip === "not-a-response") return response;
6061
6481
  return hardenHeadersOnly(response, reqCtx);
6062
6482
  }
6063
- const text = read.text;
6483
+ const text = readJsonEscapes(read.text);
6064
6484
  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);
6485
+ if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
6486
+ if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
6067
6487
  return response;
6068
6488
  };
6069
6489
  const headerValueChanged = (was, value) => {
@@ -6125,7 +6545,8 @@ async function createProtection(options = {}) {
6125
6545
  const hardenHeadersOnly = (response, reqCtx) => {
6126
6546
  try {
6127
6547
  const meta = { status: response.status, headers: headerObject(response.headers) };
6128
- const r = screenText("", meta, reqCtx, hardensWithoutBody);
6548
+ const r = screenText("", meta, reqCtx, decidedWithoutBody);
6549
+ if (r.verdict === "block") return leakResponse();
6129
6550
  if (r.verdict !== "redact" || !r.headers) return response;
6130
6551
  if (!headersChanged(meta.headers, r.headers)) return response;
6131
6552
  return rebuildResponse(response, response.body, r.headers);
@@ -6145,6 +6566,8 @@ async function createProtection(options = {}) {
6145
6566
  const chunks = [];
6146
6567
  let size = 0;
6147
6568
  let overflow = false;
6569
+ let withheld = false;
6570
+ let headScreened = false;
6148
6571
  const MAX = screenCap;
6149
6572
  let hardened = false;
6150
6573
  let answeredWithoutBody = false;
@@ -6176,6 +6599,7 @@ async function createProtection(options = {}) {
6176
6599
  }
6177
6600
  };
6178
6601
  const stillToAnswer = (rule) => !hardensWithoutBody(rule);
6602
+ const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
6179
6603
  let pendingHead = null;
6180
6604
  let sending = false;
6181
6605
  const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
@@ -6237,7 +6661,12 @@ async function createProtection(options = {}) {
6237
6661
  if (typeof res.flushHeaders === "function") {
6238
6662
  const origFlushHeaders = res.flushHeaders.bind(res);
6239
6663
  res.flushHeaders = function() {
6240
- sendHead();
6664
+ const unread = screenUnreadHead();
6665
+ if (unread?.withhold) {
6666
+ sendWithheld();
6667
+ return void 0;
6668
+ }
6669
+ sendHead(unread);
6241
6670
  return origFlushHeaders();
6242
6671
  };
6243
6672
  }
@@ -6272,6 +6701,32 @@ async function createProtection(options = {}) {
6272
6701
  headers: at === -1 ? set : { ...set, ...writeHeadHeaderObject(pendingHead[at]) }
6273
6702
  };
6274
6703
  };
6704
+ const screenUnreadHead = () => {
6705
+ if (headScreened) return void 0;
6706
+ headScreened = true;
6707
+ try {
6708
+ const head = effectiveHead();
6709
+ const r = screenText("", head, reqCtx, decidedByHeaders);
6710
+ if (r.verdict === "block") {
6711
+ if (!reallySent()) return { withhold: true };
6712
+ recordSkip("response", "headers-sent", { action: "block" });
6713
+ return void 0;
6714
+ }
6715
+ if (r.verdict !== "redact" || !r.headers) return void 0;
6716
+ const changed = /* @__PURE__ */ new Map();
6717
+ for (const [name, value] of Object.entries(r.headers)) {
6718
+ if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
6719
+ }
6720
+ if (changed.size && reallySent()) {
6721
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
6722
+ return void 0;
6723
+ }
6724
+ return changed.size ? { headers: changed } : void 0;
6725
+ } catch (err) {
6726
+ notify(onError, err, "onError");
6727
+ return void 0;
6728
+ }
6729
+ };
6275
6730
  const sendHead = (changes) => {
6276
6731
  if (sending) return;
6277
6732
  const hardened2 = hardenOnce();
@@ -6293,6 +6748,17 @@ async function createProtection(options = {}) {
6293
6748
  applyToResponse({ headers: merged });
6294
6749
  origWriteHead(...args);
6295
6750
  };
6751
+ const sendWithheld = (cb) => {
6752
+ const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
6753
+ const headers = /* @__PURE__ */ new Map();
6754
+ for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
6755
+ headers.set("content-type", "application/json");
6756
+ headers.set("content-length", String(Buffer.byteLength(body)));
6757
+ headers.set("transfer-encoding", null);
6758
+ withheld = true;
6759
+ sendHead({ status: 500, headers });
6760
+ return origEnd(body, cb);
6761
+ };
6296
6762
  const applyToResponse = (changes) => {
6297
6763
  if (changes.status !== void 0) res.statusCode = changes.status;
6298
6764
  for (const [name, value] of changes.headers ?? []) {
@@ -6308,7 +6774,12 @@ async function createProtection(options = {}) {
6308
6774
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
6309
6775
  size += buf.length;
6310
6776
  if (size > MAX) {
6311
- sendHead();
6777
+ const unread = screenUnreadHead();
6778
+ if (unread?.withhold) {
6779
+ sendWithheld();
6780
+ return;
6781
+ }
6782
+ sendHead(unread);
6312
6783
  for (const c of chunks) origWrite(c);
6313
6784
  chunks.length = 0;
6314
6785
  origWrite(buf);
@@ -6319,6 +6790,11 @@ async function createProtection(options = {}) {
6319
6790
  chunks.push(buf);
6320
6791
  };
6321
6792
  res.write = function(chunk, enc, cb) {
6793
+ if (withheld) {
6794
+ if (typeof enc === "function") enc();
6795
+ else if (typeof cb === "function") cb();
6796
+ return true;
6797
+ }
6322
6798
  if (overflow) return origWrite(chunk, enc, cb);
6323
6799
  collect(chunk, enc);
6324
6800
  if (typeof enc === "function") enc();
@@ -6334,15 +6810,24 @@ async function createProtection(options = {}) {
6334
6810
  cb = enc;
6335
6811
  enc = void 0;
6336
6812
  }
6813
+ if (withheld) {
6814
+ if (typeof cb === "function") cb();
6815
+ return res;
6816
+ }
6337
6817
  if (overflow) {
6338
6818
  if (chunk != null) origWrite(chunk, enc);
6339
6819
  return origEnd(cb);
6340
6820
  }
6341
6821
  collect(chunk, enc);
6822
+ if (withheld) {
6823
+ if (typeof cb === "function") cb();
6824
+ return res;
6825
+ }
6342
6826
  if (overflow) return origEnd(cb);
6343
6827
  hardenOnce();
6344
- const passThrough = () => {
6345
- sendHead();
6828
+ const passThrough = (changes) => {
6829
+ if (changes?.withhold) return sendWithheld(cb);
6830
+ sendHead(changes);
6346
6831
  for (const c of chunks) origWrite(c);
6347
6832
  return origEnd(cb);
6348
6833
  };
@@ -6353,21 +6838,28 @@ async function createProtection(options = {}) {
6353
6838
  const kind = screenableContentType(ct);
6354
6839
  if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
6355
6840
  recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
6356
- return passThrough();
6841
+ return passThrough(screenUnreadHead());
6357
6842
  }
6358
6843
  const codings = declaredCodings(head.headers["content-encoding"]);
6359
6844
  if (codings.length > 0 && stillEncoded(buffer)) {
6360
6845
  recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
6361
- return passThrough();
6846
+ return passThrough(screenUnreadHead());
6847
+ }
6848
+ const reading = charsetReading(ct, buffer);
6849
+ if (reading.skip) {
6850
+ recordSkip("response", "unsupported-charset", { charset: reading.skip });
6851
+ return passThrough(screenUnreadHead());
6362
6852
  }
6363
- const text = buffer.toString("utf8");
6853
+ const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
6364
6854
  let r;
6365
6855
  try {
6366
- r = screenText(text, head, reqCtx, answeredWithoutBody ? stillToAnswer : void 0);
6856
+ r = screenText(text, head, reqCtx, notYetAnswered);
6367
6857
  } catch (err) {
6368
6858
  notify(onError, err, "onError");
6369
6859
  return passThrough();
6370
6860
  }
6861
+ if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
6862
+ if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
6371
6863
  if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
6372
6864
  if (reallySent() && res.chunkedEncoding !== true) {
6373
6865
  notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
@@ -6375,20 +6867,11 @@ async function createProtection(options = {}) {
6375
6867
  return res;
6376
6868
  }
6377
6869
  if (r.verdict === "block") {
6378
- const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
6379
6870
  if (reallySent()) {
6380
6871
  res.destroy?.();
6381
6872
  return res;
6382
6873
  }
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);
6874
+ return sendWithheld(cb);
6392
6875
  }
6393
6876
  const changed = /* @__PURE__ */ new Map();
6394
6877
  if (r.headers) {
@@ -6484,21 +6967,15 @@ async function createProtection(options = {}) {
6484
6967
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
6485
6968
  // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
6486
6969
  // 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
- ),
6970
+ screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
6494
6971
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
6495
6972
  fetchGuard() {
6496
- return async (request) => (await screenFetchRequest(request)).blocked;
6973
+ return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
6497
6974
  },
6498
6975
  // Wrap a fetch handler: screens the request, then the response (redact/block).
6499
6976
  fetch(handler) {
6500
6977
  return async (request, ...rest) => {
6501
- const { blocked, client } = await screenFetchRequest(request);
6978
+ const { blocked, client } = await screenFetchRequest(request, rest);
6502
6979
  if (blocked) return blocked;
6503
6980
  const response = await handler(request, ...rest);
6504
6981
  return screenResp(response, reqContextFromFetch(request, client));
@@ -6512,6 +6989,7 @@ async function createProtection(options = {}) {
6512
6989
  const { shaped, client } = shapeExpressRequest(req);
6513
6990
  try {
6514
6991
  result = engine.evaluate(shaped);
6992
+ recordEvaluationSkips(result);
6515
6993
  } catch (err) {
6516
6994
  notify(onError, err, "onError");
6517
6995
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
@@ -6546,33 +7024,25 @@ async function createProtection(options = {}) {
6546
7024
  screenNodeRequest(req, res, next, "", req.body);
6547
7025
  return;
6548
7026
  }
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;
7027
+ readBodyPrefix(req, maxBytes, (err, read) => {
7028
+ if (err) {
7029
+ notify(onError, err, "onError");
7030
+ next();
6556
7031
  return;
6557
7032
  }
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"));
7033
+ if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
7034
+ if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
7035
+ screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
6567
7036
  });
6568
7037
  };
6569
- function screenNodeRequest(req, res, next, rawBody, parsedBody) {
7038
+ function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
6570
7039
  let shaped;
6571
7040
  let result;
6572
7041
  try {
6573
7042
  shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
6574
7043
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
6575
7044
  result = engine.evaluate(shaped);
7045
+ recordEvaluationSkips(result);
6576
7046
  } catch (err) {
6577
7047
  notify(onError, err, "onError");
6578
7048
  return next();
@@ -6591,7 +7061,7 @@ async function createProtection(options = {}) {
6591
7061
  }
6592
7062
  },
6593
7063
  () => {
6594
- if (req.body === void 0) req.body = shaped.body;
7064
+ if (req.body === void 0 && !truncated) req.body = shaped.body;
6595
7065
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
6596
7066
  next();
6597
7067
  },
@@ -6657,12 +7127,17 @@ async function createProtection(options = {}) {
6657
7127
  protection.stop = () => {
6658
7128
  loop?.stop();
6659
7129
  recovery?.stop();
7130
+ protection.uninstallEgress?.();
6660
7131
  const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
6661
7132
  (wait) => wait && typeof wait.then === "function"
6662
7133
  );
6663
7134
  return Promise.all(outstanding).then(() => void 0);
6664
7135
  };
6665
- protection.stopRefresh = protection.stop;
7136
+ protection.stopRefresh = () => {
7137
+ loop?.stop();
7138
+ recovery?.stop();
7139
+ return Promise.resolve();
7140
+ };
6666
7141
  Object.defineProperty(protection, "ruleSource", {
6667
7142
  get: () => ({ ...ruleSource }),
6668
7143
  enumerable: true
@@ -6675,6 +7150,10 @@ async function createProtection(options = {}) {
6675
7150
  get: () => detections ? () => detections.health() : void 0,
6676
7151
  enumerable: true
6677
7152
  });
7153
+ Object.defineProperty(protection, "blockLogHealth", {
7154
+ get: () => firewallLog ? () => firewallLog.health() : void 0,
7155
+ enumerable: true
7156
+ });
6678
7157
  return protection;
6679
7158
  }
6680
7159
  function accessorOf(obj, name) {
@@ -6749,6 +7228,14 @@ function screenableContentType(ct) {
6749
7228
  if (base === "application/octet-stream") return "sniff";
6750
7229
  return "skip";
6751
7230
  }
7231
+ function prefilterText(body, headers) {
7232
+ const values = [];
7233
+ for (const value of Object.values(headers ?? {})) {
7234
+ if (Array.isArray(value)) values.push(...value.map(String));
7235
+ else if (value !== void 0 && value !== null) values.push(String(value));
7236
+ }
7237
+ return [body, ...values].join("\n").toLowerCase();
7238
+ }
6752
7239
  function looksBinary(bytes) {
6753
7240
  const n = Math.min(bytes.length, 512);
6754
7241
  let ctrl = 0;
@@ -6772,6 +7259,52 @@ function stillEncoded(bytes) {
6772
7259
  return true;
6773
7260
  }
6774
7261
  }
7262
+ var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
7263
+ var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
7264
+ function declaredCharset(ct) {
7265
+ const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
7266
+ const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
7267
+ return label || null;
7268
+ }
7269
+ function charsetReading(ct, bytes) {
7270
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
7271
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
7272
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
7273
+ const charset = declaredCharset(ct);
7274
+ if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
7275
+ if (WIDE_CHARSET.test(charset)) return { skip: charset };
7276
+ for (let i = 0; i < bytes.length; i++) {
7277
+ const b = bytes[i];
7278
+ if (b >= 128 || b === 0 || b === 27) return { skip: charset };
7279
+ }
7280
+ return { ascii: true };
7281
+ }
7282
+ function decodeForScreening(ct, bytes) {
7283
+ const reading = charsetReading(ct, bytes);
7284
+ if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
7285
+ return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
7286
+ }
7287
+ function withBom(bom, body) {
7288
+ return bom ? "\uFEFF" + body : body;
7289
+ }
7290
+ function outgrowsCharset(ascii, body) {
7291
+ return ascii === true && /[^\x00-\x7f]/.test(body);
7292
+ }
7293
+ var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
7294
+ function readJsonEscapes(text) {
7295
+ if (!text.includes("\\u") && !text.includes("\\/")) return text;
7296
+ try {
7297
+ JSON.parse(text);
7298
+ } catch {
7299
+ return text;
7300
+ }
7301
+ return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
7302
+ if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
7303
+ const code = parseInt(hex, 16);
7304
+ if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
7305
+ return String.fromCharCode(code);
7306
+ });
7307
+ }
6775
7308
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6776
7309
  if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
6777
7310
  const ct = response.headers?.get?.("content-type") || "";
@@ -6825,17 +7358,18 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
6825
7358
  const bytes = concatBytes(chunks, size);
6826
7359
  if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
6827
7360
  try {
6828
- return { text: new TextDecoder().decode(bytes) };
7361
+ return decodeForScreening(ct, bytes);
6829
7362
  } catch {
6830
7363
  return { skip: "decode-failed" };
6831
7364
  }
6832
7365
  }
6833
7366
  try {
6834
- if (codings.length > 0) {
7367
+ if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
6835
7368
  const bytes = new Uint8Array(await clone.arrayBuffer());
6836
7369
  if (bytes.byteLength > cap) return { skip: "body-cap" };
6837
- if (stillEncoded(bytes)) return encodedSkip();
6838
- return { text: new TextDecoder().decode(bytes) };
7370
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
7371
+ if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
7372
+ return decodeForScreening(ct, bytes);
6839
7373
  }
6840
7374
  const text = await clone.text();
6841
7375
  if (text.length > cap) return { skip: "body-cap" };
@@ -6866,8 +7400,9 @@ function headerObject(headers) {
6866
7400
  function hasSpanMutations(rule) {
6867
7401
  let found = false;
6868
7402
  const walk = (conds) => {
6869
- for (const c of conds ?? []) {
7403
+ for (const c of Array.isArray(conds) ? conds : []) {
6870
7404
  if (found) return;
7405
+ if (!c || typeof c !== "object") continue;
6871
7406
  if (Array.isArray(c.rules)) walk(c.rules);
6872
7407
  const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
6873
7408
  if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
@@ -6876,10 +7411,34 @@ function hasSpanMutations(rule) {
6876
7411
  walk(rule.rule_v2);
6877
7412
  return found;
6878
7413
  }
6879
- function extractRedactors(rule) {
7414
+ function redactorScope(parameter) {
7415
+ if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
7416
+ const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
7417
+ for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
7418
+ if (typeof name !== "string" || name === "") return null;
7419
+ if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
7420
+ else if (name === "response.headers") scope.allHeaders = true;
7421
+ else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
7422
+ scope.body = true;
7423
+ scope.allHeaders = true;
7424
+ } else return null;
7425
+ }
7426
+ return scope;
7427
+ }
7428
+ var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
7429
+ function masksHeader(redactor, name) {
7430
+ return redactor.scope.allHeaders || redactor.scope.headers.has(name);
7431
+ }
7432
+ function extractRedactors(rule, onUnscoped) {
6880
7433
  const out = [];
7434
+ const scoped = (c) => {
7435
+ const scope = redactorScope(c.parameter);
7436
+ if (scope === null) onUnscoped?.(c.parameter);
7437
+ return scope;
7438
+ };
6881
7439
  const walk = (conds) => {
6882
- for (const c of conds ?? []) {
7440
+ for (const c of Array.isArray(conds) ? conds : []) {
7441
+ if (!c || typeof c !== "object") continue;
6883
7442
  if (Array.isArray(c.rules)) walk(c.rules);
6884
7443
  const m = c.match;
6885
7444
  if (!m) continue;
@@ -6887,16 +7446,21 @@ function extractRedactors(rule) {
6887
7446
  const safe = safeRegExp(m.value);
6888
7447
  if (safe) {
6889
7448
  const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
7449
+ const scope = scoped(c);
6890
7450
  try {
6891
- out.push({ re: new RegExp(safe.source, flags) });
7451
+ if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
6892
7452
  } catch {
6893
7453
  }
6894
7454
  }
6895
7455
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
6896
- out.push({ literal: String(m.value) });
7456
+ const scope = scoped(c);
7457
+ if (scope) out.push({ literal: String(m.value), scope });
6897
7458
  } 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)) {
7459
+ const scope = scoped(c);
7460
+ if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
7461
+ } else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
7462
+ onUnscoped?.(c.parameter);
7463
+ } else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
6900
7464
  const keys = Array.isArray(m.key) ? m.key : [m.key];
6901
7465
  for (const key of keys) {
6902
7466
  out.push({ jsonPath: String(key).split("."), condition: m.match });
@@ -6918,30 +7482,101 @@ function isJson(text) {
6918
7482
  return false;
6919
7483
  }
6920
7484
  }
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
- }
7485
+ var JSON_STRING = 1;
7486
+ var JSON_NUMBER = 2;
7487
+ var JSON_LITERAL = 3;
7488
+ var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
7489
+ function isHexCode(c) {
7490
+ return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
6932
7491
  }
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;
7492
+ var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
7493
+ function lexJson(text) {
7494
+ let kinds = new Int32Array(1024);
7495
+ let spans = new Int32Array(2048);
7496
+ let count = 0;
7497
+ const n = text.length;
7498
+ let i = 0;
7499
+ while (i < n) {
7500
+ const c = text.charCodeAt(i);
7501
+ if (c === 32 || c === 9 || c === 10 || c === 13) {
7502
+ i++;
6939
7503
  continue;
6940
7504
  }
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";
7505
+ const start = i;
7506
+ let kind;
7507
+ if (c === 34) {
7508
+ i++;
7509
+ const close = text.indexOf('"', i);
7510
+ if (close === -1) return null;
7511
+ if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
7512
+ i = close;
7513
+ } else {
7514
+ for (; ; ) {
7515
+ if (i >= n) return null;
7516
+ const d = text.charCodeAt(i);
7517
+ if (d === 34) break;
7518
+ if (d < 32) return null;
7519
+ if (d !== 92) {
7520
+ i++;
7521
+ continue;
7522
+ }
7523
+ const e = text.charCodeAt(i + 1);
7524
+ if (e === 117) {
7525
+ for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
7526
+ i += 6;
7527
+ } else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
7528
+ i += 2;
7529
+ } else {
7530
+ return null;
7531
+ }
7532
+ }
7533
+ }
7534
+ i++;
7535
+ kind = JSON_STRING;
7536
+ } else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
7537
+ i++;
7538
+ kind = c;
7539
+ } else if (text.startsWith("true", i) || text.startsWith("null", i)) {
7540
+ i += 4;
7541
+ kind = JSON_LITERAL;
7542
+ } else if (text.startsWith("false", i)) {
7543
+ i += 5;
7544
+ kind = JSON_LITERAL;
7545
+ } else {
7546
+ JSON_NUMBER_TOKEN.lastIndex = i;
7547
+ if (!JSON_NUMBER_TOKEN.test(text)) return null;
7548
+ i = JSON_NUMBER_TOKEN.lastIndex;
7549
+ kind = JSON_NUMBER;
7550
+ }
7551
+ if (count === kinds.length) {
7552
+ const grownKinds = new Int32Array(count * 2);
7553
+ grownKinds.set(kinds);
7554
+ kinds = grownKinds;
7555
+ const grownSpans = new Int32Array(count * 4);
7556
+ grownSpans.set(spans);
7557
+ spans = grownSpans;
7558
+ }
7559
+ kinds[count] = kind;
7560
+ spans[2 * count] = start;
7561
+ spans[2 * count + 1] = i;
7562
+ count++;
7563
+ }
7564
+ return { kinds, spans, count };
7565
+ }
7566
+ function sameJsonStructure(beforeText, before, afterText, after) {
7567
+ if (!before || !after || before.count !== after.count) return false;
7568
+ const { kinds, spans, count } = before;
7569
+ for (let k = 0; k < count; k++) {
7570
+ const kind = kinds[k];
7571
+ if (after.kinds[k] !== kind) return false;
7572
+ if (kind > JSON_LITERAL) continue;
7573
+ if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
7574
+ const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
7575
+ const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
7576
+ if (was === now) continue;
7577
+ if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
6944
7578
  }
7579
+ return true;
6945
7580
  }
6946
7581
  function applyRedactors(body, redactors, mask, transform) {
6947
7582
  let out = body;
@@ -6958,6 +7593,11 @@ function applyRedactors(body, redactors, mask, transform) {
6958
7593
  }
6959
7594
  return out;
6960
7595
  }
7596
+ function readsBody(parameter) {
7597
+ if (!Array.isArray(parameter)) return isBodyParam(parameter);
7598
+ const names = parameter.every((member) => typeof member === "string" && member !== "");
7599
+ return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
7600
+ }
6961
7601
  function isBodyParam(parameter) {
6962
7602
  return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
6963
7603
  }
@@ -6989,7 +7629,7 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
6989
7629
  let obj;
6990
7630
  try {
6991
7631
  JSON.parse(text);
6992
- preserved = preserveBigInts(text, mask);
7632
+ preserved = preserveNumbers(text, mask);
6993
7633
  obj = JSON.parse(preserved.text);
6994
7634
  } catch {
6995
7635
  return text;
@@ -7008,22 +7648,22 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
7008
7648
  }
7009
7649
  });
7010
7650
  }
7011
- return changed ? restoreBigInts(JSON.stringify(obj), preserved.numbers) : text;
7651
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
7012
7652
  }
7013
- function preserveBigInts(text, mask) {
7653
+ function preserveNumbers(text, mask) {
7014
7654
  const occupied = /* @__PURE__ */ new Set([mask]);
7015
- const integers = [];
7655
+ const found = [];
7016
7656
  const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
7017
7657
  for (const match of text.matchAll(tokens)) {
7018
7658
  const token = match[0];
7019
7659
  if (token[0] === '"') occupied.add(JSON.parse(token));
7020
- else if (/^-?\d{16,}$/.test(token)) integers.push({ start: match.index, token });
7660
+ else found.push({ start: match.index, token });
7021
7661
  }
7022
7662
  const numbers = /* @__PURE__ */ new Map();
7023
7663
  const chunks = [];
7024
7664
  let offset = 0;
7025
7665
  let index = 0;
7026
- for (const { start, token } of integers) {
7666
+ for (const { start, token } of found) {
7027
7667
  let marker;
7028
7668
  do {
7029
7669
  marker = "__PSNUMBER_" + index++ + "__";
@@ -7035,7 +7675,7 @@ function preserveBigInts(text, mask) {
7035
7675
  chunks.push(text.slice(offset));
7036
7676
  return { text: chunks.join(""), numbers };
7037
7677
  }
7038
- function restoreBigInts(text, numbers) {
7678
+ function restoreNumbers(text, numbers) {
7039
7679
  return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
7040
7680
  }
7041
7681
  function isHeaderMutation(action) {