@patchstack/connect 0.5.12 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protect.cjs CHANGED
@@ -397,13 +397,13 @@ function buildEndpointUrl(base, siteUuid) {
397
397
  function authFailureMessage(status, config) {
398
398
  const hasCredential = typeof config.pulseAuth === "string" && config.pulseAuth.length > 0;
399
399
  if (status === 401 && !hasCredential) {
400
- return "Patchstack requires an API credential for this site and none is configured. Run `npx patchstack-connect login`, or set PATCHSTACK_API_KEY.";
400
+ return "Patchstack needs this project's API key, and none is set here. Run npx @patchstack/connect login, or set PATCHSTACK_API_KEY.";
401
401
  }
402
402
  if (status === 401) {
403
- return "Patchstack rejected this API credential. It may have expired, been revoked, or the site may no longer exist. Run `npx patchstack-connect login` to issue a new one.";
403
+ return "Patchstack did not accept this project's API key: it may have expired or been revoked, or the project may no longer exist. Run npx @patchstack/connect login to get a new one.";
404
404
  }
405
405
  if (status === 403) {
406
- return "This API credential is not permitted to act on this site. Check that siteUuid in .patchstackrc.json matches the credential (a credential is issued for one site).";
406
+ return "This API key belongs to a different project. Check that siteUuid in .patchstackrc.json is the project the key was made for.";
407
407
  }
408
408
  return null;
409
409
  }
@@ -1145,7 +1145,7 @@ function unreadableWarning(filename, report) {
1145
1145
  if (report.unreadable === 0) return null;
1146
1146
  const shown = report.samples.join(", ");
1147
1147
  const one = report.unreadable === 1;
1148
- return `${filename}: ${report.unreadable} entr${one ? "y was" : "ies were"} not in a form this scanner reads (${shown}${report.unreadable > report.samples.length ? ", \u2026" : ""}), so ${one ? "it is" : "they are"} absent from this manifest. Anything installed only through ${one ? "that entry" : "those entries"} is not being checked.`;
1148
+ return `${filename}: ${report.unreadable} entr${one ? "y" : "ies"} could not be read (${shown}${report.unreadable > report.samples.length ? ", \u2026" : ""}), so ${one ? "that package is" : "those packages are"} not being checked.`;
1149
1149
  }
1150
1150
  var MAX_SAMPLES;
1151
1151
  var init_report = __esm({
@@ -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(
2086
- walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
2085
+ warnings2.push(
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(
2098
- `No fully-consistent source found; reporting ${firstParsed.filename}. The manifest may understate the real dependency set.`
2097
+ warnings2.push(
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(
@@ -2109,12 +2109,12 @@ function otherCompleteSources(all, current, declared) {
2109
2109
  }
2110
2110
  function conflictWarning(filename, conflicts) {
2111
2111
  const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
2112
- return `${filename} and another lockfile disagree about installed versions (${shown}${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ""}). Scanned node_modules/ instead. Remove whichever lockfile your package manager does not maintain.`;
2112
+ return `${filename} and another package list file give different versions (${shown}${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ""}). Checked node_modules/ instead. Delete the file your package manager does not use.`;
2113
2113
  }
2114
2114
  function staleWarning(source, missing) {
2115
2115
  const sample = missing.slice(0, 3).join(", ");
2116
2116
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
2117
- return `${source} looks stale: package.json declares ${missing.length} dependenc${missing.length === 1 ? "y" : "ies"} it does not contain (${sample}${suffix}).`;
2117
+ return `${source} is out of date: it is missing ${missing.length} package${missing.length === 1 ? "" : "s"} listed in package.json (${sample}${suffix}). Run ${INSTALL_BY_SOURCE[source] ?? "your install command"} to update it.`;
2118
2118
  }
2119
2119
  async function presentLockfiles(cwd) {
2120
2120
  const probed = await Promise.all(
@@ -2149,7 +2149,7 @@ async function exists(filePath) {
2149
2149
  return false;
2150
2150
  }
2151
2151
  }
2152
- var import_promises9, import_node_path8, LOCKFILE_CANDIDATES;
2152
+ var import_promises9, import_node_path8, LOCKFILE_CANDIDATES, INSTALL_BY_SOURCE;
2153
2153
  var init_parsers = __esm({
2154
2154
  "src/parsers/index.ts"() {
2155
2155
  "use strict";
@@ -2173,6 +2173,13 @@ var init_parsers = __esm({
2173
2173
  { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
2174
2174
  { filename: "yarn.lock", strategy: "yarn-lockfile" }
2175
2175
  ];
2176
+ INSTALL_BY_SOURCE = {
2177
+ "package-lock.json": "npm install",
2178
+ "bun.lock": "bun install",
2179
+ "bun.lockb": "bun install",
2180
+ "pnpm-lock.yaml": "pnpm install",
2181
+ "yarn.lock": "yarn install"
2182
+ };
2176
2183
  }
2177
2184
  });
2178
2185
 
@@ -2718,18 +2725,124 @@ function appendOwn(target, key, value) {
2718
2725
  return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
2719
2726
  }
2720
2727
 
2721
- // src/protect/engine/request.js
2722
- var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
2723
- var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
2724
- var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
2725
- function base64DecodeUtf8(value) {
2726
- const str = String(value);
2727
- if (typeof Buffer !== "undefined") {
2728
- 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
+ }
2729
2751
  }
2730
- const binary = atob(str);
2731
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
2732
- 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);
2733
2846
  }
2734
2847
  var NAMED_ENTITIES = {
2735
2848
  lt: "<",
@@ -2737,7 +2850,6 @@ var NAMED_ENTITIES = {
2737
2850
  amp: "&",
2738
2851
  quot: '"',
2739
2852
  apos: "'",
2740
- "#39": "'",
2741
2853
  nbsp: "\xA0",
2742
2854
  sol: "/",
2743
2855
  bsol: "\\",
@@ -2756,7 +2868,7 @@ var NAMED_ENTITIES = {
2756
2868
  ast: "*"
2757
2869
  };
2758
2870
  function decodeHtmlEntities(input) {
2759
- 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) => {
2760
2872
  if (body[0] === "#") {
2761
2873
  const hex = body[1] === "x" || body[1] === "X";
2762
2874
  const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
@@ -2770,80 +2882,365 @@ function decodeHtmlEntities(input) {
2770
2882
  return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
2771
2883
  });
2772
2884
  }
2773
- var RequestResolver = class {
2774
- #req;
2775
- #cookies;
2776
- constructor(req) {
2777
- this.#req = req;
2778
- this.#cookies = null;
2885
+ function removeSqlComments(value) {
2886
+ if (typeof value !== "string") {
2887
+ return value;
2779
2888
  }
2780
- resolve(parameter) {
2781
- if (!parameter || parameter === "false") {
2782
- return [null];
2783
- }
2784
- if (parameter === "rules") {
2785
- 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;
2786
2933
  }
2787
- if (parameter === "raw") {
2788
- return this.#resolveRaw();
2934
+ if (task.kind === "key") {
2935
+ append(boundedJsonString(task.value));
2936
+ append(":");
2937
+ continue;
2789
2938
  }
2790
- if (parameter === "all") {
2791
- return this.#resolveAll();
2939
+ nodes++;
2940
+ if (nodes > MAX_RAW_NODES) {
2941
+ append('"[NodeLimit]"');
2942
+ continue;
2792
2943
  }
2793
- const dotIndex = parameter.indexOf(".");
2794
- if (dotIndex === -1) {
2795
- return [];
2944
+ const value = task.value;
2945
+ if (value === null || value === void 0) {
2946
+ append(task.root ? "" : "null");
2947
+ continue;
2796
2948
  }
2797
- const source = parameter.substring(0, dotIndex);
2798
- const key = parameter.substring(dotIndex + 1);
2799
- switch (source) {
2800
- case "get":
2801
- return this.#resolveGet(key);
2802
- case "post":
2803
- return this.#resolvePost(key);
2804
- case "request":
2805
- return this.#resolveRequest(key);
2806
- case "cookie":
2807
- return this.#resolveCookie(key);
2808
- case "server":
2809
- return this.#resolveServer(key);
2810
- case "files":
2811
- return this.#resolveFiles(key);
2812
- case "response":
2813
- return this.#resolveResponse(key);
2814
- case "egress":
2815
- return this.#resolveEgress(key);
2816
- default:
2817
- return [];
2949
+ if (typeof value === "string") {
2950
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
2951
+ continue;
2818
2952
  }
2819
- }
2820
- // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
2821
- // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
2822
- #resolveResponse(key) {
2823
- const resp = this.#req._response;
2824
- if (!resp) {
2825
- return [];
2953
+ if (typeof value !== "object") {
2954
+ append(String(value).slice(0, MAX_RAW_CHARS));
2955
+ continue;
2826
2956
  }
2827
- if (key === "status") {
2828
- return resp.status !== void 0 ? [String(resp.status)] : [];
2957
+ if (visited.has(value)) {
2958
+ append("[Circular]");
2959
+ continue;
2829
2960
  }
2830
- if (key === "body") {
2831
- return resp.body != null && resp.body !== "" ? [resp.body] : [];
2961
+ if (task.depth >= MAX_RAW_DEPTH) {
2962
+ append('"[DepthLimit]"');
2963
+ continue;
2832
2964
  }
2833
- if (key === "headers") {
2834
- const headers = resp.headers ?? {};
2835
- return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
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;
2836
2981
  }
2837
- if (key.startsWith("header.")) {
2838
- const name = key.slice("header.".length).toLowerCase();
2839
- const headers = resp.headers ?? {};
2840
- return Object.hasOwn(headers, name) ? [headers[name]] : [];
2982
+ let keys;
2983
+ try {
2984
+ keys = Object.getOwnPropertyNames(value);
2985
+ } catch {
2986
+ append('"[Unreadable]"');
2987
+ continue;
2841
2988
  }
2842
- return [];
2843
- }
2844
- // Egress-phase sources (req._egress = { url, host, method }). Lets rules inspect an
2845
- // OUTBOUND request the app is about to make — SSRF at the egress boundary.
2846
- #resolveEgress(key) {
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]] : [];
3238
+ }
3239
+ return [];
3240
+ }
3241
+ // Egress-phase sources (req._egress = { url, host, method }). Lets rules inspect an
3242
+ // OUTBOUND request the app is about to make — SSRF at the egress boundary.
3243
+ #resolveEgress(key) {
2847
3244
  const eg = this.#req._egress;
2848
3245
  if (!eg) {
2849
3246
  return [];
@@ -2867,6 +3264,9 @@ var RequestResolver = class {
2867
3264
  if (value === null || value === void 0) {
2868
3265
  return value;
2869
3266
  }
3267
+ if (typeof value === "object" && TEXT_MUTATIONS.has(mutation)) {
3268
+ return mapStringLeaves(value, (leaf) => this.#applyMutation(mutation, leaf), () => this.noteSkip("container-cap"));
3269
+ }
2870
3270
  switch (mutation) {
2871
3271
  case "base64_decode":
2872
3272
  try {
@@ -2887,11 +3287,7 @@ var RequestResolver = class {
2887
3287
  return value;
2888
3288
  }
2889
3289
  case "urldecode":
2890
- try {
2891
- return decodeURIComponent(String(value));
2892
- } catch {
2893
- return value;
2894
- }
3290
+ return safeUrlDecode(String(value).replace(/\+/g, " "));
2895
3291
  case "htmlentitydecode":
2896
3292
  return decodeHtmlEntities(String(value));
2897
3293
  case "intval":
@@ -2910,16 +3306,30 @@ var RequestResolver = class {
2910
3306
  if (key.endsWith("*")) {
2911
3307
  return this.#resolveWildcard(query, key);
2912
3308
  }
2913
- const value = this.#getNestedValue(query, key);
2914
- return value !== void 0 ? [value] : [];
3309
+ return this.#lookup(query, key);
2915
3310
  }
2916
3311
  #resolvePost(key) {
2917
3312
  const body = this.#req.body ?? {};
2918
3313
  if (key.endsWith("*")) {
2919
3314
  return this.#resolveWildcard(body, key);
2920
3315
  }
2921
- const value = this.#getNestedValue(body, key);
2922
- 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;
2923
3333
  }
2924
3334
  #resolveRequest(key) {
2925
3335
  const query = this.#req.query ?? {};
@@ -2932,8 +3342,11 @@ var RequestResolver = class {
2932
3342
  ...this.#resolveWildcard(cookies, key)
2933
3343
  ];
2934
3344
  }
2935
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
2936
- 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]] : [];
2937
3350
  }
2938
3351
  #resolveCookie(key) {
2939
3352
  const cookies = this.#parseCookies();
@@ -3103,331 +3516,53 @@ var RequestResolver = class {
3103
3516
  this.#cookies = this.#req.cookies;
3104
3517
  return this.#cookies;
3105
3518
  }
3106
- const header = this.#req.headers?.cookie;
3107
- if (!header) {
3108
- this.#cookies = {};
3109
- return this.#cookies;
3110
- }
3111
- const cookies = {};
3112
- for (const pair of header.split(";")) {
3113
- const eqIndex = pair.indexOf("=");
3114
- if (eqIndex === -1) {
3115
- continue;
3116
- }
3117
- const name = pair.substring(0, eqIndex).trim();
3118
- const value = pair.substring(eqIndex + 1).trim();
3119
- setOwn(cookies, name, value);
3120
- }
3121
- this.#cookies = cookies;
3519
+ this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
3122
3520
  return this.#cookies;
3123
3521
  }
3124
3522
  };
3125
3523
 
3126
3524
  // src/protect/notify.js
3127
- var reported = /* @__PURE__ */ new Set();
3525
+ var reported = /* @__PURE__ */ new WeakMap();
3526
+ var warnings = 0;
3527
+ var MAX_WARNINGS = 20;
3128
3528
  function notify(fn, arg, label) {
3129
3529
  if (typeof fn !== "function") return false;
3130
3530
  try {
3131
3531
  const result = fn(arg);
3132
3532
  if (result !== null && typeof result === "object" && typeof result.then === "function") {
3133
3533
  try {
3134
- result.then(void 0, (err) => warnOnce2(label, err));
3534
+ result.then(void 0, (err) => warnOnce2(fn, label, err));
3135
3535
  } catch {
3136
3536
  }
3137
3537
  }
3138
3538
  return true;
3139
3539
  } catch (err) {
3140
- warnOnce2(label, err);
3540
+ warnOnce2(fn, label, err);
3141
3541
  return false;
3142
3542
  }
3143
3543
  }
3144
- function warnOnce2(label, err) {
3145
- if (reported.has(label)) return;
3146
- reported.add(label);
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);
3550
+ }
3551
+ labels.add(label);
3552
+ if (warnings > MAX_WARNINGS) return;
3553
+ warnings++;
3147
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;
3558
+ }
3148
3559
  console.warn(
3149
- `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)}`
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)}`
3150
3561
  );
3151
3562
  } catch {
3152
3563
  }
3153
3564
  }
3154
3565
 
3155
- // src/protect/engine/normalizer.js
3156
- var HTML_ENTITIES = {
3157
- "&amp;": "&",
3158
- "&lt;": "<",
3159
- "&gt;": ">",
3160
- "&quot;": '"',
3161
- "&#39;": "'",
3162
- "&apos;": "'",
3163
- "&nbsp;": " ",
3164
- "&#x27;": "'",
3165
- "&#x22;": '"',
3166
- "&#x3C;": "<",
3167
- "&#x3E;": ">",
3168
- "&#x26;": "&",
3169
- "&#34;": '"',
3170
- "&#60;": "<",
3171
- "&#62;": ">",
3172
- "&#38;": "&"
3173
- };
3174
- var MAX_DECODE_ITERATIONS = 5;
3175
- function normalize(value, options = {}) {
3176
- if (typeof value !== "string") {
3177
- return value;
3178
- }
3179
- const opts = {
3180
- urlDecode: true,
3181
- htmlDecode: true,
3182
- sqlComments: true,
3183
- nullBytes: true,
3184
- whitespace: true,
3185
- ...options
3186
- };
3187
- let result = value;
3188
- if (opts.nullBytes) {
3189
- result = removeNullBytes(result);
3190
- }
3191
- if (opts.urlDecode) {
3192
- result = urlDecode(result);
3193
- }
3194
- if (opts.htmlDecode) {
3195
- result = htmlEntityDecode(result);
3196
- }
3197
- if (opts.sqlComments) {
3198
- result = removeSqlComments(result);
3199
- }
3200
- if (opts.whitespace) {
3201
- result = normalizeWhitespace(result);
3202
- }
3203
- return result;
3204
- }
3205
- function urlDecode(value) {
3206
- if (typeof value !== "string") {
3207
- return value;
3208
- }
3209
- let result = value;
3210
- let previous = "";
3211
- let iterations = 0;
3212
- while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
3213
- previous = result;
3214
- iterations++;
3215
- try {
3216
- result = decodeURIComponent(result);
3217
- } catch {
3218
- result = safeUrlDecode(result);
3219
- break;
3220
- }
3221
- }
3222
- return result;
3223
- }
3224
- function safeUrlDecode(value) {
3225
- return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
3226
- try {
3227
- return String.fromCharCode(parseInt(hex, 16));
3228
- } catch {
3229
- return match;
3230
- }
3231
- });
3232
- }
3233
- function htmlEntityDecode(value) {
3234
- if (typeof value !== "string") {
3235
- return value;
3236
- }
3237
- let result = value;
3238
- for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
3239
- result = result.split(entity).join(char);
3240
- }
3241
- result = result.replace(/&#(\d+);/g, (match, code) => {
3242
- const num = parseInt(code, 10);
3243
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
3244
- });
3245
- result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
3246
- const num = parseInt(hex, 16);
3247
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
3248
- });
3249
- return result;
3250
- }
3251
- function removeSqlComments(value) {
3252
- if (typeof value !== "string") {
3253
- return value;
3254
- }
3255
- let result = value;
3256
- result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
3257
- result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
3258
- return result;
3259
- }
3260
- function removeNullBytes(value) {
3261
- if (typeof value !== "string") {
3262
- return value;
3263
- }
3264
- let result = value.replace(/\x00/g, "");
3265
- result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
3266
- return result;
3267
- }
3268
- function normalizeWhitespace(value) {
3269
- if (typeof value !== "string") {
3270
- return value;
3271
- }
3272
- let result = value.replace(/[\t\r\n\f\v]+/g, " ");
3273
- result = result.replace(/ {2,}/g, " ");
3274
- return result;
3275
- }
3276
- var MAX_RAW_CHARS = 1024 * 1024;
3277
- var MAX_RAW_NODES = 25e3;
3278
- var MAX_RAW_DEPTH = 200;
3279
- var MAX_RAW_ENTRIES = 2e3;
3280
- function serializeForRawDetection(body) {
3281
- if (body === null || body === void 0) return "";
3282
- if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
3283
- const visited = /* @__PURE__ */ new Set();
3284
- const output = [];
3285
- const stack = [{ kind: "value", value: body, depth: 0, root: true }];
3286
- let chars = 0;
3287
- let nodes = 0;
3288
- const append = (text) => {
3289
- if (chars >= MAX_RAW_CHARS) return;
3290
- const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
3291
- output.push(piece);
3292
- chars += piece.length;
3293
- };
3294
- while (stack.length > 0 && chars < MAX_RAW_CHARS) {
3295
- const task = stack.pop();
3296
- if (task.kind === "text") {
3297
- append(task.text);
3298
- continue;
3299
- }
3300
- if (task.kind === "key") {
3301
- append(boundedJsonString(task.value));
3302
- append(":");
3303
- continue;
3304
- }
3305
- nodes++;
3306
- if (nodes > MAX_RAW_NODES) {
3307
- append('"[NodeLimit]"');
3308
- continue;
3309
- }
3310
- const value = task.value;
3311
- if (value === null || value === void 0) {
3312
- append(task.root ? "" : "null");
3313
- continue;
3314
- }
3315
- if (typeof value === "string") {
3316
- append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
3317
- continue;
3318
- }
3319
- if (typeof value !== "object") {
3320
- append(String(value).slice(0, MAX_RAW_CHARS));
3321
- continue;
3322
- }
3323
- if (visited.has(value)) {
3324
- append("[Circular]");
3325
- continue;
3326
- }
3327
- if (task.depth >= MAX_RAW_DEPTH) {
3328
- append('"[DepthLimit]"');
3329
- continue;
3330
- }
3331
- visited.add(value);
3332
- if (Array.isArray(value)) {
3333
- const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
3334
- const items = [];
3335
- for (let i = 0; i < length2; i++) {
3336
- if (i > 0) items.push({ kind: "text", text: "," });
3337
- items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
3338
- }
3339
- if (value.length > length2) {
3340
- if (length2 > 0) items.push({ kind: "text", text: "," });
3341
- items.push({ kind: "text", text: '"[EntryLimit]"' });
3342
- }
3343
- stack.push({ kind: "text", text: "]" });
3344
- for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
3345
- stack.push({ kind: "text", text: "[" });
3346
- continue;
3347
- }
3348
- let keys;
3349
- try {
3350
- keys = Object.getOwnPropertyNames(value);
3351
- } catch {
3352
- append('"[Unreadable]"');
3353
- continue;
3354
- }
3355
- const length = Math.min(keys.length, MAX_RAW_ENTRIES);
3356
- const entries = [];
3357
- for (let i = 0; i < length; i++) {
3358
- const key = keys[i];
3359
- if (i > 0) entries.push({ kind: "text", text: "," });
3360
- entries.push({ kind: "key", value: key });
3361
- entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
3362
- }
3363
- if (keys.length > length) {
3364
- if (length > 0) entries.push({ kind: "text", text: "," });
3365
- entries.push({ kind: "text", text: '"[EntryLimit]":null' });
3366
- }
3367
- stack.push({ kind: "text", text: "}" });
3368
- for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
3369
- stack.push({ kind: "text", text: "{" });
3370
- }
3371
- return output.join("");
3372
- }
3373
- function boundedJsonString(value) {
3374
- return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
3375
- }
3376
- function readOwnValue(value, key) {
3377
- try {
3378
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
3379
- return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
3380
- } catch {
3381
- return "[Unreadable]";
3382
- }
3383
- }
3384
- var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
3385
- function requestField(req, key) {
3386
- if (req === null || typeof req !== "object") return void 0;
3387
- if (Object.hasOwn(req, key)) return req[key];
3388
- if (!INHERITED_ACCESSORS.has(key)) return void 0;
3389
- let holder = Object.getPrototypeOf(req);
3390
- while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
3391
- if (holder === null || holder === Object.prototype) return void 0;
3392
- const descriptor = Object.getOwnPropertyDescriptor(holder, key);
3393
- if (typeof descriptor?.get !== "function") return void 0;
3394
- return req[key];
3395
- }
3396
- function normalizeRequest(req, options = {}) {
3397
- const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
3398
- const body = requestField(req, "body");
3399
- const url = requestField(req, "url");
3400
- const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
3401
- return {
3402
- query: normalizeObject(requestField(req, "query") || {}, options),
3403
- body: normalizeObject(body || {}, options),
3404
- headers: normalizeObject(requestField(req, "headers") || {}, options),
3405
- url: normalize(url || "", options),
3406
- originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
3407
- _rawBody: rawBody
3408
- };
3409
- }
3410
- var MAX_NORMALIZE_DEPTH = 200;
3411
- function normalizeObject(value, options = {}, depth = 0) {
3412
- if (typeof value === "string") {
3413
- return normalize(value, options);
3414
- }
3415
- if (depth >= MAX_NORMALIZE_DEPTH) {
3416
- return value;
3417
- }
3418
- if (Array.isArray(value)) {
3419
- return value.map((item) => normalizeObject(item, options, depth + 1));
3420
- }
3421
- if (typeof value === "object" && value !== null) {
3422
- const result = {};
3423
- for (const [key, val] of Object.entries(value)) {
3424
- setOwn(result, key, normalizeObject(val, options, depth + 1));
3425
- }
3426
- return result;
3427
- }
3428
- return value;
3429
- }
3430
-
3431
3566
  // src/protect/engine/engine.js
3432
3567
  var GRP = "(?:[^()]|\\([^()]*\\))*";
3433
3568
  var REDOS_PATTERNS = [
@@ -3527,6 +3662,184 @@ function hasAdjacentUnbounded(body, flags) {
3527
3662
  }
3528
3663
  return false;
3529
3664
  }
3665
+ function parseRepetition(body) {
3666
+ let i = 0;
3667
+ const parseAlternation = () => {
3668
+ const branches = [parseSequence()];
3669
+ while (body[i] === "|") {
3670
+ i++;
3671
+ branches.push(parseSequence());
3672
+ }
3673
+ return branches.length === 1 ? branches[0] : { type: "alt", branches };
3674
+ };
3675
+ const parseSequence = () => {
3676
+ const items = [];
3677
+ while (i < body.length && body[i] !== "|" && body[i] !== ")") {
3678
+ const atom = parseAtom();
3679
+ if (atom === null) return null;
3680
+ const quantified = parseQuantifier(atom);
3681
+ if (quantified === null) return null;
3682
+ items.push(quantified);
3683
+ }
3684
+ return { type: "seq", items };
3685
+ };
3686
+ const parseAtom = () => {
3687
+ const ch = body[i];
3688
+ if (ch === "(") {
3689
+ const start2 = i;
3690
+ i++;
3691
+ let kind = "capture";
3692
+ if (body.startsWith("?:", i)) {
3693
+ kind = "group";
3694
+ i += 2;
3695
+ } else if (body.startsWith("?=", i) || body.startsWith("?!", i)) {
3696
+ kind = "look";
3697
+ i += 2;
3698
+ } else if (body.startsWith("?<=", i) || body.startsWith("?<!", i)) {
3699
+ kind = "look";
3700
+ i += 3;
3701
+ } else if (body.startsWith("?<", i)) {
3702
+ const close = body.indexOf(">", i + 2);
3703
+ if (close === -1) return null;
3704
+ i = close + 1;
3705
+ } else if (body[i] === "?") {
3706
+ return null;
3707
+ }
3708
+ const inner = parseAlternation();
3709
+ if (inner === null || body[i] !== ")") return null;
3710
+ i++;
3711
+ return { type: "group", kind, body: inner, source: body.slice(start2, i) };
3712
+ }
3713
+ const start = i;
3714
+ if (ch === "[") {
3715
+ i++;
3716
+ if (body[i] === "^") i++;
3717
+ if (body[i] === "]") i++;
3718
+ while (i < body.length && body[i] !== "]") i += body[i] === "\\" ? 2 : 1;
3719
+ if (body[i] !== "]") return null;
3720
+ i++;
3721
+ return { type: "atom", source: body.slice(start, i) };
3722
+ }
3723
+ if (ch === "\\") {
3724
+ const next = body[i + 1];
3725
+ if (next === void 0) return null;
3726
+ if ((next === "p" || next === "P" || next === "u") && body[i + 2] === "{") {
3727
+ const close = body.indexOf("}", i + 3);
3728
+ if (close === -1) return null;
3729
+ i = close + 1;
3730
+ } else if (next === "k" && body[i + 2] === "<") {
3731
+ const close = body.indexOf(">", i + 3);
3732
+ if (close === -1) return null;
3733
+ i = close + 1;
3734
+ } else {
3735
+ i += 2;
3736
+ }
3737
+ return { type: /[bB]/.test(next) ? "assert" : "atom", source: body.slice(start, i) };
3738
+ }
3739
+ if (ch === "^" || ch === "$") {
3740
+ i++;
3741
+ return { type: "assert" };
3742
+ }
3743
+ if (ch === "*" || ch === "+" || ch === "?") return null;
3744
+ i++;
3745
+ return { type: "atom", source: body.slice(start, i) };
3746
+ };
3747
+ const parseQuantifier = (atom) => {
3748
+ let min;
3749
+ let max;
3750
+ const ch = body[i];
3751
+ if (ch === "*") [min, max, i] = [0, Infinity, i + 1];
3752
+ else if (ch === "+") [min, max, i] = [1, Infinity, i + 1];
3753
+ else if (ch === "?") [min, max, i] = [0, 1, i + 1];
3754
+ else if (ch === "{") {
3755
+ const bounds = /^\{(\d+)(,(\d*))?\}/.exec(body.slice(i));
3756
+ if (!bounds) return atom;
3757
+ min = Number(bounds[1]);
3758
+ max = bounds[2] === void 0 ? min : bounds[3] === "" ? Infinity : Number(bounds[3]);
3759
+ i += bounds[0].length;
3760
+ } else {
3761
+ return atom;
3762
+ }
3763
+ if (body[i] === "?") i++;
3764
+ if (body[i] === "*" || body[i] === "+" || body[i] === "?" || body[i] === "{") return null;
3765
+ return { type: "quant", min, max, body: atom };
3766
+ };
3767
+ const root = parseAlternation();
3768
+ return root !== null && i === body.length ? root : null;
3769
+ }
3770
+ function* nodesOf(node) {
3771
+ if (!node) return;
3772
+ yield node;
3773
+ if (node.type === "alt") for (const branch of node.branches) yield* nodesOf(branch);
3774
+ else if (node.type === "seq") for (const item of node.items) yield* nodesOf(item);
3775
+ else if (node.type === "group" || node.type === "quant") yield* nodesOf(node.body);
3776
+ }
3777
+ var BOUNDED_NESTING_LIMIT = 100;
3778
+ var BOUNDED_ALTERNATION_LIMIT = 16;
3779
+ var OPTIONAL_REPEAT_LIMIT = 8;
3780
+ function hasFencedRepetitions(quant, flags) {
3781
+ let body = quant.body;
3782
+ if (body.type === "group" && body.kind !== "look") body = body.body;
3783
+ if (body.type !== "seq" || body.items.length < 2) return false;
3784
+ const variable = [...nodesOf(body)].filter((n) => n.type === "quant" && (n.max > 1 || n.min < n.max));
3785
+ if (variable.some((n) => n.body.type !== "atom" || n.body.source === void 0)) return false;
3786
+ const atomFlags = flags.replace(/[^i]/g, "");
3787
+ const unicode = /[uv]/.test(flags);
3788
+ let insides;
3789
+ try {
3790
+ insides = variable.map((n) => new RegExp(`^(?:${n.body.source})$`, `${atomFlags}${unicode ? "u" : ""}`));
3791
+ } catch {
3792
+ return false;
3793
+ }
3794
+ const literalOf = (source) => {
3795
+ if (source.length === 1 && !".[]()|^$\\".includes(source)) return source;
3796
+ if (source.length === 2 && source[0] === "\\" && /[^\w\s]/.test(source[1])) return source[1];
3797
+ return null;
3798
+ };
3799
+ const disjoint = (edge) => {
3800
+ if (edge?.type !== "atom" || edge.source === void 0) return false;
3801
+ const literal = literalOf(edge.source);
3802
+ if (literal !== null) return insides.every((re) => !re.test(literal));
3803
+ if (unicode) return false;
3804
+ let fence;
3805
+ try {
3806
+ fence = new RegExp(`^(?:${edge.source})$`, atomFlags);
3807
+ } catch {
3808
+ return false;
3809
+ }
3810
+ for (let unit = 0; unit <= 65535; unit++) {
3811
+ const ch = String.fromCharCode(unit);
3812
+ if (fence.test(ch) && insides.some((re) => re.test(ch))) return false;
3813
+ }
3814
+ return true;
3815
+ };
3816
+ return disjoint(body.items[0]) || disjoint(body.items[body.items.length - 1]);
3817
+ }
3818
+ function hasCatastrophicShape(body, flags) {
3819
+ const root = parseRepetition(body);
3820
+ if (root === null) return null;
3821
+ for (const node of nodesOf(root)) {
3822
+ if (node.type === "group" && node.kind === "look" && hasAdjacentUnbounded(lookaroundBody(node.source), flags)) return true;
3823
+ if (node.type !== "quant" || node.max <= 1) continue;
3824
+ const fenced = hasFencedRepetitions(node, flags);
3825
+ for (const inner of nodesOf(node.body)) {
3826
+ if (inner === node.body && inner.type === "quant") continue;
3827
+ if (inner.type === "quant" && inner.max > 1 && !fenced) {
3828
+ if (node.max === Infinity || inner.max === Infinity || node.max * inner.max > BOUNDED_NESTING_LIMIT) return true;
3829
+ }
3830
+ if (inner.type === "quant" && inner.min === 0 && inner.max <= 1 && !fenced && (node.max === Infinity || node.max > OPTIONAL_REPEAT_LIMIT)) {
3831
+ return true;
3832
+ }
3833
+ if (inner.type === "alt" && inner.branches.length > 1 && (node.max === Infinity || node.max > BOUNDED_ALTERNATION_LIMIT)) {
3834
+ return true;
3835
+ }
3836
+ }
3837
+ }
3838
+ return false;
3839
+ }
3840
+ function lookaroundBody(source) {
3841
+ return source.slice(source.startsWith("(?<") ? 4 : 3, -1);
3842
+ }
3530
3843
  function groupEnd(body, start) {
3531
3844
  let depth = 0;
3532
3845
  let inClass = false;
@@ -3553,6 +3866,8 @@ function warnRejectedPatternOnce(pattern) {
3553
3866
  );
3554
3867
  }
3555
3868
  var MAX_PATTERN_LENGTH = 1e3;
3869
+ var regexVerdicts = /* @__PURE__ */ new Map();
3870
+ var REGEX_VERDICT_LIMIT = 2048;
3556
3871
  function safeRegExp(pattern) {
3557
3872
  if (!pattern) {
3558
3873
  return null;
@@ -3564,16 +3879,32 @@ function safeRegExp(pattern) {
3564
3879
  if (!match) {
3565
3880
  return null;
3566
3881
  }
3567
- for (const dangerous of REDOS_PATTERNS) {
3568
- if (dangerous.test(match[1])) return null;
3882
+ let safe = regexVerdicts.get(pattern);
3883
+ if (safe === void 0) {
3884
+ safe = isSafeBody(match[1], match[2]);
3885
+ if (regexVerdicts.size >= REGEX_VERDICT_LIMIT) regexVerdicts.clear();
3886
+ regexVerdicts.set(pattern, safe);
3569
3887
  }
3570
- if (hasAdjacentUnbounded(match[1], match[2])) return null;
3888
+ if (!safe) return null;
3571
3889
  try {
3572
3890
  return new RegExp(match[1], match[2]);
3573
3891
  } catch {
3574
3892
  return null;
3575
3893
  }
3576
3894
  }
3895
+ function isSafeBody(body, flags) {
3896
+ for (const dangerous of REDOS_PATTERNS) {
3897
+ if (dangerous.test(body)) return false;
3898
+ }
3899
+ if (hasAdjacentUnbounded(body, flags)) return false;
3900
+ if (hasCatastrophicShape(body, flags) === true) return false;
3901
+ try {
3902
+ new RegExp(body, flags);
3903
+ } catch {
3904
+ return false;
3905
+ }
3906
+ return true;
3907
+ }
3577
3908
  function ctypeResult(strValue, isClass, matchVal) {
3578
3909
  if (strValue === "") {
3579
3910
  return false;
@@ -3617,17 +3948,21 @@ function arrayKeyValue(value, matchObj) {
3617
3948
  }
3618
3949
  var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
3619
3950
  function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
3620
- const out = [];
3951
+ const leaves = [];
3621
3952
  const stack = [[root, 0]];
3622
3953
  let visited = 0;
3954
+ let truncated = false;
3623
3955
  while (stack.length) {
3624
3956
  const [node, depth] = stack.pop();
3625
3957
  if (node === null || node === void 0) continue;
3626
3958
  if (typeof node !== "object") {
3627
- out.push(node);
3959
+ leaves.push(node);
3960
+ continue;
3961
+ }
3962
+ if (depth >= maxDepth || visited >= nodeCap) {
3963
+ truncated = true;
3628
3964
  continue;
3629
3965
  }
3630
- if (depth >= maxDepth || visited >= nodeCap) continue;
3631
3966
  visited++;
3632
3967
  if (Array.isArray(node)) {
3633
3968
  for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
@@ -3635,10 +3970,21 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
3635
3970
  for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
3636
3971
  }
3637
3972
  }
3638
- return out;
3973
+ return { leaves, truncated };
3639
3974
  }
3640
- var warnedKeys = /* @__PURE__ */ new Set();
3641
- function warnOnce3(key, message) {
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
+ }
3985
+ }
3986
+ var warnedKeys = /* @__PURE__ */ new Set();
3987
+ function warnOnce3(key, message) {
3642
3988
  if (warnedKeys.has(key)) return;
3643
3989
  warnedKeys.add(key);
3644
3990
  console.warn(message);
@@ -3653,12 +3999,22 @@ function warnUnsupportedMatchType(type) {
3653
3999
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
3654
4000
  );
3655
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
+ }
3656
4010
  function hostFromValue(value) {
3657
- const raw = String(value ?? "").trim();
4011
+ const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
3658
4012
  if (raw === "") return "";
3659
- 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)) {
3660
4016
  try {
3661
- return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
4017
+ return new URL(relative ? `http:${raw}` : raw).hostname;
3662
4018
  } catch {
3663
4019
  return raw;
3664
4020
  }
@@ -3686,10 +4042,18 @@ function isInternalHost(hostname) {
3686
4042
  const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
3687
4043
  if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
3688
4044
  if ((g[0] & 65472) === 65152) return true;
4045
+ if ((g[0] & 65472) === 65216) return true;
3689
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;
3690
4049
  if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
3691
- 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);
3692
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);
3693
4057
  return false;
3694
4058
  }
3695
4059
  const v4 = parseIPv4ToInt(host);
@@ -3699,6 +4063,10 @@ function isInternalHost(hostname) {
3699
4063
  function isPrivateV4Int(n) {
3700
4064
  const a = n >>> 24 & 255;
3701
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;
3702
4070
  if (a === 127 || a === 10 || a === 0) return true;
3703
4071
  if (a === 169 && b === 254) return true;
3704
4072
  if (a === 172 && b >= 16 && b <= 31) return true;
@@ -4047,8 +4415,12 @@ var RuleEngine = class {
4047
4415
  let normalizedReq;
4048
4416
  let resolver;
4049
4417
  try {
4050
- normalizedReq = { ...req, ...normalizeRequest(req) };
4418
+ let limited = false;
4419
+ normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
4420
+ limited = true;
4421
+ } }) };
4051
4422
  resolver = new RequestResolver(normalizedReq);
4423
+ if (limited) resolver.noteSkip("container-cap");
4052
4424
  } catch (err) {
4053
4425
  this.#reportError(err);
4054
4426
  return { blocked: false, rule: null, message: null };
@@ -4074,14 +4446,15 @@ var RuleEngine = class {
4074
4446
  // that re-read the request instead would be reading it a second time: a getter, a stream or
4075
4447
  // anything else that answers once can give a different value, and evidence that disagrees
4076
4448
  // with the match it belongs to is worse than none.
4077
- resolver
4449
+ resolver,
4450
+ ...skipsOf(resolver)
4078
4451
  };
4079
4452
  }
4080
4453
  } catch (err) {
4081
4454
  this.#reportError(err);
4082
4455
  }
4083
4456
  }
4084
- return { blocked: false, rule: null, message: null };
4457
+ return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
4085
4458
  }
4086
4459
  #evaluateRule(conditions, resolver) {
4087
4460
  const inclusiveConditions = conditions.filter((c) => c.inclusive);
@@ -4137,9 +4510,15 @@ var RuleEngine = class {
4137
4510
  if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
4138
4511
  if (matchValue(match.type, value, match.value, match)) return true;
4139
4512
  } else {
4140
- for (const leaf of collectLeafValues(value)) {
4513
+ const { leaves, truncated } = collectLeafValues(value);
4514
+ for (const leaf of leaves) {
4141
4515
  if (matchValue(match.type, leaf, match.value, match)) return true;
4142
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
+ }
4143
4522
  }
4144
4523
  continue;
4145
4524
  }
@@ -4526,6 +4905,9 @@ function conditionShapeProblem(condition) {
4526
4905
  if (nested && !named) {
4527
4906
  return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
4528
4907
  }
4908
+ if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
4909
+ return "inclusive must be true or false";
4910
+ }
4529
4911
  if (named && nested) {
4530
4912
  if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
4531
4913
  if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
@@ -5235,7 +5617,7 @@ async function fromFetchRequest(request, options = {}) {
5235
5617
  // that guessed differently would attribute one request to two addresses.
5236
5618
  ip: client.ip ?? "",
5237
5619
  _clientIp: client,
5238
- cookies: parseCookies(headers.cookie),
5620
+ cookies: parseCookieHeader(headers.cookie),
5239
5621
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
5240
5622
  // drops, so prototype-pollution rules on `raw` are robust.
5241
5623
  _rawBody: rawBody,
@@ -5297,7 +5679,7 @@ function concatChunks(chunks, total) {
5297
5679
  }
5298
5680
  function parseBody(rawBody, contentType) {
5299
5681
  const ct = String(contentType || "").toLowerCase();
5300
- const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
5682
+ const isJson2 = ct.includes("application/json") || /\+json\b/.test(ct);
5301
5683
  const isForm = ct.includes("application/x-www-form-urlencoded");
5302
5684
  const isMultipart = ct.includes("multipart/form-data");
5303
5685
  const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
@@ -5311,7 +5693,7 @@ function parseBody(rawBody, contentType) {
5311
5693
  for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
5312
5694
  return { body, files: void 0 };
5313
5695
  }
5314
- if (isJson || isAmbiguous) {
5696
+ if (isJson2 || isAmbiguous) {
5315
5697
  try {
5316
5698
  const parsed = JSON.parse(rawBody);
5317
5699
  if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
@@ -5327,38 +5709,115 @@ function parseMultipart(rawBody, boundary) {
5327
5709
  const sep = /\r?\n\r?\n/.exec(part);
5328
5710
  if (!sep) continue;
5329
5711
  const rawHeaders = part.slice(0, sep.index);
5330
- const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
5331
- if (!disposition) continue;
5332
- const name = /name="([^"]*)"/i.exec(disposition)?.[1];
5333
- if (name == null) continue;
5712
+ const disposition = /content-disposition:([^\r\n]*)/i.exec(rawHeaders)?.[1];
5713
+ if (disposition === void 0) continue;
5714
+ const { names, filename } = dispositionFields(disposition);
5715
+ if (names.length === 0) continue;
5334
5716
  const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
5335
- const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
5336
- if (filename !== void 0) {
5337
- const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
5338
- const file = { filename, type: partType, content };
5339
- appendOwn(files, name, file);
5340
- } else {
5341
- appendOwn(body, name, content);
5717
+ const file = filename === void 0 ? void 0 : { filename, type: /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "", content };
5718
+ for (const name of names) {
5719
+ if (file) appendOwn(files, name, file);
5720
+ else appendOwn(body, name, content);
5342
5721
  }
5343
5722
  }
5344
5723
  return { body, files };
5345
5724
  }
5346
- function parseCookies(header) {
5347
- const cookies = {};
5348
- if (!header) {
5349
- return cookies;
5350
- }
5351
- for (const pair of header.split(";")) {
5352
- const idx = pair.indexOf("=");
5353
- if (idx === -1) {
5354
- continue;
5725
+ function dispositionFields(value) {
5726
+ const text = String(value);
5727
+ const names = /* @__PURE__ */ new Set();
5728
+ let filename;
5729
+ let extendedFilename;
5730
+ let i = text.indexOf(";");
5731
+ while (i !== -1 && i < text.length) {
5732
+ i++;
5733
+ while (text[i] === " " || text[i] === " ") i++;
5734
+ let key = "";
5735
+ while (i < text.length && text[i] !== "=" && text[i] !== ";") key += text[i++];
5736
+ key = key.trim().toLowerCase();
5737
+ let val = null;
5738
+ if (text[i] === "=") {
5739
+ i++;
5740
+ while (text[i] === " " || text[i] === " ") i++;
5741
+ if (text[i] === '"') {
5742
+ i++;
5743
+ val = "";
5744
+ while (i < text.length && text[i] !== '"') {
5745
+ if (text[i] === "\\" && i + 1 < text.length) i++;
5746
+ val += text[i++];
5747
+ }
5748
+ i++;
5749
+ while (i < text.length && text[i] !== ";") i++;
5750
+ } else {
5751
+ let bare = "";
5752
+ while (i < text.length && text[i] !== ";") bare += text[i++];
5753
+ val = bare.trim();
5754
+ }
5755
+ }
5756
+ if (val !== null) {
5757
+ if (key === "name") names.add(val);
5758
+ else if (key === "filename") filename = val;
5759
+ else if (key === "filename*") extendedFilename = decodeExtendedValue(val);
5355
5760
  }
5356
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
5761
+ i = i < text.length && text[i] === ";" ? i : -1;
5762
+ }
5763
+ return { names: [...names], filename: extendedFilename ?? filename };
5764
+ }
5765
+ function decodeExtendedValue(value) {
5766
+ const match = /^([^']*)'[^']*'(.*)$/.exec(value);
5767
+ if (!match) return value;
5768
+ try {
5769
+ return decodeURIComponent(match[2]);
5770
+ } catch {
5771
+ return match[2];
5357
5772
  }
5358
- return cookies;
5359
5773
  }
5360
5774
 
5361
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
+ }
5362
5821
  function fromNodeRequest(req, rawBody = "", options = {}) {
5363
5822
  const method = (req.method || "GET").toUpperCase();
5364
5823
  const headers = {};
@@ -5404,29 +5863,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
5404
5863
  headers,
5405
5864
  ip: client.ip ?? "",
5406
5865
  _clientIp: client,
5407
- cookies: parseCookies2(headers.cookie),
5866
+ cookies: parseCookieHeader(headers.cookie),
5408
5867
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
5409
5868
  _rawBody: rawBody
5410
5869
  };
5411
5870
  }
5412
- function parseCookies2(header) {
5413
- const cookies = {};
5414
- if (!header) {
5415
- 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;
5416
5891
  }
5417
- for (const pair of header.split(";")) {
5418
- const idx = pair.indexOf("=");
5419
- if (idx === -1) {
5420
- continue;
5421
- }
5422
- 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;
5423
5897
  }
5424
- return cookies;
5898
+ return refused;
5425
5899
  }
5426
-
5427
- // src/protect/egress.js
5428
5900
  async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
5429
- const restores = [];
5430
5901
  if (typeof shouldBlock !== "function") return () => {
5431
5902
  };
5432
5903
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
@@ -5471,6 +5942,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5471
5942
  resolve(false);
5472
5943
  }
5473
5944
  });
5945
+ const registry = egressRegistry();
5946
+ const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
5947
+ registry.screens.add(own);
5474
5948
  const originalFetch = globalThis.fetch;
5475
5949
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
5476
5950
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
@@ -5543,15 +6017,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5543
6017
  } catch {
5544
6018
  host = null;
5545
6019
  }
5546
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
5547
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
5548
- }
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);
5549
6024
  };
5550
6025
  const guarded = async (input, init) => {
5551
6026
  let cur;
5552
6027
  try {
5553
6028
  cur = new Request(input, { ...init || {}, redirect: "manual" });
5554
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", {});
5555
6033
  return originalFetch(input, init);
5556
6034
  }
5557
6035
  const callerRedirect = init && init.redirect || input && input.redirect || "follow";
@@ -5562,13 +6040,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5562
6040
  const headers = new Headers(cur.headers);
5563
6041
  const signal = cur.signal;
5564
6042
  const replay = captureReplayBody(cur);
6043
+ const { body: _callerBody, ...callerOptions } = init || {};
5565
6044
  let body;
5566
6045
  for (let hop = 0; ; hop++) {
5567
6046
  let resp;
5568
6047
  try {
5569
- resp = await originalFetch(
5570
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
5571
- );
6048
+ resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
5572
6049
  } catch (error) {
5573
6050
  replay.cancel();
5574
6051
  throw error;
@@ -5576,7 +6053,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5576
6053
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
5577
6054
  if (!location) {
5578
6055
  replay.cancel();
5579
- return resp;
6056
+ return hop === 0 ? resp : markRedirected(resp);
5580
6057
  }
5581
6058
  await discardResponseBody(resp);
5582
6059
  if (hop >= MAX_REDIRECTS) {
@@ -5609,43 +6086,84 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5609
6086
  };
5610
6087
  guarded.__patchstackGuarded = true;
5611
6088
  globalThis.fetch = guarded;
5612
- restores.push(() => {
5613
- 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
+ }
5614
6095
  });
5615
6096
  }
6097
+ let syncBuiltins = () => {
6098
+ };
6099
+ try {
6100
+ const { syncBuiltinESMExports } = await import("module");
6101
+ if (typeof syncBuiltinESMExports === "function") syncBuiltins = syncBuiltinESMExports;
6102
+ } catch {
6103
+ }
6104
+ let patchedAny = false;
5616
6105
  for (const moduleName of ["node:http", "node:https"]) {
5617
6106
  try {
5618
6107
  const mod = await import(moduleName);
5619
- const restore = patchHttpModule(mod.default ?? mod, block, screen, skip);
5620
- if (restore) restores.push(restore);
6108
+ if (registry.surfaces.has(moduleName)) continue;
6109
+ const release = patchHttpModule(mod.default ?? mod, registry);
6110
+ if (release) {
6111
+ patchedAny = true;
6112
+ registry.surfaces.set(moduleName, {
6113
+ release() {
6114
+ if (!release()) return false;
6115
+ syncBuiltins();
6116
+ return true;
6117
+ }
6118
+ });
6119
+ }
5621
6120
  } catch {
5622
6121
  }
5623
6122
  }
6123
+ if (patchedAny) syncBuiltins();
5624
6124
  return () => {
5625
- 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) {
5626
6128
  try {
5627
- restore();
6129
+ if (surface.release()) registry.surfaces.delete(name);
5628
6130
  } catch {
5629
6131
  }
5630
6132
  }
5631
6133
  };
5632
6134
  }
5633
- 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) {
5634
6143
  if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
5635
6144
  const originalRequest = http.request;
5636
6145
  const originalGet = http.get;
5637
- const wrap = (original) => function(...args) {
6146
+ const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
6147
+ const guardArgs = (args) => {
5638
6148
  const target = extractHttpTarget(args);
5639
- if (target && block(target.url, target.host, target.method)) {
5640
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
5641
- }
5642
- 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
+ };
5643
6158
  try {
5644
- args = withScreeningLookup(args, target, block, screen.lookup, skip);
6159
+ return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
5645
6160
  } catch {
5646
6161
  }
5647
6162
  }
5648
- return original.apply(this, args);
6163
+ return args;
6164
+ };
6165
+ const wrap = (original) => function(...args) {
6166
+ return original.apply(this, guardArgs(args));
5649
6167
  };
5650
6168
  const guardedRequest = wrap(originalRequest);
5651
6169
  http.request = guardedRequest;
@@ -5654,11 +6172,26 @@ function patchHttpModule(http, block, screen, skip) {
5654
6172
  guardedGet = wrap(originalGet);
5655
6173
  http.get = guardedGet;
5656
6174
  }
6175
+ let GuardedClientRequest;
6176
+ if (OriginalClientRequest) {
6177
+ GuardedClientRequest = function ClientRequest(...args) {
6178
+ const screened = guardArgs(args);
6179
+ return new.target ? Reflect.construct(OriginalClientRequest, screened, new.target) : Reflect.construct(OriginalClientRequest, screened);
6180
+ };
6181
+ GuardedClientRequest.prototype = OriginalClientRequest.prototype;
6182
+ Object.setPrototypeOf(GuardedClientRequest, OriginalClientRequest);
6183
+ http.ClientRequest = GuardedClientRequest;
6184
+ }
5657
6185
  http.__patchstackGuarded = true;
5658
6186
  return () => {
5659
- if (http.request === guardedRequest) http.request = originalRequest;
5660
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
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;
5661
6193
  delete http.__patchstackGuarded;
6194
+ return true;
5662
6195
  };
5663
6196
  }
5664
6197
  function extractHttpTarget(args) {
@@ -5667,19 +6200,27 @@ function extractHttpTarget(args) {
5667
6200
  if (typeof first === "string" || first instanceof URL) {
5668
6201
  const url = new URL(String(first));
5669
6202
  const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
5670
- return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
5671
- }
5672
- if (first && typeof first === "object") {
5673
- const host = normalizeHost(first.hostname || first.host) || "localhost";
5674
- const protocol = first.protocol || "http:";
5675
- const port = first.port ? `:${first.port}` : "";
5676
- const path9 = first.path || "/";
5677
- return { url: `${protocol}//${host}${port}${path9}`, host, method: first.method || "GET" };
6203
+ return targetOf({ ...optionsFromUrl(url), ...opts ?? {} });
5678
6204
  }
6205
+ if (first && typeof first === "object") return targetOf(first);
5679
6206
  } catch {
5680
6207
  }
5681
6208
  return null;
5682
6209
  }
6210
+ function optionsFromUrl(url) {
6211
+ const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
6212
+ const options = { protocol: url.protocol, hostname, path: `${url.pathname || "/"}${url.search}` };
6213
+ if (url.port !== "") options.port = Number(url.port);
6214
+ return options;
6215
+ }
6216
+ function targetOf(options) {
6217
+ const host = normalizeHost(options.hostname || options.host) || "localhost";
6218
+ const protocol = options.protocol || "http:";
6219
+ const port = options.port ? `:${options.port}` : "";
6220
+ const path9 = options.path || "/";
6221
+ const authority = host.includes(":") ? `[${host}]` : host;
6222
+ return { url: `${protocol}//${authority}${port}${path9}`, host, method: options.method || "GET" };
6223
+ }
5683
6224
  function normalizeHost(raw) {
5684
6225
  const host = String(raw || "").trim();
5685
6226
  const bracketed = /^\[([^\]]+)\]/.exec(host);
@@ -6061,19 +6602,49 @@ async function loadFs() {
6061
6602
  }
6062
6603
  return fsMod;
6063
6604
  }
6605
+ async function sourceIdentity(options = {}) {
6606
+ if (typeof options.siteUuid === "string" && options.siteUuid.trim() !== "") {
6607
+ const endpoint = typeof options.pulseRulesUrl === "string" ? options.pulseRulesUrl : "";
6608
+ return `site:${options.siteUuid.trim().toLowerCase()}@${endpoint}`;
6609
+ }
6610
+ if (typeof options.token === "string" && options.token !== "") {
6611
+ const digest = await sha256Hex(options.token);
6612
+ if (digest === null) return null;
6613
+ const endpoint = typeof options.baseUrl === "string" ? options.baseUrl : "";
6614
+ return `token:${digest}@${endpoint}`;
6615
+ }
6616
+ return null;
6617
+ }
6618
+ async function sha256Hex(text) {
6619
+ try {
6620
+ const subtle = globalThis.crypto?.subtle;
6621
+ if (!subtle || typeof TextEncoder === "undefined") return null;
6622
+ const digest = await subtle.digest("SHA-256", new TextEncoder().encode(text));
6623
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
6624
+ } catch {
6625
+ return null;
6626
+ }
6627
+ }
6064
6628
  function makeStore(options = {}) {
6065
6629
  let mem = null;
6066
6630
  const durable = durableTier(options);
6631
+ let identity;
6632
+ const identityOf = () => identity ??= sourceIdentity(options);
6067
6633
  return {
6068
6634
  async read() {
6069
6635
  if (mem) return mem;
6636
+ const source = await identityOf();
6637
+ if (source === null) return null;
6070
6638
  const env = await durable.read();
6071
- if (env) mem = env;
6639
+ if (!env || env.source !== source) return null;
6640
+ mem = env;
6072
6641
  return env;
6073
6642
  },
6074
6643
  async write(env) {
6075
- if (env) mem = env;
6076
- await durable.write(env);
6644
+ if (!env) return;
6645
+ const stamped = { ...env, source: await identityOf() };
6646
+ mem = stamped;
6647
+ await durable.write(stamped);
6077
6648
  }
6078
6649
  };
6079
6650
  }
@@ -6195,11 +6766,12 @@ function toEnvelope(value) {
6195
6766
  buildId,
6196
6767
  // A confirmation belongs to the presentation stored beside it. A crossed or partially written
6197
6768
  // envelope confirms nothing, even when one of its fields happens to name the current map.
6198
- matchedBuildId: buildId !== null && matched === buildId ? matched : null
6769
+ matchedBuildId: buildId !== null && matched === buildId ? matched : null,
6770
+ source: typeof value.source === "string" ? value.source : null
6199
6771
  };
6200
6772
  }
6201
6773
  if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
6202
- return { bundle: value, etag: null, buildId: null, matchedBuildId: null };
6774
+ return { bundle: value, etag: null, buildId: null, matchedBuildId: null, source: null };
6203
6775
  }
6204
6776
  return null;
6205
6777
  }
@@ -6267,13 +6839,17 @@ function withScopedDryRun(bundle, confirmed, reason, options = {}) {
6267
6839
  ...unusable > 0 ? [`${unusable} rule(s) carry an unusable ${BUILD_SCOPE_PROPERTY}`] : [],
6268
6840
  ...held > unusable ? [reason] : []
6269
6841
  ];
6270
- notify(
6271
- options.onError,
6272
- new Error(`${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`),
6273
- "onError"
6274
- );
6842
+ const message = `${held} build-scoped rule(s) are detecting only, not blocking: ${explanations.join("; ")}`;
6843
+ if (typeof options.onError === "function") notify(options.onError, new Error(message), "onError");
6844
+ else warnHeldOnce(message);
6275
6845
  return { ...bundle, firewall };
6276
6846
  }
6847
+ var warnedHeld = /* @__PURE__ */ new Set();
6848
+ function warnHeldOnce(message) {
6849
+ if (warnedHeld.has(message)) return;
6850
+ warnedHeld.add(message);
6851
+ console.warn(`[patchstack] ${message}. Pass { onError } to handle this yourself.`);
6852
+ }
6277
6853
  function confirmedBy(res, presented) {
6278
6854
  if (presented === null) return null;
6279
6855
  const verdict = res?.build;
@@ -6448,6 +7024,60 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
6448
7024
  }
6449
7025
  };
6450
7026
  }
7027
+ var RECOVERY_DELAYS_MS = [5e3, 15e3, 45e3, 12e4, 3e5, 6e5];
7028
+ function startRecovery(tick, { onError } = {}) {
7029
+ let stopped = false;
7030
+ let attempt = 0;
7031
+ let timer = null;
7032
+ const schedule = () => {
7033
+ if (stopped) return;
7034
+ const delay = RECOVERY_DELAYS_MS[Math.min(attempt, RECOVERY_DELAYS_MS.length - 1)];
7035
+ timer = setTimeout(run, delay * (1 - Math.random() * JITTER_FRACTION3));
7036
+ timer?.unref?.();
7037
+ };
7038
+ const run = async () => {
7039
+ if (stopped) return;
7040
+ attempt++;
7041
+ try {
7042
+ const status = await tick();
7043
+ if (!status || status.ok !== false) {
7044
+ stopped = true;
7045
+ return;
7046
+ }
7047
+ } catch (err) {
7048
+ notify(onError, err, "onError");
7049
+ }
7050
+ schedule();
7051
+ };
7052
+ schedule();
7053
+ return {
7054
+ stop() {
7055
+ stopped = true;
7056
+ if (timer) clearTimeout(timer);
7057
+ }
7058
+ };
7059
+ }
7060
+ function serialise(tick) {
7061
+ let running = null;
7062
+ let next = null;
7063
+ const run = () => {
7064
+ if (!running) {
7065
+ running = Promise.resolve().then(() => tick()).finally(() => {
7066
+ running = null;
7067
+ });
7068
+ return running;
7069
+ }
7070
+ if (!next) {
7071
+ next = running.catch(() => {
7072
+ }).then(() => {
7073
+ next = null;
7074
+ return run();
7075
+ });
7076
+ }
7077
+ return next;
7078
+ };
7079
+ return run;
7080
+ }
6451
7081
  function makeRefreshHandler(tick, secret) {
6452
7082
  let inflight2 = null;
6453
7083
  const runOnce = () => {
@@ -6462,7 +7092,7 @@ function makeRefreshHandler(tick, secret) {
6462
7092
  return async (request) => {
6463
7093
  if (!secret) return new Response("not found", { status: 404 });
6464
7094
  const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
6465
- if (provided !== secret) return new Response("forbidden", { status: 403 });
7095
+ if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
6466
7096
  let refreshed = true;
6467
7097
  try {
6468
7098
  const status = await runOnce();
@@ -6473,6 +7103,33 @@ function makeRefreshHandler(tick, secret) {
6473
7103
  return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
6474
7104
  };
6475
7105
  }
7106
+ async function sameSecret(provided, secret) {
7107
+ if (typeof provided !== "string" || typeof secret !== "string") return false;
7108
+ const subtle = globalThis.crypto?.subtle;
7109
+ if (subtle && typeof TextEncoder === "function") {
7110
+ try {
7111
+ const encoder2 = new TextEncoder();
7112
+ const [a, b] = await Promise.all([
7113
+ subtle.digest("SHA-256", encoder2.encode(provided)),
7114
+ subtle.digest("SHA-256", encoder2.encode(secret))
7115
+ ]);
7116
+ return equalBytes(new Uint8Array(a), new Uint8Array(b));
7117
+ } catch {
7118
+ }
7119
+ }
7120
+ let diff = provided.length ^ secret.length;
7121
+ const length = Math.max(provided.length, secret.length);
7122
+ for (let i = 0; i < length; i++) {
7123
+ diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
7124
+ }
7125
+ return diff === 0;
7126
+ }
7127
+ function equalBytes(a, b) {
7128
+ if (a.length !== b.length) return false;
7129
+ let diff = 0;
7130
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
7131
+ return diff === 0;
7132
+ }
6476
7133
 
6477
7134
  // src/protect/detections.js
6478
7135
  init_pulse_token();
@@ -7208,9 +7865,180 @@ function hardensWithoutBody(rule) {
7208
7865
  if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
7209
7866
  const { parameters, complete } = readRuleParameters(rule);
7210
7867
  if (!complete) return false;
7211
- return parameters.every(
7212
- (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
7868
+ return parameters.every(bodyIndependent);
7869
+ }
7870
+ function readsOnlyResponseHeaders(rule) {
7871
+ if (!rule) return false;
7872
+ const { parameters, complete } = readRuleParameters(rule);
7873
+ if (!complete || !parameters.some(isHeaderParameter)) return false;
7874
+ return parameters.every(bodyIndependent);
7875
+ }
7876
+ function isHeaderParameter(parameter) {
7877
+ return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
7878
+ }
7879
+ function bodyIndependent(parameter) {
7880
+ return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
7881
+ }
7882
+
7883
+ // src/protect/supabase-guard.js
7884
+ var GUARD_PATH = "/_patchstack/guard";
7885
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7886
+ var DEFAULT_TIMEOUT_MS = 3e4;
7887
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7888
+ "connection",
7889
+ "cookie",
7890
+ "cookie2",
7891
+ "forwarded",
7892
+ "host",
7893
+ "keep-alive",
7894
+ "proxy-authenticate",
7895
+ "proxy-authorization",
7896
+ "te",
7897
+ "trailer",
7898
+ "transfer-encoding",
7899
+ "upgrade",
7900
+ "x-forwarded-for",
7901
+ "x-forwarded-host",
7902
+ "x-forwarded-port",
7903
+ "x-forwarded-proto",
7904
+ "x-ps-target",
7905
+ "x-real-ip"
7906
+ ]);
7907
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7908
+ "connection",
7909
+ "content-encoding",
7910
+ "content-length",
7911
+ "keep-alive",
7912
+ "proxy-authenticate",
7913
+ "proxy-authorization",
7914
+ "set-cookie",
7915
+ "set-cookie2",
7916
+ "te",
7917
+ "trailer",
7918
+ "transfer-encoding",
7919
+ "upgrade"
7920
+ ]);
7921
+ var BodyTooLargeError = class extends Error {
7922
+ };
7923
+ async function boundedBody(request, maxBytes) {
7924
+ const stated2 = Number(request.headers.get("content-length"));
7925
+ if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7926
+ if (!request.body) return void 0;
7927
+ const reader = request.body.getReader();
7928
+ const chunks = [];
7929
+ let total = 0;
7930
+ try {
7931
+ for (; ; ) {
7932
+ const { done, value } = await reader.read();
7933
+ if (done) break;
7934
+ total += value.byteLength;
7935
+ if (total > maxBytes) {
7936
+ void reader.cancel().catch(() => {
7937
+ });
7938
+ throw new BodyTooLargeError();
7939
+ }
7940
+ chunks.push(value);
7941
+ }
7942
+ } finally {
7943
+ reader.releaseLock();
7944
+ }
7945
+ const body = new Uint8Array(total);
7946
+ let offset = 0;
7947
+ for (const chunk of chunks) {
7948
+ body.set(chunk, offset);
7949
+ offset += chunk.byteLength;
7950
+ }
7951
+ return body;
7952
+ }
7953
+ function requestHeaders(input) {
7954
+ const output = new Headers();
7955
+ const connectionTokens = new Set(
7956
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7957
+ );
7958
+ input.forEach((value, key) => {
7959
+ const lower = key.toLowerCase();
7960
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7961
+ });
7962
+ return output;
7963
+ }
7964
+ function responseHeaders(input) {
7965
+ const output = new Headers();
7966
+ const connectionTokens = new Set(
7967
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7213
7968
  );
7969
+ input.forEach((value, key) => {
7970
+ const lower = key.toLowerCase();
7971
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7972
+ });
7973
+ return output;
7974
+ }
7975
+ var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
7976
+ function createSupabaseGuard({
7977
+ protection,
7978
+ supabaseUrl,
7979
+ fetchImpl = fetch,
7980
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7981
+ timeoutMs = DEFAULT_TIMEOUT_MS
7982
+ }) {
7983
+ const guard = protection.fetchGuard();
7984
+ const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7985
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7986
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
7987
+ return async function handleGuardRequest(request, ...hostArgs) {
7988
+ const target = request.headers.get("x-ps-target");
7989
+ if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
7990
+ let targetUrl;
7991
+ try {
7992
+ targetUrl = new URL(target);
7993
+ } catch {
7994
+ return new Response("patchstack: invalid target", { status: 400 });
7995
+ }
7996
+ if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
7997
+ return new Response("patchstack: target not allowed", { status: 403 });
7998
+ }
7999
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
8000
+ let body;
8001
+ try {
8002
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
8003
+ } catch (error) {
8004
+ if (error instanceof BodyTooLargeError) {
8005
+ return new Response("patchstack: request body too large", { status: 413 });
8006
+ }
8007
+ throw error;
8008
+ }
8009
+ const forwardHeaders = requestHeaders(request.headers);
8010
+ const evalReq = new Request(targetUrl.toString(), {
8011
+ method: request.method,
8012
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
8013
+ // sanitized at the cross-origin boundary below.
8014
+ headers: request.headers,
8015
+ body
8016
+ });
8017
+ Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
8018
+ const blocked = await guard(evalReq, ...hostArgs);
8019
+ if (blocked) return blocked;
8020
+ let upstream;
8021
+ try {
8022
+ upstream = await fetchImpl(targetUrl.toString(), {
8023
+ method: request.method,
8024
+ headers: forwardHeaders,
8025
+ body,
8026
+ redirect: "manual",
8027
+ signal: AbortSignal.timeout(upstreamTimeout)
8028
+ });
8029
+ } catch (error) {
8030
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
8031
+ return new Response("patchstack: upstream timed out", { status: 504 });
8032
+ }
8033
+ throw error;
8034
+ }
8035
+ const forwarded = new Response(upstream.body, {
8036
+ status: upstream.status,
8037
+ statusText: upstream.statusText,
8038
+ headers: responseHeaders(upstream.headers)
8039
+ });
8040
+ return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
8041
+ };
7214
8042
  }
7215
8043
 
7216
8044
  // src/protect/firewall-log.js
@@ -7261,8 +8089,13 @@ function resolveApiBase(pulseOrManifestUrl) {
7261
8089
  function createFirewallLogReporter(opts) {
7262
8090
  const creds = parseApiKey(opts.apiKey);
7263
8091
  if (!creds) {
7264
- return { record() {
7265
- }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
8092
+ return {
8093
+ record() {
8094
+ },
8095
+ flush: () => Promise.resolve(),
8096
+ stop: () => Promise.resolve(),
8097
+ health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
8098
+ };
7266
8099
  }
7267
8100
  const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
7268
8101
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -7275,6 +8108,19 @@ function createFirewallLogReporter(opts) {
7275
8108
  let drainPromise = null;
7276
8109
  let activeController = null;
7277
8110
  let ended = false;
8111
+ let recorded = 0;
8112
+ let delivered = 0;
8113
+ let failed = 0;
8114
+ let dropped = 0;
8115
+ let activeBatch = null;
8116
+ const settle = (sent, outcome) => {
8117
+ if (sent.counted) return;
8118
+ sent.counted = true;
8119
+ if (outcome === "delivered") delivered += sent.size;
8120
+ else if (outcome === "failed") failed += sent.size;
8121
+ else dropped += sent.size;
8122
+ if (activeBatch === sent) activeBatch = null;
8123
+ };
7278
8124
  let cachedToken = null;
7279
8125
  let tokenInflight = null;
7280
8126
  const fetchAccessToken = async (signal) => {
@@ -7328,10 +8174,16 @@ function createFirewallLogReporter(opts) {
7328
8174
  const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
7329
8175
  attemptTimer?.unref?.();
7330
8176
  let entry;
8177
+ const sent = { size: batch.length, counted: false };
8178
+ activeBatch = sent;
7331
8179
  entry = (async () => {
7332
8180
  try {
7333
8181
  const token = await fetchAccessToken(controller?.signal);
7334
- if (!token || ended) return;
8182
+ if (ended) return;
8183
+ if (!token) {
8184
+ settle(sent, "failed");
8185
+ return;
8186
+ }
7335
8187
  const body = new URLSearchParams();
7336
8188
  body.set("type", "firewall");
7337
8189
  body.set("logs", JSON.stringify(batch));
@@ -7348,10 +8200,11 @@ function createFirewallLogReporter(opts) {
7348
8200
  // Both phases carry the attempt controller, so slow transports cannot accumulate work.
7349
8201
  ...controller ? { signal: controller.signal } : {}
7350
8202
  });
7351
- if (p && typeof p.then === "function") await p.catch(() => {
7352
- });
8203
+ const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
8204
+ settle(sent, res && res.ok ? "delivered" : "failed");
7353
8205
  } catch {
7354
8206
  } finally {
8207
+ settle(sent, "failed");
7355
8208
  clearTimeout(attemptTimer);
7356
8209
  if (activeController === controller) activeController = null;
7357
8210
  if (inFlight === entry) inFlight = null;
@@ -7378,234 +8231,85 @@ function createFirewallLogReporter(opts) {
7378
8231
  if (stopped) return;
7379
8232
  const fid = event?.rule?.id;
7380
8233
  if (fid === void 0 || fid === null || fid === "") return;
7381
- if (queue.length >= MAX_QUEUE2) return;
7382
- queue.push({
7383
- fid,
7384
- method: event.method ?? null,
7385
- request_uri: event.path ?? null,
7386
- ip: event.ip ?? null,
7387
- user_agent: event.userAgent ?? null,
7388
- log_date: (/* @__PURE__ */ new Date()).toISOString()
7389
- });
7390
- if (queue.length >= MAX_BATCH2) {
7391
- flush();
8234
+ if (queue.length >= MAX_QUEUE2) {
8235
+ dropped++;
7392
8236
  return;
7393
8237
  }
7394
- if (!timer) timer = setTimeout(flush, flushMs);
7395
- },
7396
- flush,
7397
- /**
7398
- * Stop, and hand back a wait for what was outstanding.
7399
- *
7400
- * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
7401
- * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
7402
- * spinning would be worse than leaving the records where they are.
7403
- */
7404
- stop() {
7405
- if (drainPromise) return drainPromise;
7406
- stopped = true;
7407
- if (timer) {
7408
- clearTimeout(timer);
7409
- timer = null;
7410
- }
7411
- const terminate = () => {
7412
- ended = true;
7413
- activeController?.abort();
7414
- activeController = null;
7415
- queue = [];
7416
- inFlight = null;
7417
- };
7418
- let budget = null;
7419
- const spent = new Promise((resolve) => {
7420
- budget = setTimeout(() => {
7421
- terminate();
7422
- resolve();
7423
- }, STOP_BUDGET_MS2);
7424
- if (typeof budget.unref === "function") budget.unref();
7425
- });
7426
- const work = (async () => {
7427
- while (!ended) {
7428
- if (inFlight) {
7429
- await inFlight;
7430
- continue;
7431
- }
7432
- if (queue.length === 0) break;
7433
- const before = queue.length;
7434
- await flush();
7435
- if (queue.length >= before) break;
7436
- }
7437
- })();
7438
- drainPromise = Promise.race([work, spent]).then(() => {
7439
- if (budget) clearTimeout(budget);
7440
- });
7441
- return drainPromise;
7442
- }
7443
- };
7444
- }
7445
- function telemetryEnabled() {
7446
- const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
7447
- if (v === void 0 || v === "") return true;
7448
- return !/^(0|false|off|no)$/i.test(String(v));
7449
- }
7450
-
7451
- // src/protect/supabase-guard.js
7452
- var GUARD_PATH = "/_patchstack/guard";
7453
- var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7454
- var DEFAULT_TIMEOUT_MS = 3e4;
7455
- var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7456
- "connection",
7457
- "cookie",
7458
- "cookie2",
7459
- "forwarded",
7460
- "host",
7461
- "keep-alive",
7462
- "proxy-authenticate",
7463
- "proxy-authorization",
7464
- "te",
7465
- "trailer",
7466
- "transfer-encoding",
7467
- "upgrade",
7468
- "x-forwarded-for",
7469
- "x-forwarded-host",
7470
- "x-forwarded-port",
7471
- "x-forwarded-proto",
7472
- "x-ps-target",
7473
- "x-real-ip"
7474
- ]);
7475
- var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7476
- "connection",
7477
- "content-encoding",
7478
- "content-length",
7479
- "keep-alive",
7480
- "proxy-authenticate",
7481
- "proxy-authorization",
7482
- "set-cookie",
7483
- "set-cookie2",
7484
- "te",
7485
- "trailer",
7486
- "transfer-encoding",
7487
- "upgrade"
7488
- ]);
7489
- var BodyTooLargeError = class extends Error {
7490
- };
7491
- async function boundedBody(request, maxBytes) {
7492
- const stated2 = Number(request.headers.get("content-length"));
7493
- if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7494
- if (!request.body) return void 0;
7495
- const reader = request.body.getReader();
7496
- const chunks = [];
7497
- let total = 0;
7498
- try {
7499
- for (; ; ) {
7500
- const { done, value } = await reader.read();
7501
- if (done) break;
7502
- total += value.byteLength;
7503
- if (total > maxBytes) {
7504
- void reader.cancel().catch(() => {
7505
- });
7506
- throw new BodyTooLargeError();
7507
- }
7508
- chunks.push(value);
7509
- }
7510
- } finally {
7511
- reader.releaseLock();
7512
- }
7513
- const body = new Uint8Array(total);
7514
- let offset = 0;
7515
- for (const chunk of chunks) {
7516
- body.set(chunk, offset);
7517
- offset += chunk.byteLength;
7518
- }
7519
- return body;
7520
- }
7521
- function requestHeaders(input) {
7522
- const output = new Headers();
7523
- const connectionTokens = new Set(
7524
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7525
- );
7526
- input.forEach((value, key) => {
7527
- const lower = key.toLowerCase();
7528
- if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7529
- });
7530
- return output;
7531
- }
7532
- function responseHeaders(input) {
7533
- const output = new Headers();
7534
- const connectionTokens = new Set(
7535
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7536
- );
7537
- input.forEach((value, key) => {
7538
- const lower = key.toLowerCase();
7539
- if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7540
- });
7541
- return output;
7542
- }
7543
- function createSupabaseGuard({
7544
- protection,
7545
- supabaseUrl,
7546
- fetchImpl = fetch,
7547
- maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7548
- timeoutMs = DEFAULT_TIMEOUT_MS
7549
- }) {
7550
- const guard = protection.fetchGuard();
7551
- const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7552
- const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7553
- const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
7554
- return async function handleGuardRequest(request) {
7555
- const target = request.headers.get("x-ps-target");
7556
- if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
7557
- let targetUrl;
7558
- try {
7559
- targetUrl = new URL(target);
7560
- } catch {
7561
- return new Response("patchstack: invalid target", { status: 400 });
7562
- }
7563
- if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
7564
- return new Response("patchstack: target not allowed", { status: 403 });
7565
- }
7566
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
7567
- let body;
7568
- try {
7569
- body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
7570
- } catch (error) {
7571
- if (error instanceof BodyTooLargeError) {
7572
- return new Response("patchstack: request body too large", { status: 413 });
7573
- }
7574
- throw error;
7575
- }
7576
- const forwardHeaders = requestHeaders(request.headers);
7577
- const evalReq = new Request(targetUrl.toString(), {
7578
- method: request.method,
7579
- // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
7580
- // sanitized at the cross-origin boundary below.
7581
- headers: request.headers,
7582
- body
7583
- });
7584
- const blocked = await guard(evalReq);
7585
- if (blocked) return blocked;
7586
- let upstream;
7587
- try {
7588
- upstream = await fetchImpl(targetUrl.toString(), {
7589
- method: request.method,
7590
- headers: forwardHeaders,
7591
- body,
7592
- redirect: "manual",
7593
- signal: AbortSignal.timeout(upstreamTimeout)
8238
+ recorded++;
8239
+ queue.push({
8240
+ fid,
8241
+ method: event.method ?? null,
8242
+ request_uri: event.path ?? null,
8243
+ ip: event.ip ?? null,
8244
+ user_agent: event.userAgent ?? null,
8245
+ log_date: (/* @__PURE__ */ new Date()).toISOString()
7594
8246
  });
7595
- } catch (error) {
7596
- if (error?.name === "TimeoutError" || error?.name === "AbortError") {
7597
- return new Response("patchstack: upstream timed out", { status: 504 });
8247
+ if (queue.length >= MAX_BATCH2) {
8248
+ flush();
8249
+ return;
7598
8250
  }
7599
- throw error;
8251
+ if (!timer) timer = setTimeout(flush, flushMs);
8252
+ },
8253
+ flush,
8254
+ /** Where the records went so far; see the counters above. `queued` is what is waiting now. */
8255
+ health() {
8256
+ return { recorded, delivered, failed, dropped, queued: queue.length };
8257
+ },
8258
+ /**
8259
+ * Stop, and hand back a wait for what was outstanding.
8260
+ *
8261
+ * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
8262
+ * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
8263
+ * spinning would be worse than leaving the records where they are.
8264
+ */
8265
+ stop() {
8266
+ if (drainPromise) return drainPromise;
8267
+ stopped = true;
8268
+ if (timer) {
8269
+ clearTimeout(timer);
8270
+ timer = null;
8271
+ }
8272
+ const terminate = () => {
8273
+ ended = true;
8274
+ activeController?.abort();
8275
+ activeController = null;
8276
+ if (activeBatch) settle(activeBatch, "dropped");
8277
+ dropped += queue.length;
8278
+ queue = [];
8279
+ inFlight = null;
8280
+ };
8281
+ let budget = null;
8282
+ const spent = new Promise((resolve) => {
8283
+ budget = setTimeout(() => {
8284
+ terminate();
8285
+ resolve();
8286
+ }, STOP_BUDGET_MS2);
8287
+ if (typeof budget.unref === "function") budget.unref();
8288
+ });
8289
+ const work = (async () => {
8290
+ while (!ended) {
8291
+ if (inFlight) {
8292
+ await inFlight;
8293
+ continue;
8294
+ }
8295
+ if (queue.length === 0) break;
8296
+ const before = queue.length;
8297
+ await flush();
8298
+ if (queue.length >= before) break;
8299
+ }
8300
+ })();
8301
+ drainPromise = Promise.race([work, spent]).then(() => {
8302
+ if (budget) clearTimeout(budget);
8303
+ });
8304
+ return drainPromise;
7600
8305
  }
7601
- const forwarded = new Response(upstream.body, {
7602
- status: upstream.status,
7603
- statusText: upstream.statusText,
7604
- headers: responseHeaders(upstream.headers)
7605
- });
7606
- return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
7607
8306
  };
7608
8307
  }
8308
+ function telemetryEnabled() {
8309
+ const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
8310
+ if (v === void 0 || v === "") return true;
8311
+ return !/^(0|false|off|no)$/i.test(String(v));
8312
+ }
7609
8313
 
7610
8314
  // src/protect/verify-sentinel.js
7611
8315
  var VERIFY_HEADER = "x-patchstack-verify";
@@ -7697,6 +8401,7 @@ async function createProtection(options = {}) {
7697
8401
  "onDetect"
7698
8402
  );
7699
8403
  };
8404
+ const nodeHttp = await import("http").then((m) => m.default ?? m, () => null);
7700
8405
  const store = makeStore(options);
7701
8406
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
7702
8407
  const pulseAuth = await resolvePulseAuth(options);
@@ -7722,6 +8427,21 @@ async function createProtection(options = {}) {
7722
8427
  pulseAuth,
7723
8428
  detectionState: preFetchState
7724
8429
  });
8430
+ let ruleSource = { ...bundle.source ?? { ok: true, origin: "empty" } };
8431
+ const warnedSources = /* @__PURE__ */ new Set();
8432
+ const hasLiveSource = Boolean(options.siteUuid || options.token);
8433
+ const noteRuleSource = (source) => {
8434
+ ruleSource = { ...source ?? { ok: true, origin: ruleSource.origin } };
8435
+ if (!hasLiveSource || typeof onError === "function" || ruleSource.ok !== false) return;
8436
+ const cause = `${ruleSource.origin}:${ruleSource.reason ?? ""}`;
8437
+ if (warnedSources.has(cause)) return;
8438
+ warnedSources.add(cause);
8439
+ const running = ruleSource.origin === "cache" ? "the last rules it received" : ruleSource.origin === "bundled" ? "its bundled fallback rules" : "no rules at all";
8440
+ console.warn(
8441
+ `[patchstack] the guard's rules are not current (${ruleSource.reason ?? "the rules source did not answer"}); it is running on ${running}. Pass { onError } to handle this yourself.`
8442
+ );
8443
+ };
8444
+ noteRuleSource(bundle.source);
7725
8445
  let detectionReporting = "not-enrolled";
7726
8446
  let currentOrigin;
7727
8447
  const applyReportingState = async (origin) => {
@@ -7777,20 +8497,47 @@ async function createProtection(options = {}) {
7777
8497
  whitelist_keys: delivered.whitelist_keys,
7778
8498
  onError
7779
8499
  });
7780
- responseRuleSet = responseRules.map((rule) => ({
7781
- rule,
7782
- engine: new RuleEngine({ firewall: [rule], onError }),
7783
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
7784
- // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
7785
- // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
7786
- // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
7787
- // fails such a rule CLOSED (block) instead of serving a no-op redaction.
7788
- mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
7789
- // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
7790
- // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
7791
- // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
7792
- prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
7793
- }));
8500
+ responseRuleSet = responseRules.map((rule) => {
8501
+ const unscoped = [];
8502
+ const rewrites = rule.action === "redact" || rule.action === "encode";
8503
+ let derived;
8504
+ try {
8505
+ const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
8506
+ const mutatedSpan = rewrites && hasSpanMutations(rule);
8507
+ derived = {
8508
+ redactors,
8509
+ mutatedSpan,
8510
+ // A redaction that reads response headers only, and masks spans in them, and an explicit block
8511
+ // that reads response headers only, are decided and carried out without the body — so they
8512
+ // still apply to a response whose body was not screened.
8513
+ redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
8514
+ blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
8515
+ };
8516
+ } catch (err) {
8517
+ notify(onError, err, "onError");
8518
+ derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
8519
+ }
8520
+ if (unscoped.length) {
8521
+ notify(
8522
+ onError,
8523
+ new Error(
8524
+ `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`
8525
+ ),
8526
+ "onError"
8527
+ );
8528
+ }
8529
+ return {
8530
+ rule,
8531
+ engine: new RuleEngine({ firewall: [rule], onError }),
8532
+ ...derived,
8533
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
8534
+ // have any chance of matching. Lets screenText skip the full scan on responses with no candidate
8535
+ // — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
8536
+ // Case-insensitive, and checked against the header values as well as the body, since a rule may
8537
+ // read either.
8538
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
8539
+ };
8540
+ });
7794
8541
  egressRuleSet = egressRules.map((rule) => ({
7795
8542
  rule,
7796
8543
  engine: new RuleEngine({ firewall: [rule], onError })
@@ -7804,6 +8551,9 @@ async function createProtection(options = {}) {
7804
8551
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
7805
8552
  notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
7806
8553
  };
8554
+ const recordEvaluationSkips = (result) => {
8555
+ for (const reason of result?.skips ?? []) recordSkip("request", reason);
8556
+ };
7807
8557
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
7808
8558
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
7809
8559
  const planCache = createPlanCache();
@@ -7846,15 +8596,19 @@ async function createProtection(options = {}) {
7846
8596
  });
7847
8597
  return effectiveMode === "block" ? block() : allow2();
7848
8598
  };
8599
+ const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
8600
+ const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
7849
8601
  const screenText = (text, meta, reqCtx, only) => {
7850
8602
  let blockRule = null;
7851
8603
  const redactions = [];
7852
8604
  const headerMutations = [];
7853
8605
  let lowerText = null;
7854
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
7855
- if (only && !only(rule)) continue;
8606
+ const responseSkips = /* @__PURE__ */ new Set();
8607
+ for (const entry of responseRuleSet) {
8608
+ const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
8609
+ if (only && !only(rule, entry)) continue;
7856
8610
  if (prefilter) {
7857
- if (lowerText === null) lowerText = text.toLowerCase();
8611
+ if (lowerText === null) lowerText = prefilterText(text, meta.headers);
7858
8612
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
7859
8613
  }
7860
8614
  let result;
@@ -7864,6 +8618,11 @@ async function createProtection(options = {}) {
7864
8618
  notify(onError, err, "onError");
7865
8619
  continue;
7866
8620
  }
8621
+ for (const reason of result.skips ?? []) {
8622
+ if (responseSkips.has(reason)) continue;
8623
+ responseSkips.add(reason);
8624
+ recordSkip("response", reason);
8625
+ }
7867
8626
  if (!result.blocked) continue;
7868
8627
  const responseMode = ruleMode(rule);
7869
8628
  onDetect({
@@ -7893,6 +8652,8 @@ async function createProtection(options = {}) {
7893
8652
  if (blockRule) return { verdict: "block" };
7894
8653
  let body = text;
7895
8654
  const headers = { ...meta.headers || {} };
8655
+ let responseIsJson;
8656
+ let structure;
7896
8657
  for (const { rule, redactors } of redactions) {
7897
8658
  const mask = maskFn(rule.category);
7898
8659
  const transform = rule.action === "encode" ? htmlEscape : null;
@@ -7900,17 +8661,29 @@ async function createProtection(options = {}) {
7900
8661
  const spanRedactors = redactors.filter((r) => !r.jsonPath);
7901
8662
  if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
7902
8663
  if (!spanRedactors.length) continue;
7903
- body = applyRedactors(body, spanRedactors, mask, transform);
8664
+ const beforeSpan = body;
8665
+ const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
8666
+ if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
8667
+ if (body !== beforeSpan) {
8668
+ responseIsJson ??= isJson(text);
8669
+ if (responseIsJson) {
8670
+ if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
8671
+ const tokens = lexJson(body);
8672
+ if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
8673
+ structure = { text: body, tokens };
8674
+ }
8675
+ }
7904
8676
  if (transform) continue;
7905
8677
  for (const name of Object.keys(headers)) {
7906
8678
  const value = headers[name];
8679
+ const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
7907
8680
  if (typeof value === "string") {
7908
- setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
8681
+ setOwn(headers, name, applyRedactors(value, headerSpans, mask));
7909
8682
  } else if (Array.isArray(value)) {
7910
8683
  setOwn(
7911
8684
  headers,
7912
8685
  name,
7913
- value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
8686
+ value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
7914
8687
  );
7915
8688
  }
7916
8689
  }
@@ -7957,18 +8730,53 @@ async function createProtection(options = {}) {
7957
8730
  }
7958
8731
  };
7959
8732
  };
7960
- const screenFetchRequest = async (request) => {
8733
+ const peerOf = (request, hostArgs) => {
8734
+ if (typeof options.peerAddress !== "function") return void 0;
8735
+ try {
8736
+ return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
8737
+ } catch (err) {
8738
+ notify(onError, err, "onError");
8739
+ return void 0;
8740
+ }
8741
+ };
8742
+ let warnedNoPeer = false;
8743
+ const warnNoPeer = () => {
8744
+ if (warnedNoPeer) return;
8745
+ warnedNoPeer = true;
8746
+ 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.";
8747
+ if (typeof onError === "function") notify(onError, new Error(message), "onError");
8748
+ else console.warn(message);
8749
+ };
8750
+ const clientByRequest = /* @__PURE__ */ new WeakMap();
8751
+ const clientForResponse = (request, hostArgs) => {
8752
+ const known = clientByRequest.get(request);
8753
+ if (known) return known;
8754
+ const client = resolveClientIp({
8755
+ peer: peerOf(request, hostArgs),
8756
+ headers: headerObject(request.headers),
8757
+ trustedProxy: options.trustedProxy
8758
+ });
8759
+ if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
8760
+ return client;
8761
+ };
8762
+ const screenFetchRequest = async (request, hostArgs = []) => {
7961
8763
  let result;
7962
8764
  let shaped;
7963
8765
  try {
7964
8766
  shaped = await fromFetchRequest(request, {
8767
+ peer: peerOf(request, hostArgs),
7965
8768
  trustedProxy: options.trustedProxy,
7966
8769
  maxBodyBytes: options.maxBodyBytes
7967
8770
  });
8771
+ if (shaped?._clientIp) {
8772
+ clientByRequest.set(request, shaped._clientIp);
8773
+ if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
8774
+ }
7968
8775
  if (shaped?._bodyInspectionSkip) {
7969
8776
  recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
7970
8777
  }
7971
8778
  result = engine.evaluate(shaped);
8779
+ recordEvaluationSkips(result);
7972
8780
  } catch (err) {
7973
8781
  notify(onError, err, "onError");
7974
8782
  return { blocked: null, client: void 0 };
@@ -8017,14 +8825,20 @@ async function createProtection(options = {}) {
8017
8825
  const screenResp = async (response, reqCtx) => {
8018
8826
  const read = await readTextResponse(response, screenCap);
8019
8827
  if (read.skip) {
8020
- if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
8828
+ if (read.skip !== "not-a-response") {
8829
+ recordSkip("response", read.skip, {
8830
+ status: response?.status,
8831
+ ...read.encoding ? { encoding: read.encoding } : {},
8832
+ ...read.charset ? { charset: read.charset } : {}
8833
+ });
8834
+ }
8021
8835
  if (read.skip === "not-a-response") return response;
8022
8836
  return hardenHeadersOnly(response, reqCtx);
8023
8837
  }
8024
- const text = read.text;
8838
+ const text = readJsonEscapes(read.text);
8025
8839
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
8026
- if (r.verdict === "block") return leakResponse();
8027
- if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
8840
+ if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
8841
+ if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
8028
8842
  return response;
8029
8843
  };
8030
8844
  const headerValueChanged = (was, value) => {
@@ -8086,7 +8900,8 @@ async function createProtection(options = {}) {
8086
8900
  const hardenHeadersOnly = (response, reqCtx) => {
8087
8901
  try {
8088
8902
  const meta = { status: response.status, headers: headerObject(response.headers) };
8089
- const r = screenText("", meta, reqCtx, hardensWithoutBody);
8903
+ const r = screenText("", meta, reqCtx, decidedWithoutBody);
8904
+ if (r.verdict === "block") return leakResponse();
8090
8905
  if (r.verdict !== "redact" || !r.headers) return response;
8091
8906
  if (!headersChanged(meta.headers, r.headers)) return response;
8092
8907
  return rebuildResponse(response, response.body, r.headers);
@@ -8098,9 +8913,16 @@ async function createProtection(options = {}) {
8098
8913
  const wrapNodeResponse = (res, reqCtx) => {
8099
8914
  const origWrite = res.write.bind(res);
8100
8915
  const origEnd = res.end.bind(res);
8916
+ const setHeaderNow = typeof res.setHeader === "function" ? res.setHeader.bind(res) : null;
8917
+ const removeHeaderNow = typeof res.removeHeader === "function" ? res.removeHeader.bind(res) : null;
8918
+ const sentAccessor = accessorOf(res, "headersSent");
8919
+ let sentValue = sentAccessor ? void 0 : res.headersSent;
8920
+ const reallySent = () => Boolean(sentAccessor ? sentAccessor.call(res) : sentValue);
8101
8921
  const chunks = [];
8102
8922
  let size = 0;
8103
8923
  let overflow = false;
8924
+ let withheld = false;
8925
+ let headScreened = false;
8104
8926
  const MAX = screenCap;
8105
8927
  let hardened = false;
8106
8928
  let answeredWithoutBody = false;
@@ -8108,7 +8930,7 @@ async function createProtection(options = {}) {
8108
8930
  if (hardened) return null;
8109
8931
  hardened = true;
8110
8932
  try {
8111
- if (res.headersSent || typeof res.setHeader !== "function") return null;
8933
+ if (reallySent() || !setHeaderNow) return null;
8112
8934
  const set2 = typeof res.getHeaders === "function" ? res.getHeaders() : {};
8113
8935
  const headers = effective && effective.headers ? { ...set2, ...effective.headers } : set2;
8114
8936
  const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
@@ -8120,8 +8942,8 @@ async function createProtection(options = {}) {
8120
8942
  if (!headerValueChanged(headers[name], value)) continue;
8121
8943
  changed.set(name.toLowerCase(), value);
8122
8944
  try {
8123
- if (value === null || value === void 0) res.removeHeader?.(name);
8124
- else res.setHeader(name, value);
8945
+ if (value === null || value === void 0) removeHeaderNow?.(name);
8946
+ else setHeaderNow(name, value);
8125
8947
  } catch {
8126
8948
  }
8127
8949
  }
@@ -8132,26 +8954,187 @@ async function createProtection(options = {}) {
8132
8954
  }
8133
8955
  };
8134
8956
  const stillToAnswer = (rule) => !hardensWithoutBody(rule);
8135
- if (typeof res.writeHead === "function") {
8136
- const origWriteHead = res.writeHead.bind(res);
8957
+ const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
8958
+ let pendingHead = null;
8959
+ let sending = false;
8960
+ const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
8961
+ if (origWriteHead) {
8137
8962
  res.writeHead = function(...args) {
8138
- const at = writeHeadHeaderIndex(args);
8139
- const given = at === -1 ? null : args[at];
8140
- const changed = hardenBeforeFlush({
8141
- status: typeof args[0] === "number" ? args[0] : void 0,
8142
- headers: given ? writeHeadHeaderObject(given) : null
8143
- });
8144
- if (!changed || !given) return origWriteHead(...args);
8145
- const rewritten = [...args];
8146
- rewritten[at] = rewriteWriteHeadHeaders(given, changed);
8147
- return origWriteHead(...rewritten);
8963
+ if (sending || reallySent()) return origWriteHead(...args);
8964
+ if (pendingHead) throw headersSentError("write");
8965
+ const judged = judgeWriteHead(args);
8966
+ if (judged) {
8967
+ res.statusCode = judged.statusCode;
8968
+ res.statusMessage = judged.statusMessage;
8969
+ mirrorHeaders(judged.headers);
8970
+ pendingHead = args;
8971
+ } else {
8972
+ pendingHead = args;
8973
+ if (typeof args[0] === "number") res.statusCode = args[0];
8974
+ if (typeof args[1] === "string") res.statusMessage = args[1];
8975
+ }
8976
+ return res;
8977
+ };
8978
+ }
8979
+ function judgeWriteHead(args) {
8980
+ const ServerResponse = nodeHttp?.ServerResponse;
8981
+ if (typeof ServerResponse !== "function") return null;
8982
+ const detached = new ServerResponse({ method: res.req?.method ?? "GET", httpVersionMajor: 1, httpVersionMinor: 1, headers: {} });
8983
+ if (res.statusMessage !== void 0) detached.statusMessage = res.statusMessage;
8984
+ for (const [name, value] of headerEntries(res)) detached.setHeader(name, value);
8985
+ detached.writeHead(...args);
8986
+ return { statusCode: detached.statusCode, statusMessage: detached.statusMessage, headers: headerEntries(detached) };
8987
+ }
8988
+ function headerEntries(target) {
8989
+ const names = typeof target.getRawHeaderNames === "function" ? target.getRawHeaderNames() : typeof target.getHeaderNames === "function" ? target.getHeaderNames() : [];
8990
+ return names.map((name) => [name, target.getHeader(name)]);
8991
+ }
8992
+ function mirrorHeaders(entries) {
8993
+ if (!setHeaderNow) return;
8994
+ for (const [name, value] of entries) {
8995
+ const current = res.getHeader?.(name);
8996
+ if (current === void 0 || headerValueChanged(current, value)) setHeaderNow(name, value);
8997
+ }
8998
+ }
8999
+ Object.defineProperty(res, "headersSent", {
9000
+ configurable: true,
9001
+ enumerable: true,
9002
+ get: () => pendingHead !== null || reallySent(),
9003
+ // A response-like object that tracks the flag itself keeps doing so.
9004
+ set: (value) => {
9005
+ if (!sentAccessor) sentValue = value;
9006
+ }
9007
+ });
9008
+ for (const [method, verb] of [["setHeader", "set"], ["removeHeader", "remove"], ["appendHeader", "append"]]) {
9009
+ const original = typeof res[method] === "function" ? res[method].bind(res) : null;
9010
+ if (!original) continue;
9011
+ res[method] = function(...args) {
9012
+ if (pendingHead) throw headersSentError(verb);
9013
+ return original(...args);
9014
+ };
9015
+ }
9016
+ if (typeof res.flushHeaders === "function") {
9017
+ const origFlushHeaders = res.flushHeaders.bind(res);
9018
+ res.flushHeaders = function() {
9019
+ const unread = screenUnreadHead();
9020
+ if (unread?.withhold) {
9021
+ sendWithheld();
9022
+ return void 0;
9023
+ }
9024
+ sendHead(unread);
9025
+ return origFlushHeaders();
8148
9026
  };
8149
9027
  }
9028
+ let hardenDone = false;
9029
+ let hardenedValues = null;
9030
+ const hardenOnce = () => {
9031
+ if (hardenDone) return hardenedValues;
9032
+ hardenDone = true;
9033
+ if (!pendingHead) {
9034
+ hardenedValues = hardenBeforeFlush();
9035
+ return hardenedValues;
9036
+ }
9037
+ const at = writeHeadHeaderIndex(pendingHead);
9038
+ hardenedValues = hardenBeforeFlush({
9039
+ status: typeof pendingHead[0] === "number" ? pendingHead[0] : void 0,
9040
+ headers: at === -1 ? null : writeHeadHeaderObject(pendingHead[at])
9041
+ });
9042
+ return hardenedValues;
9043
+ };
9044
+ const effectiveHead = () => {
9045
+ const set2 = typeof res.getHeaders === "function" ? { ...res.getHeaders() } : {};
9046
+ if (typeof res.getHeaders !== "function" && typeof res.getHeader === "function") {
9047
+ for (const name of ["content-type", "content-length"]) {
9048
+ const value = res.getHeader(name);
9049
+ if (value !== void 0) set2[name] = value;
9050
+ }
9051
+ }
9052
+ if (!pendingHead) return { status: res.statusCode, headers: set2 };
9053
+ const at = writeHeadHeaderIndex(pendingHead);
9054
+ return {
9055
+ status: typeof pendingHead[0] === "number" ? pendingHead[0] : res.statusCode,
9056
+ headers: at === -1 ? set2 : { ...set2, ...writeHeadHeaderObject(pendingHead[at]) }
9057
+ };
9058
+ };
9059
+ const screenUnreadHead = () => {
9060
+ if (headScreened) return void 0;
9061
+ headScreened = true;
9062
+ try {
9063
+ const head = effectiveHead();
9064
+ const r = screenText("", head, reqCtx, decidedByHeaders);
9065
+ if (r.verdict === "block") {
9066
+ if (!reallySent()) return { withhold: true };
9067
+ recordSkip("response", "headers-sent", { action: "block" });
9068
+ return void 0;
9069
+ }
9070
+ if (r.verdict !== "redact" || !r.headers) return void 0;
9071
+ const changed = /* @__PURE__ */ new Map();
9072
+ for (const [name, value] of Object.entries(r.headers)) {
9073
+ if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
9074
+ }
9075
+ if (changed.size && reallySent()) {
9076
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
9077
+ return void 0;
9078
+ }
9079
+ return changed.size ? { headers: changed } : void 0;
9080
+ } catch (err) {
9081
+ notify(onError, err, "onError");
9082
+ return void 0;
9083
+ }
9084
+ };
9085
+ const sendHead = (changes) => {
9086
+ if (sending) return;
9087
+ const hardened2 = hardenOnce();
9088
+ sending = true;
9089
+ if (!pendingHead) {
9090
+ if (changes) applyToResponse(changes);
9091
+ return;
9092
+ }
9093
+ const args = [...pendingHead];
9094
+ pendingHead = null;
9095
+ const merged = new Map(hardened2 ?? []);
9096
+ for (const [name, value] of changes?.headers ?? []) merged.set(name, value);
9097
+ if (changes?.status !== void 0) {
9098
+ args[0] = changes.status;
9099
+ if (typeof args[1] === "string") args.splice(1, 1);
9100
+ }
9101
+ const headerAt = writeHeadHeaderIndex(args);
9102
+ if (headerAt !== -1 && merged.size) args[headerAt] = rewriteWriteHeadHeaders(args[headerAt], merged);
9103
+ applyToResponse({ headers: merged });
9104
+ origWriteHead(...args);
9105
+ };
9106
+ const sendWithheld = (cb) => {
9107
+ const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
9108
+ const headers = /* @__PURE__ */ new Map();
9109
+ for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
9110
+ headers.set("content-type", "application/json");
9111
+ headers.set("content-length", String(Buffer.byteLength(body)));
9112
+ headers.set("transfer-encoding", null);
9113
+ withheld = true;
9114
+ sendHead({ status: 500, headers });
9115
+ return origEnd(body, cb);
9116
+ };
9117
+ const applyToResponse = (changes) => {
9118
+ if (changes.status !== void 0) res.statusCode = changes.status;
9119
+ for (const [name, value] of changes.headers ?? []) {
9120
+ try {
9121
+ if (value === null || value === void 0) removeHeaderNow?.(name);
9122
+ else setHeaderNow?.(name, value);
9123
+ } catch {
9124
+ }
9125
+ }
9126
+ };
8150
9127
  const collect = (chunk, enc) => {
8151
9128
  if (chunk == null) return;
8152
9129
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
8153
9130
  size += buf.length;
8154
9131
  if (size > MAX) {
9132
+ const unread = screenUnreadHead();
9133
+ if (unread?.withhold) {
9134
+ sendWithheld();
9135
+ return;
9136
+ }
9137
+ sendHead(unread);
8155
9138
  for (const c of chunks) origWrite(c);
8156
9139
  chunks.length = 0;
8157
9140
  origWrite(buf);
@@ -8162,7 +9145,11 @@ async function createProtection(options = {}) {
8162
9145
  chunks.push(buf);
8163
9146
  };
8164
9147
  res.write = function(chunk, enc, cb) {
8165
- hardenBeforeFlush();
9148
+ if (withheld) {
9149
+ if (typeof enc === "function") enc();
9150
+ else if (typeof cb === "function") cb();
9151
+ return true;
9152
+ }
8166
9153
  if (overflow) return origWrite(chunk, enc, cb);
8167
9154
  collect(chunk, enc);
8168
9155
  if (typeof enc === "function") enc();
@@ -8170,7 +9157,6 @@ async function createProtection(options = {}) {
8170
9157
  return true;
8171
9158
  };
8172
9159
  res.end = function(chunk, enc, cb) {
8173
- hardenBeforeFlush();
8174
9160
  if (typeof chunk === "function") {
8175
9161
  cb = chunk;
8176
9162
  chunk = void 0;
@@ -8179,74 +9165,90 @@ async function createProtection(options = {}) {
8179
9165
  cb = enc;
8180
9166
  enc = void 0;
8181
9167
  }
9168
+ if (withheld) {
9169
+ if (typeof cb === "function") cb();
9170
+ return res;
9171
+ }
8182
9172
  if (overflow) {
8183
9173
  if (chunk != null) origWrite(chunk, enc);
8184
9174
  return origEnd(cb);
8185
9175
  }
8186
9176
  collect(chunk, enc);
9177
+ if (withheld) {
9178
+ if (typeof cb === "function") cb();
9179
+ return res;
9180
+ }
8187
9181
  if (overflow) return origEnd(cb);
9182
+ hardenOnce();
9183
+ const passThrough = (changes) => {
9184
+ if (changes?.withhold) return sendWithheld(cb);
9185
+ sendHead(changes);
9186
+ for (const c of chunks) origWrite(c);
9187
+ return origEnd(cb);
9188
+ };
8188
9189
  const buffer = Buffer.concat(chunks);
8189
- let ct = res.getHeader ? res.getHeader("content-type") : void 0;
9190
+ const head = effectiveHead();
9191
+ let ct = head.headers["content-type"];
8190
9192
  if (Array.isArray(ct)) ct = ct[0];
8191
9193
  const kind = screenableContentType(ct);
8192
9194
  if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
8193
9195
  recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
8194
- for (const c of chunks) origWrite(c);
8195
- return origEnd(cb);
9196
+ return passThrough(screenUnreadHead());
9197
+ }
9198
+ const codings = declaredCodings(head.headers["content-encoding"]);
9199
+ if (codings.length > 0 && stillEncoded(buffer)) {
9200
+ recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
9201
+ return passThrough(screenUnreadHead());
9202
+ }
9203
+ const reading = charsetReading(ct, buffer);
9204
+ if (reading.skip) {
9205
+ recordSkip("response", "unsupported-charset", { charset: reading.skip });
9206
+ return passThrough(screenUnreadHead());
8196
9207
  }
8197
- const text = buffer.toString("utf8");
9208
+ const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
8198
9209
  let r;
8199
9210
  try {
8200
- r = screenText(
8201
- text,
8202
- { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
8203
- reqCtx,
8204
- answeredWithoutBody ? stillToAnswer : void 0
8205
- );
9211
+ r = screenText(text, head, reqCtx, notYetAnswered);
8206
9212
  } catch (err) {
8207
9213
  notify(onError, err, "onError");
8208
- for (const c of chunks) origWrite(c);
8209
- return origEnd(cb);
9214
+ return passThrough();
9215
+ }
9216
+ if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
9217
+ if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
9218
+ if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
9219
+ if (reallySent() && res.chunkedEncoding !== true) {
9220
+ notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
9221
+ res.destroy?.();
9222
+ return res;
8210
9223
  }
8211
9224
  if (r.verdict === "block") {
8212
- res.statusCode = 500;
8213
- try {
8214
- res.setHeader("content-type", "application/json");
8215
- } catch {
9225
+ if (reallySent()) {
9226
+ res.destroy?.();
9227
+ return res;
8216
9228
  }
8217
- return origEnd(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), cb);
9229
+ return sendWithheld(cb);
8218
9230
  }
8219
- if (r.verdict === "redact") {
8220
- try {
8221
- res.removeHeader && res.removeHeader("content-length");
8222
- } catch {
9231
+ const changed = /* @__PURE__ */ new Map();
9232
+ if (r.headers) {
9233
+ for (const [name, value] of Object.entries(r.headers)) {
9234
+ if (name.toLowerCase() === "content-length") continue;
9235
+ if (!headerValueChanged(head.headers[name], value)) continue;
9236
+ changed.set(name.toLowerCase(), value);
8223
9237
  }
8224
- if (r.headers && res.setHeader) {
8225
- const current = res.getHeaders ? res.getHeaders() : {};
8226
- for (const [name, value] of Object.entries(r.headers)) {
8227
- if (name.toLowerCase() === "content-length") continue;
8228
- if (value === null || value === void 0) {
8229
- try {
8230
- res.removeHeader && res.removeHeader(name);
8231
- } catch {
8232
- }
8233
- } else if (Array.isArray(value)) {
8234
- try {
8235
- res.setHeader(name, value);
8236
- } catch {
8237
- }
8238
- } else if (typeof value === "string" && current[name] !== value) {
8239
- try {
8240
- res.setHeader(name, value);
8241
- } catch {
8242
- }
8243
- }
8244
- }
9238
+ }
9239
+ if (!reallySent()) {
9240
+ const declared = head.headers["content-length"];
9241
+ if (declared === void 0) {
9242
+ changed.set("content-length", null);
9243
+ } else {
9244
+ changed.set("content-length", String(Buffer.byteLength(r.body)));
9245
+ changed.set("transfer-encoding", null);
8245
9246
  }
8246
- return origEnd(r.body, cb);
9247
+ sendHead({ headers: changed });
9248
+ } else if (changed.size) {
9249
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
8247
9250
  }
8248
- for (const c of chunks) origWrite(c);
8249
- return origEnd(cb);
9251
+ return origEnd(r.body, cb);
8250
9252
  };
8251
9253
  };
8252
9254
  const allow = new Set(allowHosts.map((h) => String(h).toLowerCase()));
@@ -8320,21 +9322,15 @@ async function createProtection(options = {}) {
8320
9322
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
8321
9323
  // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
8322
9324
  // upstream response. It resolves once here, which is the only resolution for this call.
8323
- screenResponse: (response, request) => screenResp(
8324
- response,
8325
- request ? reqContextFromFetch(
8326
- request,
8327
- resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
8328
- ) : void 0
8329
- ),
9325
+ screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
8330
9326
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
8331
9327
  fetchGuard() {
8332
- return async (request) => (await screenFetchRequest(request)).blocked;
9328
+ return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
8333
9329
  },
8334
9330
  // Wrap a fetch handler: screens the request, then the response (redact/block).
8335
9331
  fetch(handler) {
8336
9332
  return async (request, ...rest) => {
8337
- const { blocked, client } = await screenFetchRequest(request);
9333
+ const { blocked, client } = await screenFetchRequest(request, rest);
8338
9334
  if (blocked) return blocked;
8339
9335
  const response = await handler(request, ...rest);
8340
9336
  return screenResp(response, reqContextFromFetch(request, client));
@@ -8348,6 +9344,7 @@ async function createProtection(options = {}) {
8348
9344
  const { shaped, client } = shapeExpressRequest(req);
8349
9345
  try {
8350
9346
  result = engine.evaluate(shaped);
9347
+ recordEvaluationSkips(result);
8351
9348
  } catch (err) {
8352
9349
  notify(onError, err, "onError");
8353
9350
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
@@ -8382,33 +9379,25 @@ async function createProtection(options = {}) {
8382
9379
  screenNodeRequest(req, res, next, "", req.body);
8383
9380
  return;
8384
9381
  }
8385
- const chunks = [];
8386
- let size = 0;
8387
- let overflow = false;
8388
- req.on("data", (chunk) => {
8389
- size += chunk.length;
8390
- if (size > maxBytes) {
8391
- overflow = true;
9382
+ readBodyPrefix(req, maxBytes, (err, read) => {
9383
+ if (err) {
9384
+ notify(onError, err, "onError");
9385
+ next();
8392
9386
  return;
8393
9387
  }
8394
- chunks.push(chunk);
8395
- });
8396
- req.on("error", (err) => {
8397
- notify(onError, err, "onError");
8398
- next();
8399
- });
8400
- req.on("end", () => {
8401
- if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
8402
- screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
9388
+ if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
9389
+ if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
9390
+ screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
8403
9391
  });
8404
9392
  };
8405
- function screenNodeRequest(req, res, next, rawBody, parsedBody) {
9393
+ function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
8406
9394
  let shaped;
8407
9395
  let result;
8408
9396
  try {
8409
9397
  shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
8410
9398
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
8411
9399
  result = engine.evaluate(shaped);
9400
+ recordEvaluationSkips(result);
8412
9401
  } catch (err) {
8413
9402
  notify(onError, err, "onError");
8414
9403
  return next();
@@ -8427,7 +9416,7 @@ async function createProtection(options = {}) {
8427
9416
  }
8428
9417
  },
8429
9418
  () => {
8430
- if (req.body === void 0) req.body = shaped.body;
9419
+ if (req.body === void 0 && !truncated) req.body = shaped.body;
8431
9420
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
8432
9421
  next();
8433
9422
  },
@@ -8459,6 +9448,7 @@ async function createProtection(options = {}) {
8459
9448
  notify(onError, err, "onError");
8460
9449
  }
8461
9450
  }
9451
+ let recovery = null;
8462
9452
  const runRefreshTick = async () => {
8463
9453
  if (reporter) {
8464
9454
  try {
@@ -8475,23 +9465,38 @@ async function createProtection(options = {}) {
8475
9465
  });
8476
9466
  mode = resolveMode(options, next);
8477
9467
  applyBundle(next);
9468
+ noteRuleSource(next.source);
9469
+ if (next.source?.ok !== false) recovery?.stop();
8478
9470
  await applyAndAcknowledge(next.source?.origin, declaredState);
8479
9471
  if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
8480
9472
  return next.source ?? { ok: true };
8481
9473
  };
9474
+ const refreshTick = serialise(runRefreshTick);
8482
9475
  if (live) {
8483
- protection.refresh = () => runRefreshTick();
8484
- protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
9476
+ protection.refresh = () => refreshTick();
9477
+ protection.refreshHandler = () => makeRefreshHandler(refreshTick, refreshSecret);
8485
9478
  }
8486
- const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
9479
+ const loop = options.refreshMs > 0 && live ? startRefresh(refreshTick, { refreshMs: options.refreshMs, onError }) : null;
9480
+ const canAsk = Boolean(options.token || pulseAuth);
9481
+ recovery = live && canAsk && !loop && ruleSource.ok === false ? startRecovery(refreshTick, { onError }) : null;
8487
9482
  protection.stop = () => {
8488
9483
  loop?.stop();
9484
+ recovery?.stop();
9485
+ protection.uninstallEgress?.();
8489
9486
  const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
8490
9487
  (wait) => wait && typeof wait.then === "function"
8491
9488
  );
8492
9489
  return Promise.all(outstanding).then(() => void 0);
8493
9490
  };
8494
- protection.stopRefresh = protection.stop;
9491
+ protection.stopRefresh = () => {
9492
+ loop?.stop();
9493
+ recovery?.stop();
9494
+ return Promise.resolve();
9495
+ };
9496
+ Object.defineProperty(protection, "ruleSource", {
9497
+ get: () => ({ ...ruleSource }),
9498
+ enumerable: true
9499
+ });
8495
9500
  Object.defineProperty(protection, "detectionReporting", {
8496
9501
  get: () => detectionReporting,
8497
9502
  enumerable: true
@@ -8500,8 +9505,22 @@ async function createProtection(options = {}) {
8500
9505
  get: () => detections ? () => detections.health() : void 0,
8501
9506
  enumerable: true
8502
9507
  });
9508
+ Object.defineProperty(protection, "blockLogHealth", {
9509
+ get: () => firewallLog ? () => firewallLog.health() : void 0,
9510
+ enumerable: true
9511
+ });
8503
9512
  return protection;
8504
9513
  }
9514
+ function accessorOf(obj, name) {
9515
+ for (let o = obj; o; o = Object.getPrototypeOf(o)) {
9516
+ const d = Object.getOwnPropertyDescriptor(o, name);
9517
+ if (d) return typeof d.get === "function" ? d.get : null;
9518
+ }
9519
+ return null;
9520
+ }
9521
+ function headersSentError(verb) {
9522
+ return Object.assign(new Error(`Cannot ${verb} headers after they are sent to the client`), { code: "ERR_HTTP_HEADERS_SENT" });
9523
+ }
8505
9524
  function resolveMode(options, bundle) {
8506
9525
  const env = typeof process !== "undefined" ? process.env?.PATCHSTACK_MODE : void 0;
8507
9526
  if (env === "block" || env === "dry-run") return env;
@@ -8564,6 +9583,14 @@ function screenableContentType(ct) {
8564
9583
  if (base === "application/octet-stream") return "sniff";
8565
9584
  return "skip";
8566
9585
  }
9586
+ function prefilterText(body, headers) {
9587
+ const values = [];
9588
+ for (const value of Object.values(headers ?? {})) {
9589
+ if (Array.isArray(value)) values.push(...value.map(String));
9590
+ else if (value !== void 0 && value !== null) values.push(String(value));
9591
+ }
9592
+ return [body, ...values].join("\n").toLowerCase();
9593
+ }
8567
9594
  function looksBinary(bytes) {
8568
9595
  const n = Math.min(bytes.length, 512);
8569
9596
  let ctrl = 0;
@@ -8574,11 +9601,72 @@ function looksBinary(bytes) {
8574
9601
  }
8575
9602
  return n > 0 && ctrl / n > 0.1;
8576
9603
  }
9604
+ function declaredCodings(value) {
9605
+ const raw = Array.isArray(value) ? value.join(",") : String(value ?? "");
9606
+ return raw.toLowerCase().split(",").map((c) => c.trim()).filter((c) => c !== "" && c !== "identity");
9607
+ }
9608
+ function stillEncoded(bytes) {
9609
+ if (looksBinary(bytes)) return true;
9610
+ try {
9611
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
9612
+ return false;
9613
+ } catch {
9614
+ return true;
9615
+ }
9616
+ }
9617
+ var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
9618
+ var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
9619
+ function declaredCharset(ct) {
9620
+ const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
9621
+ const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
9622
+ return label || null;
9623
+ }
9624
+ function charsetReading(ct, bytes) {
9625
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
9626
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
9627
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
9628
+ const charset = declaredCharset(ct);
9629
+ if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
9630
+ if (WIDE_CHARSET.test(charset)) return { skip: charset };
9631
+ for (let i = 0; i < bytes.length; i++) {
9632
+ const b = bytes[i];
9633
+ if (b >= 128 || b === 0 || b === 27) return { skip: charset };
9634
+ }
9635
+ return { ascii: true };
9636
+ }
9637
+ function decodeForScreening(ct, bytes) {
9638
+ const reading = charsetReading(ct, bytes);
9639
+ if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
9640
+ return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
9641
+ }
9642
+ function withBom(bom, body) {
9643
+ return bom ? "\uFEFF" + body : body;
9644
+ }
9645
+ function outgrowsCharset(ascii, body) {
9646
+ return ascii === true && /[^\x00-\x7f]/.test(body);
9647
+ }
9648
+ var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
9649
+ function readJsonEscapes(text) {
9650
+ if (!text.includes("\\u") && !text.includes("\\/")) return text;
9651
+ try {
9652
+ JSON.parse(text);
9653
+ } catch {
9654
+ return text;
9655
+ }
9656
+ return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
9657
+ if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
9658
+ const code = parseInt(hex, 16);
9659
+ if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
9660
+ return String.fromCharCode(code);
9661
+ });
9662
+ }
8577
9663
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
8578
9664
  if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
8579
9665
  const ct = response.headers?.get?.("content-type") || "";
8580
9666
  const kind = screenableContentType(ct);
8581
9667
  if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
9668
+ const codings = declaredCodings(response.headers?.get?.("content-encoding"));
9669
+ const encodedSkip = () => ({ skip: "encoded-body", encoding: codings.join(", ") });
8582
9670
  const sniff = kind === "sniff";
8583
9671
  const len = Number(response.headers?.get?.("content-length") || 0);
8584
9672
  if (len && len > cap) return { skip: "body-cap" };
@@ -8622,13 +9710,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
8622
9710
  } finally {
8623
9711
  reader.releaseLock();
8624
9712
  }
9713
+ const bytes = concatBytes(chunks, size);
9714
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
8625
9715
  try {
8626
- return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
9716
+ return decodeForScreening(ct, bytes);
8627
9717
  } catch {
8628
9718
  return { skip: "decode-failed" };
8629
9719
  }
8630
9720
  }
8631
9721
  try {
9722
+ if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
9723
+ const bytes = new Uint8Array(await clone.arrayBuffer());
9724
+ if (bytes.byteLength > cap) return { skip: "body-cap" };
9725
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
9726
+ if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
9727
+ return decodeForScreening(ct, bytes);
9728
+ }
8632
9729
  const text = await clone.text();
8633
9730
  if (text.length > cap) return { skip: "body-cap" };
8634
9731
  if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
@@ -8658,8 +9755,9 @@ function headerObject(headers) {
8658
9755
  function hasSpanMutations(rule) {
8659
9756
  let found = false;
8660
9757
  const walk2 = (conds) => {
8661
- for (const c of conds ?? []) {
9758
+ for (const c of Array.isArray(conds) ? conds : []) {
8662
9759
  if (found) return;
9760
+ if (!c || typeof c !== "object") continue;
8663
9761
  if (Array.isArray(c.rules)) walk2(c.rules);
8664
9762
  const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
8665
9763
  if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
@@ -8668,10 +9766,34 @@ function hasSpanMutations(rule) {
8668
9766
  walk2(rule.rule_v2);
8669
9767
  return found;
8670
9768
  }
8671
- function extractRedactors(rule) {
9769
+ function redactorScope(parameter) {
9770
+ if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
9771
+ const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
9772
+ for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
9773
+ if (typeof name !== "string" || name === "") return null;
9774
+ if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
9775
+ else if (name === "response.headers") scope.allHeaders = true;
9776
+ else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
9777
+ scope.body = true;
9778
+ scope.allHeaders = true;
9779
+ } else return null;
9780
+ }
9781
+ return scope;
9782
+ }
9783
+ var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
9784
+ function masksHeader(redactor, name) {
9785
+ return redactor.scope.allHeaders || redactor.scope.headers.has(name);
9786
+ }
9787
+ function extractRedactors(rule, onUnscoped) {
8672
9788
  const out = [];
9789
+ const scoped = (c) => {
9790
+ const scope = redactorScope(c.parameter);
9791
+ if (scope === null) onUnscoped?.(c.parameter);
9792
+ return scope;
9793
+ };
8673
9794
  const walk2 = (conds) => {
8674
- for (const c of conds ?? []) {
9795
+ for (const c of Array.isArray(conds) ? conds : []) {
9796
+ if (!c || typeof c !== "object") continue;
8675
9797
  if (Array.isArray(c.rules)) walk2(c.rules);
8676
9798
  const m = c.match;
8677
9799
  if (!m) continue;
@@ -8679,16 +9801,21 @@ function extractRedactors(rule) {
8679
9801
  const safe = safeRegExp(m.value);
8680
9802
  if (safe) {
8681
9803
  const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
9804
+ const scope = scoped(c);
8682
9805
  try {
8683
- out.push({ re: new RegExp(safe.source, flags) });
9806
+ if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
8684
9807
  } catch {
8685
9808
  }
8686
9809
  }
8687
9810
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
8688
- out.push({ literal: String(m.value) });
9811
+ const scope = scoped(c);
9812
+ if (scope) out.push({ literal: String(m.value), scope });
8689
9813
  } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
8690
- out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
8691
- } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
9814
+ const scope = scoped(c);
9815
+ if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
9816
+ } else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
9817
+ onUnscoped?.(c.parameter);
9818
+ } else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
8692
9819
  const keys = Array.isArray(m.key) ? m.key : [m.key];
8693
9820
  for (const key of keys) {
8694
9821
  out.push({ jsonPath: String(key).split("."), condition: m.match });
@@ -8702,6 +9829,110 @@ function extractRedactors(rule) {
8702
9829
  function htmlEscape(str) {
8703
9830
  return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
8704
9831
  }
9832
+ function isJson(text) {
9833
+ try {
9834
+ JSON.parse(text);
9835
+ return true;
9836
+ } catch {
9837
+ return false;
9838
+ }
9839
+ }
9840
+ var JSON_STRING = 1;
9841
+ var JSON_NUMBER = 2;
9842
+ var JSON_LITERAL = 3;
9843
+ var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
9844
+ function isHexCode(c) {
9845
+ return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
9846
+ }
9847
+ var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
9848
+ function lexJson(text) {
9849
+ let kinds = new Int32Array(1024);
9850
+ let spans = new Int32Array(2048);
9851
+ let count = 0;
9852
+ const n = text.length;
9853
+ let i = 0;
9854
+ while (i < n) {
9855
+ const c = text.charCodeAt(i);
9856
+ if (c === 32 || c === 9 || c === 10 || c === 13) {
9857
+ i++;
9858
+ continue;
9859
+ }
9860
+ const start = i;
9861
+ let kind;
9862
+ if (c === 34) {
9863
+ i++;
9864
+ const close = text.indexOf('"', i);
9865
+ if (close === -1) return null;
9866
+ if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
9867
+ i = close;
9868
+ } else {
9869
+ for (; ; ) {
9870
+ if (i >= n) return null;
9871
+ const d = text.charCodeAt(i);
9872
+ if (d === 34) break;
9873
+ if (d < 32) return null;
9874
+ if (d !== 92) {
9875
+ i++;
9876
+ continue;
9877
+ }
9878
+ const e = text.charCodeAt(i + 1);
9879
+ if (e === 117) {
9880
+ for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
9881
+ i += 6;
9882
+ } else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
9883
+ i += 2;
9884
+ } else {
9885
+ return null;
9886
+ }
9887
+ }
9888
+ }
9889
+ i++;
9890
+ kind = JSON_STRING;
9891
+ } else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
9892
+ i++;
9893
+ kind = c;
9894
+ } else if (text.startsWith("true", i) || text.startsWith("null", i)) {
9895
+ i += 4;
9896
+ kind = JSON_LITERAL;
9897
+ } else if (text.startsWith("false", i)) {
9898
+ i += 5;
9899
+ kind = JSON_LITERAL;
9900
+ } else {
9901
+ JSON_NUMBER_TOKEN.lastIndex = i;
9902
+ if (!JSON_NUMBER_TOKEN.test(text)) return null;
9903
+ i = JSON_NUMBER_TOKEN.lastIndex;
9904
+ kind = JSON_NUMBER;
9905
+ }
9906
+ if (count === kinds.length) {
9907
+ const grownKinds = new Int32Array(count * 2);
9908
+ grownKinds.set(kinds);
9909
+ kinds = grownKinds;
9910
+ const grownSpans = new Int32Array(count * 4);
9911
+ grownSpans.set(spans);
9912
+ spans = grownSpans;
9913
+ }
9914
+ kinds[count] = kind;
9915
+ spans[2 * count] = start;
9916
+ spans[2 * count + 1] = i;
9917
+ count++;
9918
+ }
9919
+ return { kinds, spans, count };
9920
+ }
9921
+ function sameJsonStructure(beforeText, before, afterText, after) {
9922
+ if (!before || !after || before.count !== after.count) return false;
9923
+ const { kinds, spans, count } = before;
9924
+ for (let k = 0; k < count; k++) {
9925
+ const kind = kinds[k];
9926
+ if (after.kinds[k] !== kind) return false;
9927
+ if (kind > JSON_LITERAL) continue;
9928
+ if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
9929
+ const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
9930
+ const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
9931
+ if (was === now) continue;
9932
+ if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
9933
+ }
9934
+ return true;
9935
+ }
8705
9936
  function applyRedactors(body, redactors, mask, transform) {
8706
9937
  let out = body;
8707
9938
  for (const r of redactors) {
@@ -8717,6 +9948,11 @@ function applyRedactors(body, redactors, mask, transform) {
8717
9948
  }
8718
9949
  return out;
8719
9950
  }
9951
+ function readsBody(parameter) {
9952
+ if (!Array.isArray(parameter)) return isBodyParam(parameter);
9953
+ const names = parameter.every((member) => typeof member === "string" && member !== "");
9954
+ return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
9955
+ }
8720
9956
  function isBodyParam(parameter) {
8721
9957
  return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
8722
9958
  }
@@ -8744,10 +9980,12 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
8744
9980
  if (!pathRedactors.length || typeof text !== "string" || text.length > cap) return text;
8745
9981
  const head = text.trimStart()[0];
8746
9982
  if (head !== "{" && head !== "[") return text;
8747
- const preserved = preserveBigInts(text);
9983
+ let preserved;
8748
9984
  let obj;
8749
9985
  try {
8750
- obj = JSON.parse(preserved);
9986
+ JSON.parse(text);
9987
+ preserved = preserveNumbers(text, mask);
9988
+ obj = JSON.parse(preserved.text);
8751
9989
  } catch {
8752
9990
  return text;
8753
9991
  }
@@ -8756,65 +9994,44 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
8756
9994
  const pred = conditionPredicate(r.condition);
8757
9995
  walkLeaves(obj, r.jsonPath, (loc) => {
8758
9996
  try {
8759
- if (pred(loc.value)) {
8760
- loc.parent[loc.key] = transform ? transform(String(loc.value)) : mask;
9997
+ const number = preserved.numbers.get(loc.value);
9998
+ if (pred(number === void 0 ? loc.value : Number(number))) {
9999
+ setOwn(loc.parent, loc.key, transform ? transform(number ?? String(loc.value)) : mask);
8761
10000
  changed = true;
8762
10001
  }
8763
10002
  } catch {
8764
10003
  }
8765
10004
  });
8766
10005
  }
8767
- return changed ? restoreBigInts(JSON.stringify(obj)) : text;
8768
- }
8769
- var BIGINT_OPEN = "__PSBIGINT_9c2f__";
8770
- var BIGINT_CLOSE = "__DNEGIB__";
8771
- function preserveBigInts(text) {
8772
- let out = "";
8773
- let inStr = false;
8774
- for (let i = 0; i < text.length; ) {
8775
- const ch = text[i];
8776
- if (inStr) {
8777
- out += ch;
8778
- if (ch === "\\") {
8779
- out += text[i + 1] ?? "";
8780
- i += 2;
8781
- continue;
8782
- }
8783
- if (ch === '"') inStr = false;
8784
- i++;
8785
- continue;
8786
- }
8787
- if (ch === '"') {
8788
- inStr = true;
8789
- out += ch;
8790
- i++;
8791
- continue;
8792
- }
8793
- if (ch === "-" || ch >= "0" && ch <= "9") {
8794
- let j = ch === "-" ? i + 1 : i;
8795
- let digits = 0;
8796
- while (j < text.length && text[j] >= "0" && text[j] <= "9") {
8797
- digits++;
8798
- j++;
8799
- }
8800
- const next = text[j];
8801
- const isIntToken = digits > 0 && next !== "." && next !== "e" && next !== "E";
8802
- if (isIntToken && digits >= 16) {
8803
- out += `"${BIGINT_OPEN}${text.slice(i, j)}${BIGINT_CLOSE}"`;
8804
- } else {
8805
- out += text.slice(i, j || i + 1);
8806
- }
8807
- i = j > i ? j : i + 1;
8808
- continue;
8809
- }
8810
- out += ch;
8811
- i++;
8812
- }
8813
- return out;
10006
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
8814
10007
  }
8815
- function restoreBigInts(text) {
8816
- if (!text.includes(BIGINT_OPEN)) return text;
8817
- return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
10008
+ function preserveNumbers(text, mask) {
10009
+ const occupied = /* @__PURE__ */ new Set([mask]);
10010
+ const found = [];
10011
+ const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
10012
+ for (const match of text.matchAll(tokens)) {
10013
+ const token = match[0];
10014
+ if (token[0] === '"') occupied.add(JSON.parse(token));
10015
+ else found.push({ start: match.index, token });
10016
+ }
10017
+ const numbers = /* @__PURE__ */ new Map();
10018
+ const chunks = [];
10019
+ let offset = 0;
10020
+ let index = 0;
10021
+ for (const { start, token } of found) {
10022
+ let marker;
10023
+ do {
10024
+ marker = "__PSNUMBER_" + index++ + "__";
10025
+ } while (occupied.has(marker));
10026
+ numbers.set(marker, token);
10027
+ chunks.push(text.slice(offset, start), JSON.stringify(marker));
10028
+ offset = start + token.length;
10029
+ }
10030
+ chunks.push(text.slice(offset));
10031
+ return { text: chunks.join(""), numbers };
10032
+ }
10033
+ function restoreNumbers(text, numbers) {
10034
+ return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
8818
10035
  }
8819
10036
  function isHeaderMutation(action) {
8820
10037
  return action === "set-header" || action === "remove-header" || action === "harden-cookie";