@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.cjs CHANGED
@@ -2033,7 +2033,7 @@ async function scanLockfile(cwd) {
2033
2033
  await detectLockfile(cwd);
2034
2034
  }
2035
2035
  const declared = await readDeclaredDependencyNames(cwd);
2036
- const warnings = [];
2036
+ const warnings2 = [];
2037
2037
  let firstParsed = null;
2038
2038
  let walked = null;
2039
2039
  let walkWasCandidate = false;
@@ -2047,7 +2047,7 @@ async function scanLockfile(cwd) {
2047
2047
  continue;
2048
2048
  }
2049
2049
  const unreadable = unreadableWarning(candidate.filename, report);
2050
- if (unreadable !== null) warnings.push(unreadable);
2050
+ if (unreadable !== null) warnings2.push(unreadable);
2051
2051
  if (candidate.strategy === "node-modules-walk") {
2052
2052
  walked = packages;
2053
2053
  walkWasCandidate = true;
@@ -2058,16 +2058,16 @@ async function scanLockfile(cwd) {
2058
2058
  for (const source of parsedSources) {
2059
2059
  const missing = missingDependencies(declared, source.packages);
2060
2060
  if (missing.length > 0) {
2061
- warnings.push(staleWarning(source.filename, missing));
2061
+ warnings2.push(staleWarning(source.filename, missing));
2062
2062
  continue;
2063
2063
  }
2064
2064
  const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
2065
2065
  (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
2066
2066
  );
2067
2067
  if (conflicts.length === 0) {
2068
- return manifestWith(source.packages, warnings);
2068
+ return manifestWith(source.packages, warnings2);
2069
2069
  }
2070
- warnings.push(conflictWarning(source.filename, conflicts));
2070
+ warnings2.push(conflictWarning(source.filename, conflicts));
2071
2071
  break;
2072
2072
  }
2073
2073
  if (walked === null) {
@@ -2080,12 +2080,12 @@ async function scanLockfile(cwd) {
2080
2080
  if (walked !== null) {
2081
2081
  const missing = missingDependencies(declared, walked);
2082
2082
  if (missing.length > 0) {
2083
- warnings.push(staleWarning("node_modules/", missing));
2083
+ warnings2.push(staleWarning("node_modules/", missing));
2084
2084
  }
2085
- warnings.push(
2085
+ warnings2.push(
2086
2086
  walkWasCandidate ? "Checked the installed packages in node_modules/, which is what the build loads." : "Checked the installed packages in node_modules/ instead. Delete the out-of-date package list file to stop this warning."
2087
2087
  );
2088
- return manifestWith(walked, warnings);
2088
+ return manifestWith(walked, warnings2);
2089
2089
  }
2090
2090
  if (firstParsed === null) {
2091
2091
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
@@ -2094,13 +2094,13 @@ async function scanLockfile(cwd) {
2094
2094
  "LOCKFILE_NOT_FOUND"
2095
2095
  );
2096
2096
  }
2097
- warnings.push(
2097
+ warnings2.push(
2098
2098
  `No package list matches package.json; checked ${firstParsed.filename}, so some packages may be missing. Reinstall your packages to fix this.`
2099
2099
  );
2100
- return manifestWith(firstParsed.packages, warnings);
2100
+ return manifestWith(firstParsed.packages, warnings2);
2101
2101
  }
2102
- function manifestWith(packages, warnings) {
2103
- return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
2102
+ function manifestWith(packages, warnings2) {
2103
+ return warnings2.length > 0 ? { ecosystem: "npm", packages, warnings: warnings2 } : { ecosystem: "npm", packages };
2104
2104
  }
2105
2105
  function otherCompleteSources(all, current, declared) {
2106
2106
  return all.filter(
@@ -2725,18 +2725,124 @@ function appendOwn(target, key, value) {
2725
2725
  return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
2726
2726
  }
2727
2727
 
2728
- // src/protect/engine/request.js
2729
- var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
2730
- var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
2731
- var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
2732
- function base64DecodeUtf8(value) {
2733
- const str = String(value);
2734
- if (typeof Buffer !== "undefined") {
2735
- return Buffer.from(str, "base64").toString("utf-8");
2728
+ // src/protect/engine/cookies.js
2729
+ function parseCookieHeader(header) {
2730
+ const cookies = {};
2731
+ if (typeof header !== "string" || header === "") return cookies;
2732
+ const repeated = /* @__PURE__ */ new Map();
2733
+ for (const pair of header.split(";")) {
2734
+ const eq = pair.indexOf("=");
2735
+ if (eq === -1) continue;
2736
+ const name = pair.slice(0, eq).trim();
2737
+ if (name === "") continue;
2738
+ let value = pair.slice(eq + 1).trim();
2739
+ if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') value = value.slice(1, -1);
2740
+ if (!Object.hasOwn(cookies, name)) {
2741
+ setOwn(cookies, name, value);
2742
+ } else {
2743
+ let values = repeated.get(name);
2744
+ if (values === void 0) {
2745
+ values = [cookies[name]];
2746
+ repeated.set(name, values);
2747
+ setOwn(cookies, name, values);
2748
+ }
2749
+ values.push(value);
2750
+ }
2736
2751
  }
2737
- const binary = atob(str);
2738
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
2739
- return new TextDecoder().decode(bytes);
2752
+ return cookies;
2753
+ }
2754
+
2755
+ // src/protect/engine/normalizer.js
2756
+ var HTML_ENTITIES = {
2757
+ "&": "&",
2758
+ "&lt;": "<",
2759
+ "&gt;": ">",
2760
+ "&quot;": '"',
2761
+ "&#39;": "'",
2762
+ "&apos;": "'",
2763
+ "&nbsp;": " ",
2764
+ "&#x27;": "'",
2765
+ "&#x22;": '"',
2766
+ "&#x3C;": "<",
2767
+ "&#x3E;": ">",
2768
+ "&#x26;": "&",
2769
+ "&#34;": '"',
2770
+ "&#60;": "<",
2771
+ "&#62;": ">",
2772
+ "&#38;": "&"
2773
+ };
2774
+ var MAX_DECODE_ITERATIONS = 5;
2775
+ function normalize(value, options = {}) {
2776
+ if (typeof value !== "string") {
2777
+ return value;
2778
+ }
2779
+ const opts = {
2780
+ urlDecode: true,
2781
+ htmlDecode: true,
2782
+ sqlComments: true,
2783
+ nullBytes: true,
2784
+ whitespace: true,
2785
+ ...options
2786
+ };
2787
+ let result = value;
2788
+ if (opts.nullBytes) {
2789
+ result = removeNullBytes(result);
2790
+ }
2791
+ if (opts.urlDecode) {
2792
+ result = urlDecode(result);
2793
+ }
2794
+ if (opts.htmlDecode) {
2795
+ result = htmlEntityDecode(result);
2796
+ }
2797
+ if (opts.sqlComments) {
2798
+ result = removeSqlComments(result);
2799
+ }
2800
+ if (opts.whitespace) {
2801
+ result = normalizeWhitespace(result);
2802
+ }
2803
+ return result;
2804
+ }
2805
+ function urlDecode(value) {
2806
+ if (typeof value !== "string") {
2807
+ return value;
2808
+ }
2809
+ let result = value;
2810
+ let previous = "";
2811
+ let iterations = 0;
2812
+ while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
2813
+ previous = result;
2814
+ iterations++;
2815
+ result = safeUrlDecode(result);
2816
+ }
2817
+ return result;
2818
+ }
2819
+ var utf8 = new TextDecoder();
2820
+ function safeUrlDecode(value) {
2821
+ return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
2822
+ try {
2823
+ return decodeURIComponent(run);
2824
+ } catch {
2825
+ const bytes = new Uint8Array(run.length / 3);
2826
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
2827
+ return utf8.decode(bytes);
2828
+ }
2829
+ });
2830
+ }
2831
+ function decodeQueryPlus(target) {
2832
+ if (typeof target !== "string") return target;
2833
+ const query = target.indexOf("?");
2834
+ if (query === -1) return target;
2835
+ return target.slice(0, query + 1) + target.slice(query + 1).replace(/\+/g, " ");
2836
+ }
2837
+ function htmlEntityDecode(value) {
2838
+ if (typeof value !== "string") {
2839
+ return value;
2840
+ }
2841
+ let result = value;
2842
+ for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
2843
+ result = result.split(entity).join(char);
2844
+ }
2845
+ return decodeHtmlEntities(result);
2740
2846
  }
2741
2847
  var NAMED_ENTITIES = {
2742
2848
  lt: "<",
@@ -2744,7 +2850,6 @@ var NAMED_ENTITIES = {
2744
2850
  amp: "&",
2745
2851
  quot: '"',
2746
2852
  apos: "'",
2747
- "#39": "'",
2748
2853
  nbsp: "\xA0",
2749
2854
  sol: "/",
2750
2855
  bsol: "\\",
@@ -2763,7 +2868,7 @@ var NAMED_ENTITIES = {
2763
2868
  ast: "*"
2764
2869
  };
2765
2870
  function decodeHtmlEntities(input) {
2766
- return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
2871
+ return input.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
2767
2872
  if (body[0] === "#") {
2768
2873
  const hex = body[1] === "x" || body[1] === "X";
2769
2874
  const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
@@ -2777,74 +2882,359 @@ function decodeHtmlEntities(input) {
2777
2882
  return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
2778
2883
  });
2779
2884
  }
2780
- var RequestResolver = class {
2781
- #req;
2782
- #cookies;
2783
- constructor(req) {
2784
- this.#req = req;
2785
- this.#cookies = null;
2885
+ function removeSqlComments(value) {
2886
+ if (typeof value !== "string") {
2887
+ return value;
2786
2888
  }
2787
- resolve(parameter) {
2788
- if (!parameter || parameter === "false") {
2789
- return [null];
2889
+ let result = value;
2890
+ result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
2891
+ result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
2892
+ return result;
2893
+ }
2894
+ function removeNullBytes(value) {
2895
+ if (typeof value !== "string") {
2896
+ return value;
2897
+ }
2898
+ let result = value.replace(/\x00/g, "");
2899
+ result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
2900
+ return result;
2901
+ }
2902
+ function normalizeWhitespace(value) {
2903
+ if (typeof value !== "string") {
2904
+ return value;
2905
+ }
2906
+ let result = value.replace(/[\t\r\n\f\v]+/g, " ");
2907
+ result = result.replace(/ {2,}/g, " ");
2908
+ return result;
2909
+ }
2910
+ var MAX_RAW_CHARS = 1024 * 1024;
2911
+ var MAX_RAW_NODES = 25e3;
2912
+ var MAX_RAW_DEPTH = 200;
2913
+ var MAX_RAW_ENTRIES = 2e3;
2914
+ function serializeForRawDetection(body) {
2915
+ if (body === null || body === void 0) return "";
2916
+ if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
2917
+ const visited = /* @__PURE__ */ new Set();
2918
+ const output = [];
2919
+ const stack = [{ kind: "value", value: body, depth: 0, root: true }];
2920
+ let chars = 0;
2921
+ let nodes = 0;
2922
+ const append = (text) => {
2923
+ if (chars >= MAX_RAW_CHARS) return;
2924
+ const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
2925
+ output.push(piece);
2926
+ chars += piece.length;
2927
+ };
2928
+ while (stack.length > 0 && chars < MAX_RAW_CHARS) {
2929
+ const task = stack.pop();
2930
+ if (task.kind === "text") {
2931
+ append(task.text);
2932
+ continue;
2790
2933
  }
2791
- if (parameter === "rules") {
2792
- return [null];
2934
+ if (task.kind === "key") {
2935
+ append(boundedJsonString(task.value));
2936
+ append(":");
2937
+ continue;
2793
2938
  }
2794
- if (parameter === "raw") {
2795
- return this.#resolveRaw();
2939
+ nodes++;
2940
+ if (nodes > MAX_RAW_NODES) {
2941
+ append('"[NodeLimit]"');
2942
+ continue;
2796
2943
  }
2797
- if (parameter === "all") {
2798
- return this.#resolveAll();
2944
+ const value = task.value;
2945
+ if (value === null || value === void 0) {
2946
+ append(task.root ? "" : "null");
2947
+ continue;
2799
2948
  }
2800
- const dotIndex = parameter.indexOf(".");
2801
- if (dotIndex === -1) {
2802
- return [];
2949
+ if (typeof value === "string") {
2950
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
2951
+ continue;
2803
2952
  }
2804
- const source = parameter.substring(0, dotIndex);
2805
- const key = parameter.substring(dotIndex + 1);
2806
- switch (source) {
2807
- case "get":
2808
- return this.#resolveGet(key);
2809
- case "post":
2810
- return this.#resolvePost(key);
2811
- case "request":
2812
- return this.#resolveRequest(key);
2813
- case "cookie":
2814
- return this.#resolveCookie(key);
2815
- case "server":
2816
- return this.#resolveServer(key);
2817
- case "files":
2818
- return this.#resolveFiles(key);
2819
- case "response":
2820
- return this.#resolveResponse(key);
2821
- case "egress":
2822
- return this.#resolveEgress(key);
2823
- default:
2824
- return [];
2953
+ if (typeof value !== "object") {
2954
+ append(String(value).slice(0, MAX_RAW_CHARS));
2955
+ continue;
2825
2956
  }
2826
- }
2827
- // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
2828
- // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
2829
- #resolveResponse(key) {
2830
- const resp = this.#req._response;
2831
- if (!resp) {
2832
- return [];
2957
+ if (visited.has(value)) {
2958
+ append("[Circular]");
2959
+ continue;
2833
2960
  }
2834
- if (key === "status") {
2835
- return resp.status !== void 0 ? [String(resp.status)] : [];
2961
+ if (task.depth >= MAX_RAW_DEPTH) {
2962
+ append('"[DepthLimit]"');
2963
+ continue;
2836
2964
  }
2837
- if (key === "body") {
2838
- return resp.body != null && resp.body !== "" ? [resp.body] : [];
2965
+ visited.add(value);
2966
+ if (Array.isArray(value)) {
2967
+ const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
2968
+ const items = [];
2969
+ for (let i = 0; i < length2; i++) {
2970
+ if (i > 0) items.push({ kind: "text", text: "," });
2971
+ items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
2972
+ }
2973
+ if (value.length > length2) {
2974
+ if (length2 > 0) items.push({ kind: "text", text: "," });
2975
+ items.push({ kind: "text", text: '"[EntryLimit]"' });
2976
+ }
2977
+ stack.push({ kind: "text", text: "]" });
2978
+ for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
2979
+ stack.push({ kind: "text", text: "[" });
2980
+ continue;
2839
2981
  }
2840
- if (key === "headers") {
2841
- const headers = resp.headers ?? {};
2842
- return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
2982
+ let keys;
2983
+ try {
2984
+ keys = Object.getOwnPropertyNames(value);
2985
+ } catch {
2986
+ append('"[Unreadable]"');
2987
+ continue;
2843
2988
  }
2844
- if (key.startsWith("header.")) {
2845
- const name = key.slice("header.".length).toLowerCase();
2846
- const headers = resp.headers ?? {};
2847
- return Object.hasOwn(headers, name) ? [headers[name]] : [];
2989
+ const length = Math.min(keys.length, MAX_RAW_ENTRIES);
2990
+ const entries = [];
2991
+ for (let i = 0; i < length; i++) {
2992
+ const key = keys[i];
2993
+ if (i > 0) entries.push({ kind: "text", text: "," });
2994
+ entries.push({ kind: "key", value: key });
2995
+ entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
2996
+ }
2997
+ if (keys.length > length) {
2998
+ if (length > 0) entries.push({ kind: "text", text: "," });
2999
+ entries.push({ kind: "text", text: '"[EntryLimit]":null' });
3000
+ }
3001
+ stack.push({ kind: "text", text: "}" });
3002
+ for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
3003
+ stack.push({ kind: "text", text: "{" });
3004
+ }
3005
+ return output.join("");
3006
+ }
3007
+ function boundedJsonString(value) {
3008
+ return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
3009
+ }
3010
+ function readOwnValue(value, key) {
3011
+ try {
3012
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
3013
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
3014
+ } catch {
3015
+ return "[Unreadable]";
3016
+ }
3017
+ }
3018
+ var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
3019
+ function requestField(req, key) {
3020
+ if (req === null || typeof req !== "object") return void 0;
3021
+ if (Object.hasOwn(req, key)) return req[key];
3022
+ if (!INHERITED_ACCESSORS.has(key)) return void 0;
3023
+ let holder = Object.getPrototypeOf(req);
3024
+ while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
3025
+ if (holder === null || holder === Object.prototype) return void 0;
3026
+ const descriptor = Object.getOwnPropertyDescriptor(holder, key);
3027
+ if (typeof descriptor?.get !== "function") return void 0;
3028
+ return req[key];
3029
+ }
3030
+ function normalizeRequest(req, options = {}) {
3031
+ const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
3032
+ const body = requestField(req, "body");
3033
+ const url = requestField(req, "url");
3034
+ const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
3035
+ const headers = requestField(req, "headers") || {};
3036
+ return {
3037
+ query: normalizeObject(requestField(req, "query") || {}, options),
3038
+ body: normalizeObject(body || {}, options),
3039
+ headers: normalizeObject(headers, options),
3040
+ // Cookies are split into name/value pairs BEFORE their values are normalised, and normalised the
3041
+ // same way whether a framework parsed them or they come from the header — so a rule on a cookie
3042
+ // sees one value, independent of which cookie parser (if any) ran first.
3043
+ cookies: normalizeObject(cookieValues(requestField(req, "cookies"), headers), options),
3044
+ url: normalize(decodeQueryPlus(url || ""), options),
3045
+ originalUrl: normalize(decodeQueryPlus(requestField(req, "originalUrl") || url || ""), options),
3046
+ _rawBody: rawBody
3047
+ };
3048
+ }
3049
+ function cookieValues(parsed, headers) {
3050
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
3051
+ return parseCookieHeader(headers?.cookie);
3052
+ }
3053
+ var MAX_NORMALIZE_DEPTH = 1e3;
3054
+ function normalizeObject(value, options = {}, depth = 0) {
3055
+ if (typeof value === "string") {
3056
+ return normalize(value, options);
3057
+ }
3058
+ if (value === null || typeof value !== "object") {
3059
+ return value;
3060
+ }
3061
+ if (depth >= MAX_NORMALIZE_DEPTH) {
3062
+ options.onLimit?.();
3063
+ return value;
3064
+ }
3065
+ const copies = /* @__PURE__ */ new Map();
3066
+ const copyOf = (node) => {
3067
+ const copy = Array.isArray(node) ? new Array(node.length) : {};
3068
+ copies.set(node, copy);
3069
+ return copy;
3070
+ };
3071
+ const top = copyOf(value);
3072
+ const stack = [[value, top, depth]];
3073
+ while (stack.length > 0) {
3074
+ const [node, copy, level] = stack.pop();
3075
+ for (const key of Object.keys(node)) {
3076
+ const child = node[key];
3077
+ if (typeof child === "string") {
3078
+ setOwn(copy, key, normalize(child, options));
3079
+ } else if (child === null || typeof child !== "object") {
3080
+ setOwn(copy, key, child);
3081
+ } else if (copies.has(child)) {
3082
+ setOwn(copy, key, copies.get(child));
3083
+ } else if (level + 1 >= MAX_NORMALIZE_DEPTH) {
3084
+ setOwn(copy, key, child);
3085
+ options.onLimit?.();
3086
+ } else {
3087
+ const childCopy = copyOf(child);
3088
+ setOwn(copy, key, childCopy);
3089
+ stack.push([child, childCopy, level + 1]);
3090
+ }
3091
+ }
3092
+ }
3093
+ return top;
3094
+ }
3095
+
3096
+ // src/protect/engine/request.js
3097
+ var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
3098
+ var ownValue = (obj, key) => obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
3099
+ function fieldSpellings(key) {
3100
+ const spellings = [{ key, nested: true }];
3101
+ if (key.includes("[")) {
3102
+ const dotted = key.replace(/\[\]$/, "").replace(/\[([^\][]*)\]/g, ".$1");
3103
+ if (dotted !== key && dotted !== "" && !dotted.includes("[") && !dotted.includes("]")) {
3104
+ spellings.push({ key: dotted, nested: true });
3105
+ }
3106
+ return spellings;
3107
+ }
3108
+ const [head, ...rest] = key.split(".");
3109
+ const bracketed = head + rest.map((part) => `[${part}]`).join("");
3110
+ if (bracketed !== key) spellings.push({ key: bracketed, nested: false });
3111
+ spellings.push({ key: `${bracketed}[]`, nested: false });
3112
+ return spellings;
3113
+ }
3114
+ var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
3115
+ var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
3116
+ function base64DecodeUtf8(value) {
3117
+ const str = String(value);
3118
+ if (typeof Buffer !== "undefined") {
3119
+ return Buffer.from(str, "base64").toString("utf-8");
3120
+ }
3121
+ const binary = atob(str);
3122
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
3123
+ return new TextDecoder().decode(bytes);
3124
+ }
3125
+ var TEXT_MUTATIONS = /* @__PURE__ */ new Set(["base64_decode", "urldecode", "htmlentitydecode"]);
3126
+ var MAX_MAPPED_NODES = 2e4;
3127
+ var MAX_MAPPED_DEPTH = 1e3;
3128
+ function mapStringLeaves(root, fn, onLimit) {
3129
+ const copies = /* @__PURE__ */ new Map();
3130
+ const copyOf = (node) => {
3131
+ const copy = Array.isArray(node) ? [] : {};
3132
+ copies.set(node, copy);
3133
+ return copy;
3134
+ };
3135
+ const top = copyOf(root);
3136
+ const stack = [[root, top, 0]];
3137
+ let visited = 0;
3138
+ while (stack.length) {
3139
+ const [node, copy, depth] = stack.pop();
3140
+ visited++;
3141
+ for (const key of Object.keys(node)) {
3142
+ const child = node[key];
3143
+ if (typeof child === "string") {
3144
+ setOwn(copy, key, fn(child));
3145
+ } else if (child === null || typeof child !== "object") {
3146
+ setOwn(copy, key, child);
3147
+ } else if (copies.has(child)) {
3148
+ setOwn(copy, key, copies.get(child));
3149
+ } else if (depth + 1 >= MAX_MAPPED_DEPTH || visited + stack.length >= MAX_MAPPED_NODES) {
3150
+ setOwn(copy, key, child);
3151
+ onLimit();
3152
+ } else {
3153
+ const childCopy = copyOf(child);
3154
+ setOwn(copy, key, childCopy);
3155
+ stack.push([child, childCopy, depth + 1]);
3156
+ }
3157
+ }
3158
+ }
3159
+ return top;
3160
+ }
3161
+ var RequestResolver = class {
3162
+ #req;
3163
+ #cookies;
3164
+ #skips = /* @__PURE__ */ new Set();
3165
+ constructor(req) {
3166
+ this.#req = req;
3167
+ this.#cookies = null;
3168
+ }
3169
+ /** Record an inspection limit this evaluation reached (reported once per reason). */
3170
+ noteSkip(reason) {
3171
+ this.#skips.add(reason);
3172
+ }
3173
+ /** The inspection limits recorded so far, in the order first reached. */
3174
+ get skips() {
3175
+ return [...this.#skips];
3176
+ }
3177
+ resolve(parameter) {
3178
+ if (!parameter || parameter === "false") {
3179
+ return [null];
3180
+ }
3181
+ if (parameter === "rules") {
3182
+ return [null];
3183
+ }
3184
+ if (parameter === "raw") {
3185
+ return this.#resolveRaw();
3186
+ }
3187
+ if (parameter === "all") {
3188
+ return this.#resolveAll();
3189
+ }
3190
+ const dotIndex = parameter.indexOf(".");
3191
+ if (dotIndex === -1) {
3192
+ return [];
3193
+ }
3194
+ const source = parameter.substring(0, dotIndex);
3195
+ const key = parameter.substring(dotIndex + 1);
3196
+ switch (source) {
3197
+ case "get":
3198
+ return this.#resolveGet(key);
3199
+ case "post":
3200
+ return this.#resolvePost(key);
3201
+ case "request":
3202
+ return this.#resolveRequest(key);
3203
+ case "cookie":
3204
+ return this.#resolveCookie(key);
3205
+ case "server":
3206
+ return this.#resolveServer(key);
3207
+ case "files":
3208
+ return this.#resolveFiles(key);
3209
+ case "response":
3210
+ return this.#resolveResponse(key);
3211
+ case "egress":
3212
+ return this.#resolveEgress(key);
3213
+ default:
3214
+ return [];
3215
+ }
3216
+ }
3217
+ // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
3218
+ // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
3219
+ #resolveResponse(key) {
3220
+ const resp = this.#req._response;
3221
+ if (!resp) {
3222
+ return [];
3223
+ }
3224
+ if (key === "status") {
3225
+ return resp.status !== void 0 ? [String(resp.status)] : [];
3226
+ }
3227
+ if (key === "body") {
3228
+ return resp.body != null && resp.body !== "" ? [resp.body] : [];
3229
+ }
3230
+ if (key === "headers") {
3231
+ const headers = resp.headers ?? {};
3232
+ return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
3233
+ }
3234
+ if (key.startsWith("header.")) {
3235
+ const name = key.slice("header.".length).toLowerCase();
3236
+ const headers = resp.headers ?? {};
3237
+ return Object.hasOwn(headers, name) ? [headers[name]] : [];
2848
3238
  }
2849
3239
  return [];
2850
3240
  }
@@ -2874,6 +3264,9 @@ var RequestResolver = class {
2874
3264
  if (value === null || value === void 0) {
2875
3265
  return value;
2876
3266
  }
3267
+ if (typeof value === "object" && TEXT_MUTATIONS.has(mutation)) {
3268
+ return mapStringLeaves(value, (leaf) => this.#applyMutation(mutation, leaf), () => this.noteSkip("container-cap"));
3269
+ }
2877
3270
  switch (mutation) {
2878
3271
  case "base64_decode":
2879
3272
  try {
@@ -2894,11 +3287,7 @@ var RequestResolver = class {
2894
3287
  return value;
2895
3288
  }
2896
3289
  case "urldecode":
2897
- try {
2898
- return decodeURIComponent(String(value));
2899
- } catch {
2900
- return value;
2901
- }
3290
+ return safeUrlDecode(String(value).replace(/\+/g, " "));
2902
3291
  case "htmlentitydecode":
2903
3292
  return decodeHtmlEntities(String(value));
2904
3293
  case "intval":
@@ -2917,16 +3306,30 @@ var RequestResolver = class {
2917
3306
  if (key.endsWith("*")) {
2918
3307
  return this.#resolveWildcard(query, key);
2919
3308
  }
2920
- const value = this.#getNestedValue(query, key);
2921
- return value !== void 0 ? [value] : [];
3309
+ return this.#lookup(query, key);
2922
3310
  }
2923
3311
  #resolvePost(key) {
2924
3312
  const body = this.#req.body ?? {};
2925
3313
  if (key.endsWith("*")) {
2926
3314
  return this.#resolveWildcard(body, key);
2927
3315
  }
2928
- const value = this.#getNestedValue(body, key);
2929
- return value !== void 0 ? [value] : [];
3316
+ return this.#lookup(body, key);
3317
+ }
3318
+ /**
3319
+ * Every value a field path names in `obj`, in either spelling a form or query field can take.
3320
+ *
3321
+ * A parser that expands brackets (`qs`, as Express uses) turns `user[name]=x` into `{ user: { name } }`
3322
+ * and `id[]=x` into `{ id: [x] }`; a flat parser (`URLSearchParams`, as the Fetch and Node adapters use)
3323
+ * keeps `user[name]` and `id[]` as literal keys. The rule names the field once, so both shapes answer
3324
+ * to both spellings: `user.name` and `user[name]`, `id` and `id[]`.
3325
+ */
3326
+ #lookup(obj, key) {
3327
+ const values = [];
3328
+ for (const candidate of fieldSpellings(key)) {
3329
+ const value = candidate.nested ? this.#getNestedValue(obj, candidate.key) : ownValue(obj, candidate.key);
3330
+ if (value !== void 0) values.push(value);
3331
+ }
3332
+ return values;
2930
3333
  }
2931
3334
  #resolveRequest(key) {
2932
3335
  const query = this.#req.query ?? {};
@@ -2939,8 +3342,11 @@ var RequestResolver = class {
2939
3342
  ...this.#resolveWildcard(cookies, key)
2940
3343
  ];
2941
3344
  }
2942
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
2943
- return value !== void 0 ? [value] : [];
3345
+ const fromQuery = this.#lookup(query, key);
3346
+ if (fromQuery.length > 0) return fromQuery;
3347
+ const fromBody = this.#lookup(body, key);
3348
+ if (fromBody.length > 0) return fromBody;
3349
+ return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
2944
3350
  }
2945
3351
  #resolveCookie(key) {
2946
3352
  const cookies = this.#parseCookies();
@@ -3110,329 +3516,51 @@ var RequestResolver = class {
3110
3516
  this.#cookies = this.#req.cookies;
3111
3517
  return this.#cookies;
3112
3518
  }
3113
- const header = this.#req.headers?.cookie;
3114
- if (!header) {
3115
- this.#cookies = {};
3116
- return this.#cookies;
3117
- }
3118
- const cookies = {};
3119
- for (const pair of header.split(";")) {
3120
- const eqIndex = pair.indexOf("=");
3121
- if (eqIndex === -1) {
3122
- continue;
3123
- }
3124
- const name = pair.substring(0, eqIndex).trim();
3125
- const value = pair.substring(eqIndex + 1).trim();
3126
- setOwn(cookies, name, value);
3127
- }
3128
- this.#cookies = cookies;
3519
+ this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
3129
3520
  return this.#cookies;
3130
3521
  }
3131
3522
  };
3132
3523
 
3133
3524
  // src/protect/notify.js
3134
- var reported = /* @__PURE__ */ new Set();
3525
+ var reported = /* @__PURE__ */ new WeakMap();
3526
+ var warnings = 0;
3527
+ var MAX_WARNINGS = 20;
3135
3528
  function notify(fn, arg, label) {
3136
3529
  if (typeof fn !== "function") return false;
3137
3530
  try {
3138
3531
  const result = fn(arg);
3139
3532
  if (result !== null && typeof result === "object" && typeof result.then === "function") {
3140
3533
  try {
3141
- result.then(void 0, (err) => warnOnce2(label, err));
3534
+ result.then(void 0, (err) => warnOnce2(fn, label, err));
3142
3535
  } catch {
3143
- }
3144
- }
3145
- return true;
3146
- } catch (err) {
3147
- warnOnce2(label, err);
3148
- return false;
3149
- }
3150
- }
3151
- function warnOnce2(label, err) {
3152
- if (reported.has(label)) return;
3153
- reported.add(label);
3154
- try {
3155
- console.warn(
3156
- `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)}`
3157
- );
3158
- } catch {
3159
- }
3160
- }
3161
-
3162
- // src/protect/engine/normalizer.js
3163
- var HTML_ENTITIES = {
3164
- "&amp;": "&",
3165
- "&lt;": "<",
3166
- "&gt;": ">",
3167
- "&quot;": '"',
3168
- "&#39;": "'",
3169
- "&apos;": "'",
3170
- "&nbsp;": " ",
3171
- "&#x27;": "'",
3172
- "&#x22;": '"',
3173
- "&#x3C;": "<",
3174
- "&#x3E;": ">",
3175
- "&#x26;": "&",
3176
- "&#34;": '"',
3177
- "&#60;": "<",
3178
- "&#62;": ">",
3179
- "&#38;": "&"
3180
- };
3181
- var MAX_DECODE_ITERATIONS = 5;
3182
- function normalize(value, options = {}) {
3183
- if (typeof value !== "string") {
3184
- return value;
3185
- }
3186
- const opts = {
3187
- urlDecode: true,
3188
- htmlDecode: true,
3189
- sqlComments: true,
3190
- nullBytes: true,
3191
- whitespace: true,
3192
- ...options
3193
- };
3194
- let result = value;
3195
- if (opts.nullBytes) {
3196
- result = removeNullBytes(result);
3197
- }
3198
- if (opts.urlDecode) {
3199
- result = urlDecode(result);
3200
- }
3201
- if (opts.htmlDecode) {
3202
- result = htmlEntityDecode(result);
3203
- }
3204
- if (opts.sqlComments) {
3205
- result = removeSqlComments(result);
3206
- }
3207
- if (opts.whitespace) {
3208
- result = normalizeWhitespace(result);
3209
- }
3210
- return result;
3211
- }
3212
- function urlDecode(value) {
3213
- if (typeof value !== "string") {
3214
- return value;
3215
- }
3216
- let result = value;
3217
- let previous = "";
3218
- let iterations = 0;
3219
- while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
3220
- previous = result;
3221
- iterations++;
3222
- try {
3223
- result = decodeURIComponent(result);
3224
- } catch {
3225
- result = safeUrlDecode(result);
3226
- break;
3227
- }
3228
- }
3229
- return result;
3230
- }
3231
- function safeUrlDecode(value) {
3232
- return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
3233
- try {
3234
- return String.fromCharCode(parseInt(hex, 16));
3235
- } catch {
3236
- return match;
3237
- }
3238
- });
3239
- }
3240
- function htmlEntityDecode(value) {
3241
- if (typeof value !== "string") {
3242
- return value;
3243
- }
3244
- let result = value;
3245
- for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
3246
- result = result.split(entity).join(char);
3247
- }
3248
- result = result.replace(/&#(\d+);/g, (match, code) => {
3249
- const num = parseInt(code, 10);
3250
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
3251
- });
3252
- result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
3253
- const num = parseInt(hex, 16);
3254
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
3255
- });
3256
- return result;
3257
- }
3258
- function removeSqlComments(value) {
3259
- if (typeof value !== "string") {
3260
- return value;
3261
- }
3262
- let result = value;
3263
- result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
3264
- result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
3265
- return result;
3266
- }
3267
- function removeNullBytes(value) {
3268
- if (typeof value !== "string") {
3269
- return value;
3270
- }
3271
- let result = value.replace(/\x00/g, "");
3272
- result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
3273
- return result;
3274
- }
3275
- function normalizeWhitespace(value) {
3276
- if (typeof value !== "string") {
3277
- return value;
3278
- }
3279
- let result = value.replace(/[\t\r\n\f\v]+/g, " ");
3280
- result = result.replace(/ {2,}/g, " ");
3281
- return result;
3282
- }
3283
- var MAX_RAW_CHARS = 1024 * 1024;
3284
- var MAX_RAW_NODES = 25e3;
3285
- var MAX_RAW_DEPTH = 200;
3286
- var MAX_RAW_ENTRIES = 2e3;
3287
- function serializeForRawDetection(body) {
3288
- if (body === null || body === void 0) return "";
3289
- if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
3290
- const visited = /* @__PURE__ */ new Set();
3291
- const output = [];
3292
- const stack = [{ kind: "value", value: body, depth: 0, root: true }];
3293
- let chars = 0;
3294
- let nodes = 0;
3295
- const append = (text) => {
3296
- if (chars >= MAX_RAW_CHARS) return;
3297
- const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
3298
- output.push(piece);
3299
- chars += piece.length;
3300
- };
3301
- while (stack.length > 0 && chars < MAX_RAW_CHARS) {
3302
- const task = stack.pop();
3303
- if (task.kind === "text") {
3304
- append(task.text);
3305
- continue;
3306
- }
3307
- if (task.kind === "key") {
3308
- append(boundedJsonString(task.value));
3309
- append(":");
3310
- continue;
3311
- }
3312
- nodes++;
3313
- if (nodes > MAX_RAW_NODES) {
3314
- append('"[NodeLimit]"');
3315
- continue;
3316
- }
3317
- const value = task.value;
3318
- if (value === null || value === void 0) {
3319
- append(task.root ? "" : "null");
3320
- continue;
3321
- }
3322
- if (typeof value === "string") {
3323
- append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
3324
- continue;
3325
- }
3326
- if (typeof value !== "object") {
3327
- append(String(value).slice(0, MAX_RAW_CHARS));
3328
- continue;
3329
- }
3330
- if (visited.has(value)) {
3331
- append("[Circular]");
3332
- continue;
3333
- }
3334
- if (task.depth >= MAX_RAW_DEPTH) {
3335
- append('"[DepthLimit]"');
3336
- continue;
3337
- }
3338
- visited.add(value);
3339
- if (Array.isArray(value)) {
3340
- const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
3341
- const items = [];
3342
- for (let i = 0; i < length2; i++) {
3343
- if (i > 0) items.push({ kind: "text", text: "," });
3344
- items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
3345
- }
3346
- if (value.length > length2) {
3347
- if (length2 > 0) items.push({ kind: "text", text: "," });
3348
- items.push({ kind: "text", text: '"[EntryLimit]"' });
3349
- }
3350
- stack.push({ kind: "text", text: "]" });
3351
- for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
3352
- stack.push({ kind: "text", text: "[" });
3353
- continue;
3354
- }
3355
- let keys;
3356
- try {
3357
- keys = Object.getOwnPropertyNames(value);
3358
- } catch {
3359
- append('"[Unreadable]"');
3360
- continue;
3361
- }
3362
- const length = Math.min(keys.length, MAX_RAW_ENTRIES);
3363
- const entries = [];
3364
- for (let i = 0; i < length; i++) {
3365
- const key = keys[i];
3366
- if (i > 0) entries.push({ kind: "text", text: "," });
3367
- entries.push({ kind: "key", value: key });
3368
- entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
3369
- }
3370
- if (keys.length > length) {
3371
- if (length > 0) entries.push({ kind: "text", text: "," });
3372
- entries.push({ kind: "text", text: '"[EntryLimit]":null' });
3373
- }
3374
- stack.push({ kind: "text", text: "}" });
3375
- for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
3376
- stack.push({ kind: "text", text: "{" });
3377
- }
3378
- return output.join("");
3379
- }
3380
- function boundedJsonString(value) {
3381
- return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
3382
- }
3383
- function readOwnValue(value, key) {
3384
- try {
3385
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
3386
- return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
3387
- } catch {
3388
- return "[Unreadable]";
3389
- }
3390
- }
3391
- var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
3392
- function requestField(req, key) {
3393
- if (req === null || typeof req !== "object") return void 0;
3394
- if (Object.hasOwn(req, key)) return req[key];
3395
- if (!INHERITED_ACCESSORS.has(key)) return void 0;
3396
- let holder = Object.getPrototypeOf(req);
3397
- while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
3398
- if (holder === null || holder === Object.prototype) return void 0;
3399
- const descriptor = Object.getOwnPropertyDescriptor(holder, key);
3400
- if (typeof descriptor?.get !== "function") return void 0;
3401
- return req[key];
3402
- }
3403
- function normalizeRequest(req, options = {}) {
3404
- const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
3405
- const body = requestField(req, "body");
3406
- const url = requestField(req, "url");
3407
- const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
3408
- return {
3409
- query: normalizeObject(requestField(req, "query") || {}, options),
3410
- body: normalizeObject(body || {}, options),
3411
- headers: normalizeObject(requestField(req, "headers") || {}, options),
3412
- url: normalize(url || "", options),
3413
- originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
3414
- _rawBody: rawBody
3415
- };
3416
- }
3417
- var MAX_NORMALIZE_DEPTH = 200;
3418
- function normalizeObject(value, options = {}, depth = 0) {
3419
- if (typeof value === "string") {
3420
- return normalize(value, options);
3421
- }
3422
- if (depth >= MAX_NORMALIZE_DEPTH) {
3423
- return value;
3536
+ }
3537
+ }
3538
+ return true;
3539
+ } catch (err) {
3540
+ warnOnce2(fn, label, err);
3541
+ return false;
3424
3542
  }
3425
- if (Array.isArray(value)) {
3426
- return value.map((item) => normalizeObject(item, options, depth + 1));
3543
+ }
3544
+ function warnOnce2(fn, label, err) {
3545
+ let labels = reported.get(fn);
3546
+ if (labels?.has(label)) return;
3547
+ if (!labels) {
3548
+ labels = /* @__PURE__ */ new Set();
3549
+ reported.set(fn, labels);
3427
3550
  }
3428
- if (typeof value === "object" && value !== null) {
3429
- const result = {};
3430
- for (const [key, val] of Object.entries(value)) {
3431
- setOwn(result, key, normalizeObject(val, options, depth + 1));
3551
+ labels.add(label);
3552
+ if (warnings > MAX_WARNINGS) return;
3553
+ warnings++;
3554
+ try {
3555
+ if (warnings > MAX_WARNINGS) {
3556
+ console.warn("Patchstack: further failing callbacks passed to createProtection are not reported in this process.");
3557
+ return;
3432
3558
  }
3433
- return result;
3559
+ console.warn(
3560
+ `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)}`
3561
+ );
3562
+ } catch {
3434
3563
  }
3435
- return value;
3436
3564
  }
3437
3565
 
3438
3566
  // src/protect/engine/engine.js
@@ -3820,17 +3948,21 @@ function arrayKeyValue(value, matchObj) {
3820
3948
  }
3821
3949
  var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
3822
3950
  function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
3823
- const out = [];
3951
+ const leaves = [];
3824
3952
  const stack = [[root, 0]];
3825
3953
  let visited = 0;
3954
+ let truncated = false;
3826
3955
  while (stack.length) {
3827
3956
  const [node, depth] = stack.pop();
3828
3957
  if (node === null || node === void 0) continue;
3829
3958
  if (typeof node !== "object") {
3830
- out.push(node);
3959
+ leaves.push(node);
3960
+ continue;
3961
+ }
3962
+ if (depth >= maxDepth || visited >= nodeCap) {
3963
+ truncated = true;
3831
3964
  continue;
3832
3965
  }
3833
- if (depth >= maxDepth || visited >= nodeCap) continue;
3834
3966
  visited++;
3835
3967
  if (Array.isArray(node)) {
3836
3968
  for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
@@ -3838,7 +3970,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
3838
3970
  for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
3839
3971
  }
3840
3972
  }
3841
- return out;
3973
+ return { leaves, truncated };
3974
+ }
3975
+ function skipsOf(resolver) {
3976
+ const skips = resolver?.skips;
3977
+ return skips && skips.length > 0 ? { skips } : {};
3978
+ }
3979
+ function serialisedValue(value) {
3980
+ try {
3981
+ return JSON.stringify(value);
3982
+ } catch {
3983
+ return void 0;
3984
+ }
3842
3985
  }
3843
3986
  var warnedKeys = /* @__PURE__ */ new Set();
3844
3987
  function warnOnce3(key, message) {
@@ -3856,12 +3999,22 @@ function warnUnsupportedMatchType(type) {
3856
3999
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
3857
4000
  );
3858
4001
  }
4002
+ var SPECIAL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ws", "wss", "ftp", "file"]);
4003
+ function trimControls(text) {
4004
+ let start = 0;
4005
+ let end = text.length;
4006
+ while (start < end && text.charCodeAt(start) <= 32) start++;
4007
+ while (end > start && text.charCodeAt(end - 1) <= 32) end--;
4008
+ return text.slice(start, end);
4009
+ }
3859
4010
  function hostFromValue(value) {
3860
- const raw = String(value ?? "").trim();
4011
+ const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
3861
4012
  if (raw === "") return "";
3862
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
4013
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(raw)?.[1]?.toLowerCase();
4014
+ const relative = /^[\\/]{2}/.test(raw);
4015
+ if (relative || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || scheme !== void 0 && SPECIAL_SCHEMES.has(scheme)) {
3863
4016
  try {
3864
- return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
4017
+ return new URL(relative ? `http:${raw}` : raw).hostname;
3865
4018
  } catch {
3866
4019
  return raw;
3867
4020
  }
@@ -3889,10 +4042,18 @@ function isInternalHost(hostname) {
3889
4042
  const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
3890
4043
  if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
3891
4044
  if ((g[0] & 65472) === 65152) return true;
4045
+ if ((g[0] & 65472) === 65216) return true;
3892
4046
  if ((g[0] & 65024) === 64512) return true;
4047
+ if ((g[0] & 65280) === 65280) return true;
4048
+ const low32 = (g[6] << 16 >>> 0 | g[7]) >>> 0;
3893
4049
  if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
3894
- return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
4050
+ return isPrivateV4Int(low32);
4051
+ }
4052
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
4053
+ return isPrivateV4Int(low32);
3895
4054
  }
4055
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 1) return true;
4056
+ if (g[0] === 8194) return isPrivateV4Int((g[1] << 16 >>> 0 | g[2]) >>> 0);
3896
4057
  return false;
3897
4058
  }
3898
4059
  const v4 = parseIPv4ToInt(host);
@@ -3902,6 +4063,10 @@ function isInternalHost(hostname) {
3902
4063
  function isPrivateV4Int(n) {
3903
4064
  const a = n >>> 24 & 255;
3904
4065
  const b = n >>> 16 & 255;
4066
+ const c = n >>> 8 & 255;
4067
+ if (a >= 224) return true;
4068
+ if (a === 192 && b === 0 && c === 0) return true;
4069
+ if (a === 198 && (b === 18 || b === 19)) return true;
3905
4070
  if (a === 127 || a === 10 || a === 0) return true;
3906
4071
  if (a === 169 && b === 254) return true;
3907
4072
  if (a === 172 && b >= 16 && b <= 31) return true;
@@ -4250,8 +4415,12 @@ var RuleEngine = class {
4250
4415
  let normalizedReq;
4251
4416
  let resolver;
4252
4417
  try {
4253
- normalizedReq = { ...req, ...normalizeRequest(req) };
4418
+ let limited = false;
4419
+ normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
4420
+ limited = true;
4421
+ } }) };
4254
4422
  resolver = new RequestResolver(normalizedReq);
4423
+ if (limited) resolver.noteSkip("container-cap");
4255
4424
  } catch (err) {
4256
4425
  this.#reportError(err);
4257
4426
  return { blocked: false, rule: null, message: null };
@@ -4277,14 +4446,15 @@ var RuleEngine = class {
4277
4446
  // that re-read the request instead would be reading it a second time: a getter, a stream or
4278
4447
  // anything else that answers once can give a different value, and evidence that disagrees
4279
4448
  // with the match it belongs to is worse than none.
4280
- resolver
4449
+ resolver,
4450
+ ...skipsOf(resolver)
4281
4451
  };
4282
4452
  }
4283
4453
  } catch (err) {
4284
4454
  this.#reportError(err);
4285
4455
  }
4286
4456
  }
4287
- return { blocked: false, rule: null, message: null };
4457
+ return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
4288
4458
  }
4289
4459
  #evaluateRule(conditions, resolver) {
4290
4460
  const inclusiveConditions = conditions.filter((c) => c.inclusive);
@@ -4340,9 +4510,15 @@ var RuleEngine = class {
4340
4510
  if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
4341
4511
  if (matchValue(match.type, value, match.value, match)) return true;
4342
4512
  } else {
4343
- for (const leaf of collectLeafValues(value)) {
4513
+ const { leaves, truncated } = collectLeafValues(value);
4514
+ for (const leaf of leaves) {
4344
4515
  if (matchValue(match.type, leaf, match.value, match)) return true;
4345
4516
  }
4517
+ if (truncated) {
4518
+ resolver.noteSkip("container-cap");
4519
+ const text = serialisedValue(value);
4520
+ if (text !== void 0 && matchValue(match.type, text, match.value, match)) return true;
4521
+ }
4346
4522
  }
4347
4523
  continue;
4348
4524
  }
@@ -4729,6 +4905,9 @@ function conditionShapeProblem(condition) {
4729
4905
  if (nested && !named) {
4730
4906
  return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
4731
4907
  }
4908
+ if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
4909
+ return "inclusive must be true or false";
4910
+ }
4732
4911
  if (named && nested) {
4733
4912
  if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
4734
4913
  if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
@@ -5438,7 +5617,7 @@ async function fromFetchRequest(request, options = {}) {
5438
5617
  // that guessed differently would attribute one request to two addresses.
5439
5618
  ip: client.ip ?? "",
5440
5619
  _clientIp: client,
5441
- cookies: parseCookies(headers.cookie),
5620
+ cookies: parseCookieHeader(headers.cookie),
5442
5621
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
5443
5622
  // drops, so prototype-pollution rules on `raw` are robust.
5444
5623
  _rawBody: rawBody,
@@ -5592,22 +5771,53 @@ function decodeExtendedValue(value) {
5592
5771
  return match[2];
5593
5772
  }
5594
5773
  }
5595
- function parseCookies(header) {
5596
- const cookies = {};
5597
- if (!header) {
5598
- return cookies;
5599
- }
5600
- for (const pair of header.split(";")) {
5601
- const idx = pair.indexOf("=");
5602
- if (idx === -1) {
5603
- continue;
5604
- }
5605
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
5606
- }
5607
- return cookies;
5608
- }
5609
5774
 
5610
5775
  // src/protect/engine/node.js
5776
+ function readBodyPrefix(req, maxBytes, done) {
5777
+ const chunks = [];
5778
+ let retained = 0;
5779
+ let size = 0;
5780
+ let failed = false;
5781
+ let finished = false;
5782
+ const finish = (error, read) => {
5783
+ if (finished) return;
5784
+ finished = true;
5785
+ done(error, read);
5786
+ };
5787
+ req.on("data", (chunk) => {
5788
+ if (failed) return;
5789
+ try {
5790
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, req.readableEncoding || "utf8") : chunk;
5791
+ size += bytes.length;
5792
+ const take = Math.min(bytes.length, maxBytes - retained);
5793
+ if (take <= 0) return;
5794
+ chunks.push(take === bytes.length ? bytes : bytes.subarray(0, take));
5795
+ retained += take;
5796
+ } catch {
5797
+ failed = true;
5798
+ chunks.length = 0;
5799
+ }
5800
+ });
5801
+ req.on("error", (error) => finish(error));
5802
+ req.on("end", () => {
5803
+ if (failed) {
5804
+ finish(null, { text: "", overflow: false, size, failed: true });
5805
+ return;
5806
+ }
5807
+ const prefix = Buffer.concat(chunks);
5808
+ const overflow = size > maxBytes;
5809
+ const text = (overflow ? prefix.subarray(0, completeUtf8Length(prefix)) : prefix).toString("utf8");
5810
+ finish(null, { text, overflow, size, failed: false });
5811
+ });
5812
+ }
5813
+ function completeUtf8Length(bytes) {
5814
+ let start = bytes.length - 1;
5815
+ while (start >= 0 && bytes.length - start <= 3 && (bytes[start] & 192) === 128) start--;
5816
+ if (start < 0) return bytes.length;
5817
+ const lead = bytes[start];
5818
+ const needed = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
5819
+ return bytes.length - start < needed ? start : bytes.length;
5820
+ }
5611
5821
  function fromNodeRequest(req, rawBody = "", options = {}) {
5612
5822
  const method = (req.method || "GET").toUpperCase();
5613
5823
  const headers = {};
@@ -5653,29 +5863,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
5653
5863
  headers,
5654
5864
  ip: client.ip ?? "",
5655
5865
  _clientIp: client,
5656
- cookies: parseCookies2(headers.cookie),
5866
+ cookies: parseCookieHeader(headers.cookie),
5657
5867
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
5658
5868
  _rawBody: rawBody
5659
5869
  };
5660
5870
  }
5661
- function parseCookies2(header) {
5662
- const cookies = {};
5663
- if (!header) {
5664
- return cookies;
5871
+
5872
+ // src/protect/egress.js
5873
+ var REGISTRY = /* @__PURE__ */ Symbol.for("patchstack.connect.egress-guard");
5874
+ function egressRegistry() {
5875
+ const existing = globalThis[REGISTRY];
5876
+ if (existing && existing.screens instanceof Set && existing.surfaces instanceof Map) return existing;
5877
+ const created = { screens: /* @__PURE__ */ new Set(), surfaces: /* @__PURE__ */ new Map() };
5878
+ Object.defineProperty(globalThis, REGISTRY, { value: created, configurable: true, writable: true });
5879
+ return created;
5880
+ }
5881
+ var refusal = (host) => new Error(`Patchstack blocked an outbound request to a disallowed address: ${host}`);
5882
+ function readableDestination(input, init) {
5883
+ try {
5884
+ const raw = typeof input === "string" || input instanceof URL ? String(input) : input?.url ?? input?.href;
5885
+ if (typeof raw !== "string" && !(raw instanceof URL)) return null;
5886
+ const url = new URL(String(raw)).href;
5887
+ const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
5888
+ return { url, method };
5889
+ } catch {
5890
+ return null;
5665
5891
  }
5666
- for (const pair of header.split(";")) {
5667
- const idx = pair.indexOf("=");
5668
- if (idx === -1) {
5669
- continue;
5670
- }
5671
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
5892
+ }
5893
+ function anyRefuses(screens, url, host, method) {
5894
+ let refused = false;
5895
+ for (const screen of screens) {
5896
+ if (screen.block(url, host, method)) refused = true;
5672
5897
  }
5673
- return cookies;
5898
+ return refused;
5674
5899
  }
5675
-
5676
- // src/protect/egress.js
5677
5900
  async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
5678
- const restores = [];
5679
5901
  if (typeof shouldBlock !== "function") return () => {
5680
5902
  };
5681
5903
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
@@ -5720,6 +5942,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5720
5942
  resolve(false);
5721
5943
  }
5722
5944
  });
5945
+ const registry = egressRegistry();
5946
+ const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
5947
+ registry.screens.add(own);
5723
5948
  const originalFetch = globalThis.fetch;
5724
5949
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
5725
5950
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
@@ -5792,15 +6017,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5792
6017
  } catch {
5793
6018
  host = null;
5794
6019
  }
5795
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
5796
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
5797
- }
6020
+ const screens = [...registry.screens];
6021
+ if (anyRefuses(screens, u, host, method)) throw refusal(host ?? u);
6022
+ const resolved = await Promise.all(screens.map((each) => each.prescreen(u, host, method)));
6023
+ if (resolved.includes(true)) throw refusal(host ?? u);
5798
6024
  };
5799
6025
  const guarded = async (input, init) => {
5800
6026
  let cur;
5801
6027
  try {
5802
6028
  cur = new Request(input, { ...init || {}, redirect: "manual" });
5803
6029
  } catch {
6030
+ const destination = readableDestination(input, init);
6031
+ if (destination) await screenUrl(destination.url, destination.method);
6032
+ else for (const each of registry.screens) each.skip("unrecognised-request", {});
5804
6033
  return originalFetch(input, init);
5805
6034
  }
5806
6035
  const callerRedirect = init && init.redirect || input && input.redirect || "follow";
@@ -5811,13 +6040,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5811
6040
  const headers = new Headers(cur.headers);
5812
6041
  const signal = cur.signal;
5813
6042
  const replay = captureReplayBody(cur);
6043
+ const { body: _callerBody, ...callerOptions } = init || {};
5814
6044
  let body;
5815
6045
  for (let hop = 0; ; hop++) {
5816
6046
  let resp;
5817
6047
  try {
5818
- resp = await originalFetch(
5819
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
5820
- );
6048
+ resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
5821
6049
  } catch (error) {
5822
6050
  replay.cancel();
5823
6051
  throw error;
@@ -5825,7 +6053,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5825
6053
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
5826
6054
  if (!location) {
5827
6055
  replay.cancel();
5828
- return resp;
6056
+ return hop === 0 ? resp : markRedirected(resp);
5829
6057
  }
5830
6058
  await discardResponseBody(resp);
5831
6059
  if (hop >= MAX_REDIRECTS) {
@@ -5858,8 +6086,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5858
6086
  };
5859
6087
  guarded.__patchstackGuarded = true;
5860
6088
  globalThis.fetch = guarded;
5861
- restores.push(() => {
5862
- if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
6089
+ registry.surfaces.set(guarded, {
6090
+ release() {
6091
+ if (globalThis.fetch !== guarded) return false;
6092
+ globalThis.fetch = originalFetch;
6093
+ return true;
6094
+ }
5863
6095
  });
5864
6096
  }
5865
6097
  let syncBuiltins = () => {
@@ -5873,12 +6105,16 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5873
6105
  for (const moduleName of ["node:http", "node:https"]) {
5874
6106
  try {
5875
6107
  const mod = await import(moduleName);
5876
- const restore = patchHttpModule(mod.default ?? mod, block, screen, skip);
5877
- if (restore) {
6108
+ if (registry.surfaces.has(moduleName)) continue;
6109
+ const release = patchHttpModule(mod.default ?? mod, registry);
6110
+ if (release) {
5878
6111
  patchedAny = true;
5879
- restores.push(() => {
5880
- restore();
5881
- syncBuiltins();
6112
+ registry.surfaces.set(moduleName, {
6113
+ release() {
6114
+ if (!release()) return false;
6115
+ syncBuiltins();
6116
+ return true;
6117
+ }
5882
6118
  });
5883
6119
  }
5884
6120
  } catch {
@@ -5886,27 +6122,41 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5886
6122
  }
5887
6123
  if (patchedAny) syncBuiltins();
5888
6124
  return () => {
5889
- for (const restore of restores) {
6125
+ registry.screens.delete(own);
6126
+ if (registry.screens.size > 0) return;
6127
+ for (const [name, surface] of registry.surfaces) {
5890
6128
  try {
5891
- restore();
6129
+ if (surface.release()) registry.surfaces.delete(name);
5892
6130
  } catch {
5893
6131
  }
5894
6132
  }
5895
6133
  };
5896
6134
  }
5897
- function patchHttpModule(http, block, screen, skip) {
6135
+ function markRedirected(response) {
6136
+ try {
6137
+ Object.defineProperty(response, "redirected", { value: true, configurable: true });
6138
+ } catch {
6139
+ }
6140
+ return response;
6141
+ }
6142
+ function patchHttpModule(http, registry) {
5898
6143
  if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
5899
6144
  const originalRequest = http.request;
5900
6145
  const originalGet = http.get;
5901
6146
  const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
5902
6147
  const guardArgs = (args) => {
5903
6148
  const target = extractHttpTarget(args);
5904
- if (target && block(target.url, target.host, target.method)) {
5905
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
5906
- }
5907
- if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
6149
+ if (!target) return args;
6150
+ const screens = [...registry.screens];
6151
+ if (anyRefuses(screens, target.url, target.host, target.method)) throw refusal(target.host ?? target.url);
6152
+ const resolving = target.host ? screens.filter((each) => each.dns && each.dns.isIP(target.host) === 0 && !each.dns.isExempt(target.host)) : [];
6153
+ if (resolving.length > 0) {
6154
+ const block = (url, host, method) => anyRefuses(resolving, url, host, method);
6155
+ const skip = (reason, detail) => {
6156
+ for (const each of resolving) each.skip(reason, detail);
6157
+ };
5908
6158
  try {
5909
- return withScreeningLookup(args, target, block, screen.lookup, skip);
6159
+ return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
5910
6160
  } catch {
5911
6161
  }
5912
6162
  }
@@ -5934,10 +6184,14 @@ function patchHttpModule(http, block, screen, skip) {
5934
6184
  }
5935
6185
  http.__patchstackGuarded = true;
5936
6186
  return () => {
5937
- if (http.request === guardedRequest) http.request = originalRequest;
5938
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
5939
- if (GuardedClientRequest && http.ClientRequest === GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
6187
+ if (http.request !== guardedRequest) return false;
6188
+ if (guardedGet && http.get !== guardedGet) return false;
6189
+ if (GuardedClientRequest && http.ClientRequest !== GuardedClientRequest) return false;
6190
+ http.request = originalRequest;
6191
+ if (guardedGet) http.get = originalGet;
6192
+ if (GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
5940
6193
  delete http.__patchstackGuarded;
6194
+ return true;
5941
6195
  };
5942
6196
  }
5943
6197
  function extractHttpTarget(args) {
@@ -6206,7 +6460,10 @@ var DEFAULT_RESPONSE_RULES = [
6206
6460
  // the constraint name, the column and the offending value discloses the schema anyway.
6207
6461
  action: "block",
6208
6462
  prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
6209
- 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" } }]
6463
+ // Case-sensitive, because every signature is emitted in one fixed case. `ER_` is the one that needs
6464
+ // it: MySQL error codes are whole upper-case words (`ER_DUP_ENTRY`), and matched without case or a
6465
+ // word boundary it finds the end of ordinary identifiers such as a `cover__background` class name.
6466
+ 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)/" } }]
6210
6467
  },
6211
6468
  {
6212
6469
  id: "resp-exception-trace",
@@ -6838,7 +7095,7 @@ function makeRefreshHandler(tick, secret) {
6838
7095
  return async (request) => {
6839
7096
  if (!secret) return new Response("not found", { status: 404 });
6840
7097
  const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
6841
- if (provided !== secret) return new Response("forbidden", { status: 403 });
7098
+ if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
6842
7099
  let refreshed = true;
6843
7100
  try {
6844
7101
  const status = await runOnce();
@@ -6849,6 +7106,33 @@ function makeRefreshHandler(tick, secret) {
6849
7106
  return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
6850
7107
  };
6851
7108
  }
7109
+ async function sameSecret(provided, secret) {
7110
+ if (typeof provided !== "string" || typeof secret !== "string") return false;
7111
+ const subtle = globalThis.crypto?.subtle;
7112
+ if (subtle && typeof TextEncoder === "function") {
7113
+ try {
7114
+ const encoder2 = new TextEncoder();
7115
+ const [a, b] = await Promise.all([
7116
+ subtle.digest("SHA-256", encoder2.encode(provided)),
7117
+ subtle.digest("SHA-256", encoder2.encode(secret))
7118
+ ]);
7119
+ return equalBytes(new Uint8Array(a), new Uint8Array(b));
7120
+ } catch {
7121
+ }
7122
+ }
7123
+ let diff = provided.length ^ secret.length;
7124
+ const length = Math.max(provided.length, secret.length);
7125
+ for (let i = 0; i < length; i++) {
7126
+ diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
7127
+ }
7128
+ return diff === 0;
7129
+ }
7130
+ function equalBytes(a, b) {
7131
+ if (a.length !== b.length) return false;
7132
+ let diff = 0;
7133
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
7134
+ return diff === 0;
7135
+ }
6852
7136
 
6853
7137
  // src/protect/detections.js
6854
7138
  init_pulse_token();
@@ -7584,9 +7868,180 @@ function hardensWithoutBody(rule) {
7584
7868
  if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
7585
7869
  const { parameters, complete } = readRuleParameters(rule);
7586
7870
  if (!complete) return false;
7587
- return parameters.every(
7588
- (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
7871
+ return parameters.every(bodyIndependent);
7872
+ }
7873
+ function readsOnlyResponseHeaders(rule) {
7874
+ if (!rule) return false;
7875
+ const { parameters, complete } = readRuleParameters(rule);
7876
+ if (!complete || !parameters.some(isHeaderParameter)) return false;
7877
+ return parameters.every(bodyIndependent);
7878
+ }
7879
+ function isHeaderParameter(parameter) {
7880
+ return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
7881
+ }
7882
+ function bodyIndependent(parameter) {
7883
+ return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
7884
+ }
7885
+
7886
+ // src/protect/supabase-guard.js
7887
+ var GUARD_PATH = "/_patchstack/guard";
7888
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7889
+ var DEFAULT_TIMEOUT_MS = 3e4;
7890
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7891
+ "connection",
7892
+ "cookie",
7893
+ "cookie2",
7894
+ "forwarded",
7895
+ "host",
7896
+ "keep-alive",
7897
+ "proxy-authenticate",
7898
+ "proxy-authorization",
7899
+ "te",
7900
+ "trailer",
7901
+ "transfer-encoding",
7902
+ "upgrade",
7903
+ "x-forwarded-for",
7904
+ "x-forwarded-host",
7905
+ "x-forwarded-port",
7906
+ "x-forwarded-proto",
7907
+ "x-ps-target",
7908
+ "x-real-ip"
7909
+ ]);
7910
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7911
+ "connection",
7912
+ "content-encoding",
7913
+ "content-length",
7914
+ "keep-alive",
7915
+ "proxy-authenticate",
7916
+ "proxy-authorization",
7917
+ "set-cookie",
7918
+ "set-cookie2",
7919
+ "te",
7920
+ "trailer",
7921
+ "transfer-encoding",
7922
+ "upgrade"
7923
+ ]);
7924
+ var BodyTooLargeError = class extends Error {
7925
+ };
7926
+ async function boundedBody(request, maxBytes) {
7927
+ const stated2 = Number(request.headers.get("content-length"));
7928
+ if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7929
+ if (!request.body) return void 0;
7930
+ const reader = request.body.getReader();
7931
+ const chunks = [];
7932
+ let total = 0;
7933
+ try {
7934
+ for (; ; ) {
7935
+ const { done, value } = await reader.read();
7936
+ if (done) break;
7937
+ total += value.byteLength;
7938
+ if (total > maxBytes) {
7939
+ void reader.cancel().catch(() => {
7940
+ });
7941
+ throw new BodyTooLargeError();
7942
+ }
7943
+ chunks.push(value);
7944
+ }
7945
+ } finally {
7946
+ reader.releaseLock();
7947
+ }
7948
+ const body = new Uint8Array(total);
7949
+ let offset = 0;
7950
+ for (const chunk of chunks) {
7951
+ body.set(chunk, offset);
7952
+ offset += chunk.byteLength;
7953
+ }
7954
+ return body;
7955
+ }
7956
+ function requestHeaders(input) {
7957
+ const output = new Headers();
7958
+ const connectionTokens = new Set(
7959
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7589
7960
  );
7961
+ input.forEach((value, key) => {
7962
+ const lower = key.toLowerCase();
7963
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7964
+ });
7965
+ return output;
7966
+ }
7967
+ function responseHeaders(input) {
7968
+ const output = new Headers();
7969
+ const connectionTokens = new Set(
7970
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7971
+ );
7972
+ input.forEach((value, key) => {
7973
+ const lower = key.toLowerCase();
7974
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7975
+ });
7976
+ return output;
7977
+ }
7978
+ var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
7979
+ function createSupabaseGuard({
7980
+ protection,
7981
+ supabaseUrl,
7982
+ fetchImpl = fetch,
7983
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7984
+ timeoutMs = DEFAULT_TIMEOUT_MS
7985
+ }) {
7986
+ const guard = protection.fetchGuard();
7987
+ const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7988
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7989
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
7990
+ return async function handleGuardRequest(request, ...hostArgs) {
7991
+ const target = request.headers.get("x-ps-target");
7992
+ if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
7993
+ let targetUrl;
7994
+ try {
7995
+ targetUrl = new URL(target);
7996
+ } catch {
7997
+ return new Response("patchstack: invalid target", { status: 400 });
7998
+ }
7999
+ if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
8000
+ return new Response("patchstack: target not allowed", { status: 403 });
8001
+ }
8002
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
8003
+ let body;
8004
+ try {
8005
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
8006
+ } catch (error) {
8007
+ if (error instanceof BodyTooLargeError) {
8008
+ return new Response("patchstack: request body too large", { status: 413 });
8009
+ }
8010
+ throw error;
8011
+ }
8012
+ const forwardHeaders = requestHeaders(request.headers);
8013
+ const evalReq = new Request(targetUrl.toString(), {
8014
+ method: request.method,
8015
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
8016
+ // sanitized at the cross-origin boundary below.
8017
+ headers: request.headers,
8018
+ body
8019
+ });
8020
+ Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
8021
+ const blocked = await guard(evalReq, ...hostArgs);
8022
+ if (blocked) return blocked;
8023
+ let upstream;
8024
+ try {
8025
+ upstream = await fetchImpl(targetUrl.toString(), {
8026
+ method: request.method,
8027
+ headers: forwardHeaders,
8028
+ body,
8029
+ redirect: "manual",
8030
+ signal: AbortSignal.timeout(upstreamTimeout)
8031
+ });
8032
+ } catch (error) {
8033
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
8034
+ return new Response("patchstack: upstream timed out", { status: 504 });
8035
+ }
8036
+ throw error;
8037
+ }
8038
+ const forwarded = new Response(upstream.body, {
8039
+ status: upstream.status,
8040
+ statusText: upstream.statusText,
8041
+ headers: responseHeaders(upstream.headers)
8042
+ });
8043
+ return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
8044
+ };
7590
8045
  }
7591
8046
 
7592
8047
  // src/protect/firewall-log.js
@@ -7637,8 +8092,13 @@ function resolveApiBase(pulseOrManifestUrl) {
7637
8092
  function createFirewallLogReporter(opts) {
7638
8093
  const creds = parseApiKey(opts.apiKey);
7639
8094
  if (!creds) {
7640
- return { record() {
7641
- }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
8095
+ return {
8096
+ record() {
8097
+ },
8098
+ flush: () => Promise.resolve(),
8099
+ stop: () => Promise.resolve(),
8100
+ health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
8101
+ };
7642
8102
  }
7643
8103
  const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
7644
8104
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -7651,6 +8111,19 @@ function createFirewallLogReporter(opts) {
7651
8111
  let drainPromise = null;
7652
8112
  let activeController = null;
7653
8113
  let ended = false;
8114
+ let recorded = 0;
8115
+ let delivered = 0;
8116
+ let failed = 0;
8117
+ let dropped = 0;
8118
+ let activeBatch = null;
8119
+ const settle = (sent, outcome) => {
8120
+ if (sent.counted) return;
8121
+ sent.counted = true;
8122
+ if (outcome === "delivered") delivered += sent.size;
8123
+ else if (outcome === "failed") failed += sent.size;
8124
+ else dropped += sent.size;
8125
+ if (activeBatch === sent) activeBatch = null;
8126
+ };
7654
8127
  let cachedToken = null;
7655
8128
  let tokenInflight = null;
7656
8129
  const fetchAccessToken = async (signal) => {
@@ -7704,10 +8177,16 @@ function createFirewallLogReporter(opts) {
7704
8177
  const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
7705
8178
  attemptTimer?.unref?.();
7706
8179
  let entry;
8180
+ const sent = { size: batch.length, counted: false };
8181
+ activeBatch = sent;
7707
8182
  entry = (async () => {
7708
8183
  try {
7709
8184
  const token = await fetchAccessToken(controller?.signal);
7710
- if (!token || ended) return;
8185
+ if (ended) return;
8186
+ if (!token) {
8187
+ settle(sent, "failed");
8188
+ return;
8189
+ }
7711
8190
  const body = new URLSearchParams();
7712
8191
  body.set("type", "firewall");
7713
8192
  body.set("logs", JSON.stringify(batch));
@@ -7724,10 +8203,11 @@ function createFirewallLogReporter(opts) {
7724
8203
  // Both phases carry the attempt controller, so slow transports cannot accumulate work.
7725
8204
  ...controller ? { signal: controller.signal } : {}
7726
8205
  });
7727
- if (p && typeof p.then === "function") await p.catch(() => {
7728
- });
8206
+ const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
8207
+ settle(sent, res && res.ok ? "delivered" : "failed");
7729
8208
  } catch {
7730
8209
  } finally {
8210
+ settle(sent, "failed");
7731
8211
  clearTimeout(attemptTimer);
7732
8212
  if (activeController === controller) activeController = null;
7733
8213
  if (inFlight === entry) inFlight = null;
@@ -7754,7 +8234,11 @@ function createFirewallLogReporter(opts) {
7754
8234
  if (stopped) return;
7755
8235
  const fid = event?.rule?.id;
7756
8236
  if (fid === void 0 || fid === null || fid === "") return;
7757
- if (queue.length >= MAX_QUEUE2) return;
8237
+ if (queue.length >= MAX_QUEUE2) {
8238
+ dropped++;
8239
+ return;
8240
+ }
8241
+ recorded++;
7758
8242
  queue.push({
7759
8243
  fid,
7760
8244
  method: event.method ?? null,
@@ -7770,6 +8254,10 @@ function createFirewallLogReporter(opts) {
7770
8254
  if (!timer) timer = setTimeout(flush, flushMs);
7771
8255
  },
7772
8256
  flush,
8257
+ /** Where the records went so far; see the counters above. `queued` is what is waiting now. */
8258
+ health() {
8259
+ return { recorded, delivered, failed, dropped, queued: queue.length };
8260
+ },
7773
8261
  /**
7774
8262
  * Stop, and hand back a wait for what was outstanding.
7775
8263
  *
@@ -7788,6 +8276,8 @@ function createFirewallLogReporter(opts) {
7788
8276
  ended = true;
7789
8277
  activeController?.abort();
7790
8278
  activeController = null;
8279
+ if (activeBatch) settle(activeBatch, "dropped");
8280
+ dropped += queue.length;
7791
8281
  queue = [];
7792
8282
  inFlight = null;
7793
8283
  };
@@ -7824,165 +8314,6 @@ function telemetryEnabled() {
7824
8314
  return !/^(0|false|off|no)$/i.test(String(v));
7825
8315
  }
7826
8316
 
7827
- // src/protect/supabase-guard.js
7828
- var GUARD_PATH = "/_patchstack/guard";
7829
- var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7830
- var DEFAULT_TIMEOUT_MS = 3e4;
7831
- var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7832
- "connection",
7833
- "cookie",
7834
- "cookie2",
7835
- "forwarded",
7836
- "host",
7837
- "keep-alive",
7838
- "proxy-authenticate",
7839
- "proxy-authorization",
7840
- "te",
7841
- "trailer",
7842
- "transfer-encoding",
7843
- "upgrade",
7844
- "x-forwarded-for",
7845
- "x-forwarded-host",
7846
- "x-forwarded-port",
7847
- "x-forwarded-proto",
7848
- "x-ps-target",
7849
- "x-real-ip"
7850
- ]);
7851
- var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7852
- "connection",
7853
- "content-encoding",
7854
- "content-length",
7855
- "keep-alive",
7856
- "proxy-authenticate",
7857
- "proxy-authorization",
7858
- "set-cookie",
7859
- "set-cookie2",
7860
- "te",
7861
- "trailer",
7862
- "transfer-encoding",
7863
- "upgrade"
7864
- ]);
7865
- var BodyTooLargeError = class extends Error {
7866
- };
7867
- async function boundedBody(request, maxBytes) {
7868
- const stated2 = Number(request.headers.get("content-length"));
7869
- if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7870
- if (!request.body) return void 0;
7871
- const reader = request.body.getReader();
7872
- const chunks = [];
7873
- let total = 0;
7874
- try {
7875
- for (; ; ) {
7876
- const { done, value } = await reader.read();
7877
- if (done) break;
7878
- total += value.byteLength;
7879
- if (total > maxBytes) {
7880
- void reader.cancel().catch(() => {
7881
- });
7882
- throw new BodyTooLargeError();
7883
- }
7884
- chunks.push(value);
7885
- }
7886
- } finally {
7887
- reader.releaseLock();
7888
- }
7889
- const body = new Uint8Array(total);
7890
- let offset = 0;
7891
- for (const chunk of chunks) {
7892
- body.set(chunk, offset);
7893
- offset += chunk.byteLength;
7894
- }
7895
- return body;
7896
- }
7897
- function requestHeaders(input) {
7898
- const output = new Headers();
7899
- const connectionTokens = new Set(
7900
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7901
- );
7902
- input.forEach((value, key) => {
7903
- const lower = key.toLowerCase();
7904
- if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7905
- });
7906
- return output;
7907
- }
7908
- function responseHeaders(input) {
7909
- const output = new Headers();
7910
- const connectionTokens = new Set(
7911
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7912
- );
7913
- input.forEach((value, key) => {
7914
- const lower = key.toLowerCase();
7915
- if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7916
- });
7917
- return output;
7918
- }
7919
- function createSupabaseGuard({
7920
- protection,
7921
- supabaseUrl,
7922
- fetchImpl = fetch,
7923
- maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7924
- timeoutMs = DEFAULT_TIMEOUT_MS
7925
- }) {
7926
- const guard = protection.fetchGuard();
7927
- const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7928
- const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7929
- const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
7930
- return async function handleGuardRequest(request) {
7931
- const target = request.headers.get("x-ps-target");
7932
- if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
7933
- let targetUrl;
7934
- try {
7935
- targetUrl = new URL(target);
7936
- } catch {
7937
- return new Response("patchstack: invalid target", { status: 400 });
7938
- }
7939
- if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
7940
- return new Response("patchstack: target not allowed", { status: 403 });
7941
- }
7942
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
7943
- let body;
7944
- try {
7945
- body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
7946
- } catch (error) {
7947
- if (error instanceof BodyTooLargeError) {
7948
- return new Response("patchstack: request body too large", { status: 413 });
7949
- }
7950
- throw error;
7951
- }
7952
- const forwardHeaders = requestHeaders(request.headers);
7953
- const evalReq = new Request(targetUrl.toString(), {
7954
- method: request.method,
7955
- // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
7956
- // sanitized at the cross-origin boundary below.
7957
- headers: request.headers,
7958
- body
7959
- });
7960
- const blocked = await guard(evalReq);
7961
- if (blocked) return blocked;
7962
- let upstream;
7963
- try {
7964
- upstream = await fetchImpl(targetUrl.toString(), {
7965
- method: request.method,
7966
- headers: forwardHeaders,
7967
- body,
7968
- redirect: "manual",
7969
- signal: AbortSignal.timeout(upstreamTimeout)
7970
- });
7971
- } catch (error) {
7972
- if (error?.name === "TimeoutError" || error?.name === "AbortError") {
7973
- return new Response("patchstack: upstream timed out", { status: 504 });
7974
- }
7975
- throw error;
7976
- }
7977
- const forwarded = new Response(upstream.body, {
7978
- status: upstream.status,
7979
- statusText: upstream.statusText,
7980
- headers: responseHeaders(upstream.headers)
7981
- });
7982
- return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
7983
- };
7984
- }
7985
-
7986
8317
  // src/protect/verify-sentinel.js
7987
8318
  var VERIFY_HEADER = "x-patchstack-verify";
7988
8319
  var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
@@ -8169,20 +8500,47 @@ async function createProtection(options = {}) {
8169
8500
  whitelist_keys: delivered.whitelist_keys,
8170
8501
  onError
8171
8502
  });
8172
- responseRuleSet = responseRules.map((rule) => ({
8173
- rule,
8174
- engine: new RuleEngine({ firewall: [rule], onError }),
8175
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
8176
- // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
8177
- // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
8178
- // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
8179
- // fails such a rule CLOSED (block) instead of serving a no-op redaction.
8180
- mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
8181
- // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
8182
- // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
8183
- // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
8184
- prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
8185
- }));
8503
+ responseRuleSet = responseRules.map((rule) => {
8504
+ const unscoped = [];
8505
+ const rewrites = rule.action === "redact" || rule.action === "encode";
8506
+ let derived;
8507
+ try {
8508
+ const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
8509
+ const mutatedSpan = rewrites && hasSpanMutations(rule);
8510
+ derived = {
8511
+ redactors,
8512
+ mutatedSpan,
8513
+ // A redaction that reads response headers only, and masks spans in them, and an explicit block
8514
+ // that reads response headers only, are decided and carried out without the body — so they
8515
+ // still apply to a response whose body was not screened.
8516
+ redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
8517
+ blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
8518
+ };
8519
+ } catch (err) {
8520
+ notify(onError, err, "onError");
8521
+ derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
8522
+ }
8523
+ if (unscoped.length) {
8524
+ notify(
8525
+ onError,
8526
+ new Error(
8527
+ `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`
8528
+ ),
8529
+ "onError"
8530
+ );
8531
+ }
8532
+ return {
8533
+ rule,
8534
+ engine: new RuleEngine({ firewall: [rule], onError }),
8535
+ ...derived,
8536
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
8537
+ // have any chance of matching. Lets screenText skip the full scan on responses with no candidate
8538
+ // — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
8539
+ // Case-insensitive, and checked against the header values as well as the body, since a rule may
8540
+ // read either.
8541
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
8542
+ };
8543
+ });
8186
8544
  egressRuleSet = egressRules.map((rule) => ({
8187
8545
  rule,
8188
8546
  engine: new RuleEngine({ firewall: [rule], onError })
@@ -8196,6 +8554,9 @@ async function createProtection(options = {}) {
8196
8554
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
8197
8555
  notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
8198
8556
  };
8557
+ const recordEvaluationSkips = (result) => {
8558
+ for (const reason of result?.skips ?? []) recordSkip("request", reason);
8559
+ };
8199
8560
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
8200
8561
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
8201
8562
  const planCache = createPlanCache();
@@ -8238,15 +8599,19 @@ async function createProtection(options = {}) {
8238
8599
  });
8239
8600
  return effectiveMode === "block" ? block() : allow2();
8240
8601
  };
8602
+ const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
8603
+ const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
8241
8604
  const screenText = (text, meta, reqCtx, only) => {
8242
8605
  let blockRule = null;
8243
8606
  const redactions = [];
8244
8607
  const headerMutations = [];
8245
8608
  let lowerText = null;
8246
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
8247
- if (only && !only(rule)) continue;
8609
+ const responseSkips = /* @__PURE__ */ new Set();
8610
+ for (const entry of responseRuleSet) {
8611
+ const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
8612
+ if (only && !only(rule, entry)) continue;
8248
8613
  if (prefilter) {
8249
- if (lowerText === null) lowerText = text.toLowerCase();
8614
+ if (lowerText === null) lowerText = prefilterText(text, meta.headers);
8250
8615
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
8251
8616
  }
8252
8617
  let result;
@@ -8256,6 +8621,11 @@ async function createProtection(options = {}) {
8256
8621
  notify(onError, err, "onError");
8257
8622
  continue;
8258
8623
  }
8624
+ for (const reason of result.skips ?? []) {
8625
+ if (responseSkips.has(reason)) continue;
8626
+ responseSkips.add(reason);
8627
+ recordSkip("response", reason);
8628
+ }
8259
8629
  if (!result.blocked) continue;
8260
8630
  const responseMode = ruleMode(rule);
8261
8631
  onDetect({
@@ -8285,6 +8655,8 @@ async function createProtection(options = {}) {
8285
8655
  if (blockRule) return { verdict: "block" };
8286
8656
  let body = text;
8287
8657
  const headers = { ...meta.headers || {} };
8658
+ let responseIsJson;
8659
+ let structure;
8288
8660
  for (const { rule, redactors } of redactions) {
8289
8661
  const mask = maskFn(rule.category);
8290
8662
  const transform = rule.action === "encode" ? htmlEscape : null;
@@ -8293,23 +8665,32 @@ async function createProtection(options = {}) {
8293
8665
  if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
8294
8666
  if (!spanRedactors.length) continue;
8295
8667
  const beforeSpan = body;
8296
- body = applyRedactors(body, spanRedactors, mask, transform);
8297
- if (body !== beforeSpan && !preservesJsonStructure(beforeSpan, body)) return { verdict: "block" };
8668
+ const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
8669
+ if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
8670
+ if (body !== beforeSpan) {
8671
+ responseIsJson ??= isJson(text);
8672
+ if (responseIsJson) {
8673
+ if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
8674
+ const tokens = lexJson(body);
8675
+ if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
8676
+ structure = { text: body, tokens };
8677
+ }
8678
+ }
8298
8679
  if (transform) continue;
8299
8680
  for (const name of Object.keys(headers)) {
8300
8681
  const value = headers[name];
8682
+ const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
8301
8683
  if (typeof value === "string") {
8302
- setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
8684
+ setOwn(headers, name, applyRedactors(value, headerSpans, mask));
8303
8685
  } else if (Array.isArray(value)) {
8304
8686
  setOwn(
8305
8687
  headers,
8306
8688
  name,
8307
- value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
8689
+ value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
8308
8690
  );
8309
8691
  }
8310
8692
  }
8311
8693
  }
8312
- if (body !== text && isJson(text) && !isJson(body)) return { verdict: "block" };
8313
8694
  for (const rule of headerMutations) applyHeaderMutation(headers, rule);
8314
8695
  return { verdict: "redact", body, headers };
8315
8696
  };
@@ -8352,18 +8733,53 @@ async function createProtection(options = {}) {
8352
8733
  }
8353
8734
  };
8354
8735
  };
8355
- const screenFetchRequest = async (request) => {
8736
+ const peerOf = (request, hostArgs) => {
8737
+ if (typeof options.peerAddress !== "function") return void 0;
8738
+ try {
8739
+ return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
8740
+ } catch (err) {
8741
+ notify(onError, err, "onError");
8742
+ return void 0;
8743
+ }
8744
+ };
8745
+ let warnedNoPeer = false;
8746
+ const warnNoPeer = () => {
8747
+ if (warnedNoPeer) return;
8748
+ warnedNoPeer = true;
8749
+ 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.";
8750
+ if (typeof onError === "function") notify(onError, new Error(message), "onError");
8751
+ else console.warn(message);
8752
+ };
8753
+ const clientByRequest = /* @__PURE__ */ new WeakMap();
8754
+ const clientForResponse = (request, hostArgs) => {
8755
+ const known = clientByRequest.get(request);
8756
+ if (known) return known;
8757
+ const client = resolveClientIp({
8758
+ peer: peerOf(request, hostArgs),
8759
+ headers: headerObject(request.headers),
8760
+ trustedProxy: options.trustedProxy
8761
+ });
8762
+ if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
8763
+ return client;
8764
+ };
8765
+ const screenFetchRequest = async (request, hostArgs = []) => {
8356
8766
  let result;
8357
8767
  let shaped;
8358
8768
  try {
8359
8769
  shaped = await fromFetchRequest(request, {
8770
+ peer: peerOf(request, hostArgs),
8360
8771
  trustedProxy: options.trustedProxy,
8361
8772
  maxBodyBytes: options.maxBodyBytes
8362
8773
  });
8774
+ if (shaped?._clientIp) {
8775
+ clientByRequest.set(request, shaped._clientIp);
8776
+ if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
8777
+ }
8363
8778
  if (shaped?._bodyInspectionSkip) {
8364
8779
  recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
8365
8780
  }
8366
8781
  result = engine.evaluate(shaped);
8782
+ recordEvaluationSkips(result);
8367
8783
  } catch (err) {
8368
8784
  notify(onError, err, "onError");
8369
8785
  return { blocked: null, client: void 0 };
@@ -8413,15 +8829,19 @@ async function createProtection(options = {}) {
8413
8829
  const read = await readTextResponse(response, screenCap);
8414
8830
  if (read.skip) {
8415
8831
  if (read.skip !== "not-a-response") {
8416
- recordSkip("response", read.skip, { status: response?.status, ...read.encoding ? { encoding: read.encoding } : {} });
8832
+ recordSkip("response", read.skip, {
8833
+ status: response?.status,
8834
+ ...read.encoding ? { encoding: read.encoding } : {},
8835
+ ...read.charset ? { charset: read.charset } : {}
8836
+ });
8417
8837
  }
8418
8838
  if (read.skip === "not-a-response") return response;
8419
8839
  return hardenHeadersOnly(response, reqCtx);
8420
8840
  }
8421
- const text = read.text;
8841
+ const text = readJsonEscapes(read.text);
8422
8842
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
8423
- if (r.verdict === "block") return leakResponse();
8424
- if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
8843
+ if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
8844
+ if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
8425
8845
  return response;
8426
8846
  };
8427
8847
  const headerValueChanged = (was, value) => {
@@ -8483,7 +8903,8 @@ async function createProtection(options = {}) {
8483
8903
  const hardenHeadersOnly = (response, reqCtx) => {
8484
8904
  try {
8485
8905
  const meta = { status: response.status, headers: headerObject(response.headers) };
8486
- const r = screenText("", meta, reqCtx, hardensWithoutBody);
8906
+ const r = screenText("", meta, reqCtx, decidedWithoutBody);
8907
+ if (r.verdict === "block") return leakResponse();
8487
8908
  if (r.verdict !== "redact" || !r.headers) return response;
8488
8909
  if (!headersChanged(meta.headers, r.headers)) return response;
8489
8910
  return rebuildResponse(response, response.body, r.headers);
@@ -8503,6 +8924,8 @@ async function createProtection(options = {}) {
8503
8924
  const chunks = [];
8504
8925
  let size = 0;
8505
8926
  let overflow = false;
8927
+ let withheld = false;
8928
+ let headScreened = false;
8506
8929
  const MAX = screenCap;
8507
8930
  let hardened = false;
8508
8931
  let answeredWithoutBody = false;
@@ -8534,6 +8957,7 @@ async function createProtection(options = {}) {
8534
8957
  }
8535
8958
  };
8536
8959
  const stillToAnswer = (rule) => !hardensWithoutBody(rule);
8960
+ const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
8537
8961
  let pendingHead = null;
8538
8962
  let sending = false;
8539
8963
  const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
@@ -8595,7 +9019,12 @@ async function createProtection(options = {}) {
8595
9019
  if (typeof res.flushHeaders === "function") {
8596
9020
  const origFlushHeaders = res.flushHeaders.bind(res);
8597
9021
  res.flushHeaders = function() {
8598
- sendHead();
9022
+ const unread = screenUnreadHead();
9023
+ if (unread?.withhold) {
9024
+ sendWithheld();
9025
+ return void 0;
9026
+ }
9027
+ sendHead(unread);
8599
9028
  return origFlushHeaders();
8600
9029
  };
8601
9030
  }
@@ -8630,6 +9059,32 @@ async function createProtection(options = {}) {
8630
9059
  headers: at === -1 ? set2 : { ...set2, ...writeHeadHeaderObject(pendingHead[at]) }
8631
9060
  };
8632
9061
  };
9062
+ const screenUnreadHead = () => {
9063
+ if (headScreened) return void 0;
9064
+ headScreened = true;
9065
+ try {
9066
+ const head = effectiveHead();
9067
+ const r = screenText("", head, reqCtx, decidedByHeaders);
9068
+ if (r.verdict === "block") {
9069
+ if (!reallySent()) return { withhold: true };
9070
+ recordSkip("response", "headers-sent", { action: "block" });
9071
+ return void 0;
9072
+ }
9073
+ if (r.verdict !== "redact" || !r.headers) return void 0;
9074
+ const changed = /* @__PURE__ */ new Map();
9075
+ for (const [name, value] of Object.entries(r.headers)) {
9076
+ if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
9077
+ }
9078
+ if (changed.size && reallySent()) {
9079
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
9080
+ return void 0;
9081
+ }
9082
+ return changed.size ? { headers: changed } : void 0;
9083
+ } catch (err) {
9084
+ notify(onError, err, "onError");
9085
+ return void 0;
9086
+ }
9087
+ };
8633
9088
  const sendHead = (changes) => {
8634
9089
  if (sending) return;
8635
9090
  const hardened2 = hardenOnce();
@@ -8651,6 +9106,17 @@ async function createProtection(options = {}) {
8651
9106
  applyToResponse({ headers: merged });
8652
9107
  origWriteHead(...args);
8653
9108
  };
9109
+ const sendWithheld = (cb) => {
9110
+ const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
9111
+ const headers = /* @__PURE__ */ new Map();
9112
+ for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
9113
+ headers.set("content-type", "application/json");
9114
+ headers.set("content-length", String(Buffer.byteLength(body)));
9115
+ headers.set("transfer-encoding", null);
9116
+ withheld = true;
9117
+ sendHead({ status: 500, headers });
9118
+ return origEnd(body, cb);
9119
+ };
8654
9120
  const applyToResponse = (changes) => {
8655
9121
  if (changes.status !== void 0) res.statusCode = changes.status;
8656
9122
  for (const [name, value] of changes.headers ?? []) {
@@ -8666,7 +9132,12 @@ async function createProtection(options = {}) {
8666
9132
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
8667
9133
  size += buf.length;
8668
9134
  if (size > MAX) {
8669
- sendHead();
9135
+ const unread = screenUnreadHead();
9136
+ if (unread?.withhold) {
9137
+ sendWithheld();
9138
+ return;
9139
+ }
9140
+ sendHead(unread);
8670
9141
  for (const c of chunks) origWrite(c);
8671
9142
  chunks.length = 0;
8672
9143
  origWrite(buf);
@@ -8677,6 +9148,11 @@ async function createProtection(options = {}) {
8677
9148
  chunks.push(buf);
8678
9149
  };
8679
9150
  res.write = function(chunk, enc, cb) {
9151
+ if (withheld) {
9152
+ if (typeof enc === "function") enc();
9153
+ else if (typeof cb === "function") cb();
9154
+ return true;
9155
+ }
8680
9156
  if (overflow) return origWrite(chunk, enc, cb);
8681
9157
  collect(chunk, enc);
8682
9158
  if (typeof enc === "function") enc();
@@ -8692,15 +9168,24 @@ async function createProtection(options = {}) {
8692
9168
  cb = enc;
8693
9169
  enc = void 0;
8694
9170
  }
9171
+ if (withheld) {
9172
+ if (typeof cb === "function") cb();
9173
+ return res;
9174
+ }
8695
9175
  if (overflow) {
8696
9176
  if (chunk != null) origWrite(chunk, enc);
8697
9177
  return origEnd(cb);
8698
9178
  }
8699
9179
  collect(chunk, enc);
9180
+ if (withheld) {
9181
+ if (typeof cb === "function") cb();
9182
+ return res;
9183
+ }
8700
9184
  if (overflow) return origEnd(cb);
8701
9185
  hardenOnce();
8702
- const passThrough = () => {
8703
- sendHead();
9186
+ const passThrough = (changes) => {
9187
+ if (changes?.withhold) return sendWithheld(cb);
9188
+ sendHead(changes);
8704
9189
  for (const c of chunks) origWrite(c);
8705
9190
  return origEnd(cb);
8706
9191
  };
@@ -8711,21 +9196,28 @@ async function createProtection(options = {}) {
8711
9196
  const kind = screenableContentType(ct);
8712
9197
  if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
8713
9198
  recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
8714
- return passThrough();
9199
+ return passThrough(screenUnreadHead());
8715
9200
  }
8716
9201
  const codings = declaredCodings(head.headers["content-encoding"]);
8717
9202
  if (codings.length > 0 && stillEncoded(buffer)) {
8718
9203
  recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
8719
- return passThrough();
9204
+ return passThrough(screenUnreadHead());
9205
+ }
9206
+ const reading = charsetReading(ct, buffer);
9207
+ if (reading.skip) {
9208
+ recordSkip("response", "unsupported-charset", { charset: reading.skip });
9209
+ return passThrough(screenUnreadHead());
8720
9210
  }
8721
- const text = buffer.toString("utf8");
9211
+ const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
8722
9212
  let r;
8723
9213
  try {
8724
- r = screenText(text, head, reqCtx, answeredWithoutBody ? stillToAnswer : void 0);
9214
+ r = screenText(text, head, reqCtx, notYetAnswered);
8725
9215
  } catch (err) {
8726
9216
  notify(onError, err, "onError");
8727
9217
  return passThrough();
8728
9218
  }
9219
+ if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
9220
+ if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
8729
9221
  if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
8730
9222
  if (reallySent() && res.chunkedEncoding !== true) {
8731
9223
  notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
@@ -8733,20 +9225,11 @@ async function createProtection(options = {}) {
8733
9225
  return res;
8734
9226
  }
8735
9227
  if (r.verdict === "block") {
8736
- const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
8737
9228
  if (reallySent()) {
8738
9229
  res.destroy?.();
8739
9230
  return res;
8740
9231
  }
8741
- sendHead({
8742
- status: 500,
8743
- headers: /* @__PURE__ */ new Map([
8744
- ["content-type", "application/json"],
8745
- ["content-length", String(Buffer.byteLength(body))],
8746
- ["transfer-encoding", null]
8747
- ])
8748
- });
8749
- return origEnd(body, cb);
9232
+ return sendWithheld(cb);
8750
9233
  }
8751
9234
  const changed = /* @__PURE__ */ new Map();
8752
9235
  if (r.headers) {
@@ -8842,21 +9325,15 @@ async function createProtection(options = {}) {
8842
9325
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
8843
9326
  // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
8844
9327
  // upstream response. It resolves once here, which is the only resolution for this call.
8845
- screenResponse: (response, request) => screenResp(
8846
- response,
8847
- request ? reqContextFromFetch(
8848
- request,
8849
- resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
8850
- ) : void 0
8851
- ),
9328
+ screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
8852
9329
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
8853
9330
  fetchGuard() {
8854
- return async (request) => (await screenFetchRequest(request)).blocked;
9331
+ return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
8855
9332
  },
8856
9333
  // Wrap a fetch handler: screens the request, then the response (redact/block).
8857
9334
  fetch(handler) {
8858
9335
  return async (request, ...rest) => {
8859
- const { blocked, client } = await screenFetchRequest(request);
9336
+ const { blocked, client } = await screenFetchRequest(request, rest);
8860
9337
  if (blocked) return blocked;
8861
9338
  const response = await handler(request, ...rest);
8862
9339
  return screenResp(response, reqContextFromFetch(request, client));
@@ -8870,6 +9347,7 @@ async function createProtection(options = {}) {
8870
9347
  const { shaped, client } = shapeExpressRequest(req);
8871
9348
  try {
8872
9349
  result = engine.evaluate(shaped);
9350
+ recordEvaluationSkips(result);
8873
9351
  } catch (err) {
8874
9352
  notify(onError, err, "onError");
8875
9353
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
@@ -8904,33 +9382,25 @@ async function createProtection(options = {}) {
8904
9382
  screenNodeRequest(req, res, next, "", req.body);
8905
9383
  return;
8906
9384
  }
8907
- const chunks = [];
8908
- let size = 0;
8909
- let overflow = false;
8910
- req.on("data", (chunk) => {
8911
- size += chunk.length;
8912
- if (size > maxBytes) {
8913
- overflow = true;
9385
+ readBodyPrefix(req, maxBytes, (err, read) => {
9386
+ if (err) {
9387
+ notify(onError, err, "onError");
9388
+ next();
8914
9389
  return;
8915
9390
  }
8916
- chunks.push(chunk);
8917
- });
8918
- req.on("error", (err) => {
8919
- notify(onError, err, "onError");
8920
- next();
8921
- });
8922
- req.on("end", () => {
8923
- if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
8924
- screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
9391
+ if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
9392
+ if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
9393
+ screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
8925
9394
  });
8926
9395
  };
8927
- function screenNodeRequest(req, res, next, rawBody, parsedBody) {
9396
+ function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
8928
9397
  let shaped;
8929
9398
  let result;
8930
9399
  try {
8931
9400
  shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
8932
9401
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
8933
9402
  result = engine.evaluate(shaped);
9403
+ recordEvaluationSkips(result);
8934
9404
  } catch (err) {
8935
9405
  notify(onError, err, "onError");
8936
9406
  return next();
@@ -8949,7 +9419,7 @@ async function createProtection(options = {}) {
8949
9419
  }
8950
9420
  },
8951
9421
  () => {
8952
- if (req.body === void 0) req.body = shaped.body;
9422
+ if (req.body === void 0 && !truncated) req.body = shaped.body;
8953
9423
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
8954
9424
  next();
8955
9425
  },
@@ -9015,12 +9485,17 @@ async function createProtection(options = {}) {
9015
9485
  protection.stop = () => {
9016
9486
  loop?.stop();
9017
9487
  recovery?.stop();
9488
+ protection.uninstallEgress?.();
9018
9489
  const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
9019
9490
  (wait) => wait && typeof wait.then === "function"
9020
9491
  );
9021
9492
  return Promise.all(outstanding).then(() => void 0);
9022
9493
  };
9023
- protection.stopRefresh = protection.stop;
9494
+ protection.stopRefresh = () => {
9495
+ loop?.stop();
9496
+ recovery?.stop();
9497
+ return Promise.resolve();
9498
+ };
9024
9499
  Object.defineProperty(protection, "ruleSource", {
9025
9500
  get: () => ({ ...ruleSource }),
9026
9501
  enumerable: true
@@ -9033,6 +9508,10 @@ async function createProtection(options = {}) {
9033
9508
  get: () => detections ? () => detections.health() : void 0,
9034
9509
  enumerable: true
9035
9510
  });
9511
+ Object.defineProperty(protection, "blockLogHealth", {
9512
+ get: () => firewallLog ? () => firewallLog.health() : void 0,
9513
+ enumerable: true
9514
+ });
9036
9515
  return protection;
9037
9516
  }
9038
9517
  function accessorOf(obj, name) {
@@ -9107,6 +9586,14 @@ function screenableContentType(ct) {
9107
9586
  if (base === "application/octet-stream") return "sniff";
9108
9587
  return "skip";
9109
9588
  }
9589
+ function prefilterText(body, headers) {
9590
+ const values = [];
9591
+ for (const value of Object.values(headers ?? {})) {
9592
+ if (Array.isArray(value)) values.push(...value.map(String));
9593
+ else if (value !== void 0 && value !== null) values.push(String(value));
9594
+ }
9595
+ return [body, ...values].join("\n").toLowerCase();
9596
+ }
9110
9597
  function looksBinary(bytes) {
9111
9598
  const n = Math.min(bytes.length, 512);
9112
9599
  let ctrl = 0;
@@ -9130,6 +9617,52 @@ function stillEncoded(bytes) {
9130
9617
  return true;
9131
9618
  }
9132
9619
  }
9620
+ var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
9621
+ var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
9622
+ function declaredCharset(ct) {
9623
+ const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
9624
+ const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
9625
+ return label || null;
9626
+ }
9627
+ function charsetReading(ct, bytes) {
9628
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
9629
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
9630
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
9631
+ const charset = declaredCharset(ct);
9632
+ if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
9633
+ if (WIDE_CHARSET.test(charset)) return { skip: charset };
9634
+ for (let i = 0; i < bytes.length; i++) {
9635
+ const b = bytes[i];
9636
+ if (b >= 128 || b === 0 || b === 27) return { skip: charset };
9637
+ }
9638
+ return { ascii: true };
9639
+ }
9640
+ function decodeForScreening(ct, bytes) {
9641
+ const reading = charsetReading(ct, bytes);
9642
+ if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
9643
+ return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
9644
+ }
9645
+ function withBom(bom, body) {
9646
+ return bom ? "\uFEFF" + body : body;
9647
+ }
9648
+ function outgrowsCharset(ascii, body) {
9649
+ return ascii === true && /[^\x00-\x7f]/.test(body);
9650
+ }
9651
+ var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
9652
+ function readJsonEscapes(text) {
9653
+ if (!text.includes("\\u") && !text.includes("\\/")) return text;
9654
+ try {
9655
+ JSON.parse(text);
9656
+ } catch {
9657
+ return text;
9658
+ }
9659
+ return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
9660
+ if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
9661
+ const code = parseInt(hex, 16);
9662
+ if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
9663
+ return String.fromCharCode(code);
9664
+ });
9665
+ }
9133
9666
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
9134
9667
  if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
9135
9668
  const ct = response.headers?.get?.("content-type") || "";
@@ -9183,17 +9716,18 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
9183
9716
  const bytes = concatBytes(chunks, size);
9184
9717
  if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
9185
9718
  try {
9186
- return { text: new TextDecoder().decode(bytes) };
9719
+ return decodeForScreening(ct, bytes);
9187
9720
  } catch {
9188
9721
  return { skip: "decode-failed" };
9189
9722
  }
9190
9723
  }
9191
9724
  try {
9192
- if (codings.length > 0) {
9725
+ if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
9193
9726
  const bytes = new Uint8Array(await clone.arrayBuffer());
9194
9727
  if (bytes.byteLength > cap) return { skip: "body-cap" };
9195
- if (stillEncoded(bytes)) return encodedSkip();
9196
- return { text: new TextDecoder().decode(bytes) };
9728
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
9729
+ if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
9730
+ return decodeForScreening(ct, bytes);
9197
9731
  }
9198
9732
  const text = await clone.text();
9199
9733
  if (text.length > cap) return { skip: "body-cap" };
@@ -9224,8 +9758,9 @@ function headerObject(headers) {
9224
9758
  function hasSpanMutations(rule) {
9225
9759
  let found = false;
9226
9760
  const walk2 = (conds) => {
9227
- for (const c of conds ?? []) {
9761
+ for (const c of Array.isArray(conds) ? conds : []) {
9228
9762
  if (found) return;
9763
+ if (!c || typeof c !== "object") continue;
9229
9764
  if (Array.isArray(c.rules)) walk2(c.rules);
9230
9765
  const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
9231
9766
  if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
@@ -9234,10 +9769,34 @@ function hasSpanMutations(rule) {
9234
9769
  walk2(rule.rule_v2);
9235
9770
  return found;
9236
9771
  }
9237
- function extractRedactors(rule) {
9772
+ function redactorScope(parameter) {
9773
+ if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
9774
+ const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
9775
+ for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
9776
+ if (typeof name !== "string" || name === "") return null;
9777
+ if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
9778
+ else if (name === "response.headers") scope.allHeaders = true;
9779
+ else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
9780
+ scope.body = true;
9781
+ scope.allHeaders = true;
9782
+ } else return null;
9783
+ }
9784
+ return scope;
9785
+ }
9786
+ var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
9787
+ function masksHeader(redactor, name) {
9788
+ return redactor.scope.allHeaders || redactor.scope.headers.has(name);
9789
+ }
9790
+ function extractRedactors(rule, onUnscoped) {
9238
9791
  const out = [];
9792
+ const scoped = (c) => {
9793
+ const scope = redactorScope(c.parameter);
9794
+ if (scope === null) onUnscoped?.(c.parameter);
9795
+ return scope;
9796
+ };
9239
9797
  const walk2 = (conds) => {
9240
- for (const c of conds ?? []) {
9798
+ for (const c of Array.isArray(conds) ? conds : []) {
9799
+ if (!c || typeof c !== "object") continue;
9241
9800
  if (Array.isArray(c.rules)) walk2(c.rules);
9242
9801
  const m = c.match;
9243
9802
  if (!m) continue;
@@ -9245,16 +9804,21 @@ function extractRedactors(rule) {
9245
9804
  const safe = safeRegExp(m.value);
9246
9805
  if (safe) {
9247
9806
  const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
9807
+ const scope = scoped(c);
9248
9808
  try {
9249
- out.push({ re: new RegExp(safe.source, flags) });
9809
+ if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
9250
9810
  } catch {
9251
9811
  }
9252
9812
  }
9253
9813
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
9254
- out.push({ literal: String(m.value) });
9814
+ const scope = scoped(c);
9815
+ if (scope) out.push({ literal: String(m.value), scope });
9255
9816
  } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
9256
- out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
9257
- } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
9817
+ const scope = scoped(c);
9818
+ if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
9819
+ } else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
9820
+ onUnscoped?.(c.parameter);
9821
+ } else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
9258
9822
  const keys = Array.isArray(m.key) ? m.key : [m.key];
9259
9823
  for (const key of keys) {
9260
9824
  out.push({ jsonPath: String(key).split("."), condition: m.match });
@@ -9276,30 +9840,101 @@ function isJson(text) {
9276
9840
  return false;
9277
9841
  }
9278
9842
  }
9279
- function preservesJsonStructure(before, after) {
9280
- if (!isJson(before)) return true;
9281
- if (!isJson(after)) return false;
9282
- const expected = jsonStructure(before);
9283
- const actual = jsonStructure(after);
9284
- for (; ; ) {
9285
- const left = expected.next();
9286
- const right = actual.next();
9287
- if (left.done || right.done) return left.done === right.done;
9288
- if (left.value !== right.value) return false;
9289
- }
9843
+ var JSON_STRING = 1;
9844
+ var JSON_NUMBER = 2;
9845
+ var JSON_LITERAL = 3;
9846
+ var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
9847
+ function isHexCode(c) {
9848
+ return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
9290
9849
  }
9291
- function* jsonStructure(text) {
9292
- const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|[^\s]/g;
9293
- for (const match of text.matchAll(tokens)) {
9294
- const token = match[0];
9295
- if (token[0] !== '"') {
9296
- yield "token:" + token;
9850
+ var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
9851
+ function lexJson(text) {
9852
+ let kinds = new Int32Array(1024);
9853
+ let spans = new Int32Array(2048);
9854
+ let count = 0;
9855
+ const n = text.length;
9856
+ let i = 0;
9857
+ while (i < n) {
9858
+ const c = text.charCodeAt(i);
9859
+ if (c === 32 || c === 9 || c === 10 || c === 13) {
9860
+ i++;
9297
9861
  continue;
9298
9862
  }
9299
- let next = match.index + token.length;
9300
- while (text[next] === " " || text[next] === " " || text[next] === "\r" || text[next] === "\n") next++;
9301
- yield text[next] === ":" ? "key:" + JSON.parse(token) : "string";
9863
+ const start = i;
9864
+ let kind;
9865
+ if (c === 34) {
9866
+ i++;
9867
+ const close = text.indexOf('"', i);
9868
+ if (close === -1) return null;
9869
+ if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
9870
+ i = close;
9871
+ } else {
9872
+ for (; ; ) {
9873
+ if (i >= n) return null;
9874
+ const d = text.charCodeAt(i);
9875
+ if (d === 34) break;
9876
+ if (d < 32) return null;
9877
+ if (d !== 92) {
9878
+ i++;
9879
+ continue;
9880
+ }
9881
+ const e = text.charCodeAt(i + 1);
9882
+ if (e === 117) {
9883
+ for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
9884
+ i += 6;
9885
+ } else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
9886
+ i += 2;
9887
+ } else {
9888
+ return null;
9889
+ }
9890
+ }
9891
+ }
9892
+ i++;
9893
+ kind = JSON_STRING;
9894
+ } else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
9895
+ i++;
9896
+ kind = c;
9897
+ } else if (text.startsWith("true", i) || text.startsWith("null", i)) {
9898
+ i += 4;
9899
+ kind = JSON_LITERAL;
9900
+ } else if (text.startsWith("false", i)) {
9901
+ i += 5;
9902
+ kind = JSON_LITERAL;
9903
+ } else {
9904
+ JSON_NUMBER_TOKEN.lastIndex = i;
9905
+ if (!JSON_NUMBER_TOKEN.test(text)) return null;
9906
+ i = JSON_NUMBER_TOKEN.lastIndex;
9907
+ kind = JSON_NUMBER;
9908
+ }
9909
+ if (count === kinds.length) {
9910
+ const grownKinds = new Int32Array(count * 2);
9911
+ grownKinds.set(kinds);
9912
+ kinds = grownKinds;
9913
+ const grownSpans = new Int32Array(count * 4);
9914
+ grownSpans.set(spans);
9915
+ spans = grownSpans;
9916
+ }
9917
+ kinds[count] = kind;
9918
+ spans[2 * count] = start;
9919
+ spans[2 * count + 1] = i;
9920
+ count++;
9921
+ }
9922
+ return { kinds, spans, count };
9923
+ }
9924
+ function sameJsonStructure(beforeText, before, afterText, after) {
9925
+ if (!before || !after || before.count !== after.count) return false;
9926
+ const { kinds, spans, count } = before;
9927
+ for (let k = 0; k < count; k++) {
9928
+ const kind = kinds[k];
9929
+ if (after.kinds[k] !== kind) return false;
9930
+ if (kind > JSON_LITERAL) continue;
9931
+ if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
9932
+ const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
9933
+ const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
9934
+ if (was === now) continue;
9935
+ if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
9302
9936
  }
9937
+ return true;
9303
9938
  }
9304
9939
  function applyRedactors(body, redactors, mask, transform) {
9305
9940
  let out = body;
@@ -9316,6 +9951,11 @@ function applyRedactors(body, redactors, mask, transform) {
9316
9951
  }
9317
9952
  return out;
9318
9953
  }
9954
+ function readsBody(parameter) {
9955
+ if (!Array.isArray(parameter)) return isBodyParam(parameter);
9956
+ const names = parameter.every((member) => typeof member === "string" && member !== "");
9957
+ return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
9958
+ }
9319
9959
  function isBodyParam(parameter) {
9320
9960
  return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
9321
9961
  }
@@ -9347,7 +9987,7 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
9347
9987
  let obj;
9348
9988
  try {
9349
9989
  JSON.parse(text);
9350
- preserved = preserveBigInts(text, mask);
9990
+ preserved = preserveNumbers(text, mask);
9351
9991
  obj = JSON.parse(preserved.text);
9352
9992
  } catch {
9353
9993
  return text;
@@ -9366,22 +10006,22 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
9366
10006
  }
9367
10007
  });
9368
10008
  }
9369
- return changed ? restoreBigInts(JSON.stringify(obj), preserved.numbers) : text;
10009
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
9370
10010
  }
9371
- function preserveBigInts(text, mask) {
10011
+ function preserveNumbers(text, mask) {
9372
10012
  const occupied = /* @__PURE__ */ new Set([mask]);
9373
- const integers = [];
10013
+ const found = [];
9374
10014
  const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
9375
10015
  for (const match of text.matchAll(tokens)) {
9376
10016
  const token = match[0];
9377
10017
  if (token[0] === '"') occupied.add(JSON.parse(token));
9378
- else if (/^-?\d{16,}$/.test(token)) integers.push({ start: match.index, token });
10018
+ else found.push({ start: match.index, token });
9379
10019
  }
9380
10020
  const numbers = /* @__PURE__ */ new Map();
9381
10021
  const chunks = [];
9382
10022
  let offset = 0;
9383
10023
  let index = 0;
9384
- for (const { start, token } of integers) {
10024
+ for (const { start, token } of found) {
9385
10025
  let marker;
9386
10026
  do {
9387
10027
  marker = "__PSNUMBER_" + index++ + "__";
@@ -9393,7 +10033,7 @@ function preserveBigInts(text, mask) {
9393
10033
  chunks.push(text.slice(offset));
9394
10034
  return { text: chunks.join(""), numbers };
9395
10035
  }
9396
- function restoreBigInts(text, numbers) {
10036
+ function restoreNumbers(text, numbers) {
9397
10037
  return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
9398
10038
  }
9399
10039
  function isHeaderMutation(action) {