@patchstack/connect 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protect.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  canonicalBuildId,
3
+ isSafeOrigin,
3
4
  pulseAuthHeader,
4
5
  pulseFetch,
5
- readBuildStamp
6
- } from "./chunk-IOODP4ZB.js";
6
+ readBoundedJson,
7
+ readBuildStamp,
8
+ safeBaseUrl
9
+ } from "./chunk-4OHAYSV2.js";
7
10
 
8
11
  // src/protect/client-ip.js
9
12
  var ZONE = /^[A-Za-z0-9._-]{1,64}$/;
@@ -219,27 +222,49 @@ function clientIpFields(resolved) {
219
222
  return established && address !== null ? { client_ip: address, client_ip_source: resolved.source } : { client_ip_source: "unavailable" };
220
223
  }
221
224
 
222
- // src/protect/safe-origin.js
223
- function isSafeOrigin(value) {
225
+ // src/protect/engine/response-json.js
226
+ var MAX_RULE_RESPONSE_BYTES = 5 * 1024 * 1024;
227
+ async function readBoundedJson2(response, maxBytes = MAX_RULE_RESPONSE_BYTES) {
228
+ const stated = Number(response.headers?.get?.("content-length"));
229
+ if (Number.isFinite(stated) && stated > maxBytes) {
230
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
231
+ }
232
+ const stream = response.body;
233
+ if (!stream || typeof stream.getReader !== "function") {
234
+ if (typeof response.text === "function") {
235
+ const text = await response.text();
236
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
237
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
238
+ }
239
+ return JSON.parse(text);
240
+ }
241
+ throw new Error("rule response body cannot be read with a size bound");
242
+ }
243
+ const reader = stream.getReader();
244
+ const chunks = [];
245
+ let total = 0;
224
246
  try {
225
- const u = new URL(value);
226
- if (u.protocol === "https:") return true;
227
- return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
228
- } catch {
229
- return false;
247
+ for (; ; ) {
248
+ const { done, value } = await reader.read();
249
+ if (done) break;
250
+ total += value.byteLength;
251
+ if (total > maxBytes) {
252
+ void reader.cancel().catch(() => {
253
+ });
254
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
255
+ }
256
+ chunks.push(value);
257
+ }
258
+ } finally {
259
+ reader.releaseLock();
230
260
  }
231
- }
232
- function safeBaseUrl(candidate, fallback, label) {
233
- if (typeof candidate !== "string" || candidate === "") return fallback;
234
- if (isSafeOrigin(candidate)) return candidate;
235
- warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
236
- return fallback;
237
- }
238
- var warned = /* @__PURE__ */ new Set();
239
- function warnOnce(key, message) {
240
- if (warned.has(key)) return;
241
- warned.add(key);
242
- console.warn(message);
261
+ const bytes = new Uint8Array(total);
262
+ let offset = 0;
263
+ for (const chunk of chunks) {
264
+ bytes.set(chunk, offset);
265
+ offset += chunk.byteLength;
266
+ }
267
+ return JSON.parse(new TextDecoder().decode(bytes));
243
268
  }
244
269
 
245
270
  // src/protect/engine/client.js
@@ -298,7 +323,7 @@ var PatchstackRuleClient = class {
298
323
  whitelist_keys: {}
299
324
  };
300
325
  }
301
- const data = await response.json();
326
+ const data = await readBoundedJson2(response);
302
327
  if (!data || !Array.isArray(data.firewall)) {
303
328
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
304
329
  }
@@ -335,6 +360,22 @@ var PatchstackRuleClient = class {
335
360
  }
336
361
  };
337
362
 
363
+ // src/protect/engine/own.js
364
+ function setOwn(target, key, value) {
365
+ Object.defineProperty(target, key, {
366
+ configurable: true,
367
+ enumerable: true,
368
+ writable: true,
369
+ value
370
+ });
371
+ return value;
372
+ }
373
+ function appendOwn(target, key, value) {
374
+ if (!Object.hasOwn(target, key)) return setOwn(target, key, value);
375
+ const previous = target[key];
376
+ return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
377
+ }
378
+
338
379
  // src/protect/engine/request.js
339
380
  var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
340
381
  var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
@@ -453,8 +494,8 @@ var RequestResolver = class {
453
494
  }
454
495
  if (key.startsWith("header.")) {
455
496
  const name = key.slice("header.".length).toLowerCase();
456
- const value = (resp.headers ?? {})[name];
457
- return value !== void 0 ? [value] : [];
497
+ const headers = resp.headers ?? {};
498
+ return Object.hasOwn(headers, name) ? [headers[name]] : [];
458
499
  }
459
500
  return [];
460
501
  }
@@ -549,7 +590,7 @@ var RequestResolver = class {
549
590
  ...this.#resolveWildcard(cookies, key)
550
591
  ];
551
592
  }
552
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? cookies[key];
593
+ const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
553
594
  return value !== void 0 ? [value] : [];
554
595
  }
555
596
  #resolveCookie(key) {
@@ -557,8 +598,7 @@ var RequestResolver = class {
557
598
  if (key.endsWith("*")) {
558
599
  return this.#resolveWildcard(cookies, key);
559
600
  }
560
- const value = cookies[key];
561
- return value !== void 0 ? [value] : [];
601
+ return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
562
602
  }
563
603
  #resolveServer(key) {
564
604
  const req = this.#req;
@@ -734,7 +774,7 @@ var RequestResolver = class {
734
774
  }
735
775
  const name = pair.substring(0, eqIndex).trim();
736
776
  const value = pair.substring(eqIndex + 1).trim();
737
- cookies[name] = value;
777
+ setOwn(cookies, name, value);
738
778
  }
739
779
  this.#cookies = cookies;
740
780
  return this.#cookies;
@@ -749,17 +789,17 @@ function notify(fn, arg, label) {
749
789
  const result = fn(arg);
750
790
  if (result !== null && typeof result === "object" && typeof result.then === "function") {
751
791
  try {
752
- result.then(void 0, (err) => warnOnce2(label, err));
792
+ result.then(void 0, (err) => warnOnce(label, err));
753
793
  } catch {
754
794
  }
755
795
  }
756
796
  return true;
757
797
  } catch (err) {
758
- warnOnce2(label, err);
798
+ warnOnce(label, err);
759
799
  return false;
760
800
  }
761
801
  }
762
- function warnOnce2(label, err) {
802
+ function warnOnce(label, err) {
763
803
  if (reported.has(label)) return;
764
804
  reported.add(label);
765
805
  try {
@@ -891,30 +931,113 @@ function normalizeWhitespace(value) {
891
931
  result = result.replace(/ {2,}/g, " ");
892
932
  return result;
893
933
  }
894
- function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isRoot = true) {
895
- if (body === null || body === void 0) {
896
- return isRoot ? "" : "null";
897
- }
898
- if (typeof body === "string") {
899
- return isRoot ? body : JSON.stringify(body);
900
- }
901
- if (typeof body !== "object") {
902
- return String(body);
903
- }
904
- if (visited.has(body)) {
905
- return "[Circular]";
934
+ var MAX_RAW_CHARS = 1024 * 1024;
935
+ var MAX_RAW_NODES = 25e3;
936
+ var MAX_RAW_DEPTH = 200;
937
+ var MAX_RAW_ENTRIES = 2e3;
938
+ function serializeForRawDetection(body) {
939
+ if (body === null || body === void 0) return "";
940
+ if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
941
+ const visited = /* @__PURE__ */ new Set();
942
+ const output = [];
943
+ const stack = [{ kind: "value", value: body, depth: 0, root: true }];
944
+ let chars = 0;
945
+ let nodes = 0;
946
+ const append = (text) => {
947
+ if (chars >= MAX_RAW_CHARS) return;
948
+ const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
949
+ output.push(piece);
950
+ chars += piece.length;
951
+ };
952
+ while (stack.length > 0 && chars < MAX_RAW_CHARS) {
953
+ const task = stack.pop();
954
+ if (task.kind === "text") {
955
+ append(task.text);
956
+ continue;
957
+ }
958
+ if (task.kind === "key") {
959
+ append(boundedJsonString(task.value));
960
+ append(":");
961
+ continue;
962
+ }
963
+ nodes++;
964
+ if (nodes > MAX_RAW_NODES) {
965
+ append('"[NodeLimit]"');
966
+ continue;
967
+ }
968
+ const value = task.value;
969
+ if (value === null || value === void 0) {
970
+ append(task.root ? "" : "null");
971
+ continue;
972
+ }
973
+ if (typeof value === "string") {
974
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
975
+ continue;
976
+ }
977
+ if (typeof value !== "object") {
978
+ append(String(value).slice(0, MAX_RAW_CHARS));
979
+ continue;
980
+ }
981
+ if (visited.has(value)) {
982
+ append("[Circular]");
983
+ continue;
984
+ }
985
+ if (task.depth >= MAX_RAW_DEPTH) {
986
+ append('"[DepthLimit]"');
987
+ continue;
988
+ }
989
+ visited.add(value);
990
+ if (Array.isArray(value)) {
991
+ const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
992
+ const items = [];
993
+ for (let i = 0; i < length2; i++) {
994
+ if (i > 0) items.push({ kind: "text", text: "," });
995
+ items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
996
+ }
997
+ if (value.length > length2) {
998
+ if (length2 > 0) items.push({ kind: "text", text: "," });
999
+ items.push({ kind: "text", text: '"[EntryLimit]"' });
1000
+ }
1001
+ stack.push({ kind: "text", text: "]" });
1002
+ for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
1003
+ stack.push({ kind: "text", text: "[" });
1004
+ continue;
1005
+ }
1006
+ let keys;
1007
+ try {
1008
+ keys = Object.getOwnPropertyNames(value);
1009
+ } catch {
1010
+ append('"[Unreadable]"');
1011
+ continue;
1012
+ }
1013
+ const length = Math.min(keys.length, MAX_RAW_ENTRIES);
1014
+ const entries = [];
1015
+ for (let i = 0; i < length; i++) {
1016
+ const key = keys[i];
1017
+ if (i > 0) entries.push({ kind: "text", text: "," });
1018
+ entries.push({ kind: "key", value: key });
1019
+ entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
1020
+ }
1021
+ if (keys.length > length) {
1022
+ if (length > 0) entries.push({ kind: "text", text: "," });
1023
+ entries.push({ kind: "text", text: '"[EntryLimit]":null' });
1024
+ }
1025
+ stack.push({ kind: "text", text: "}" });
1026
+ for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
1027
+ stack.push({ kind: "text", text: "{" });
906
1028
  }
907
- visited.add(body);
908
- if (Array.isArray(body)) {
909
- const items = body.map((item) => serializeForRawDetection(item, visited, false));
910
- return "[" + items.join(",") + "]";
1029
+ return output.join("");
1030
+ }
1031
+ function boundedJsonString(value) {
1032
+ return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
1033
+ }
1034
+ function readOwnValue(value, key) {
1035
+ try {
1036
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
1037
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
1038
+ } catch {
1039
+ return "[Unreadable]";
911
1040
  }
912
- const keys = Object.getOwnPropertyNames(body);
913
- const parts = keys.map((key) => {
914
- const val = serializeForRawDetection(body[key], visited, false);
915
- return JSON.stringify(key) + ":" + val;
916
- });
917
- return "{" + parts.join(",") + "}";
918
1041
  }
919
1042
  var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
920
1043
  function requestField(req, key) {
@@ -956,7 +1079,7 @@ function normalizeObject(value, options = {}, depth = 0) {
956
1079
  if (typeof value === "object" && value !== null) {
957
1080
  const result = {};
958
1081
  for (const [key, val] of Object.entries(value)) {
959
- result[key] = normalizeObject(val, options, depth + 1);
1082
+ setOwn(result, key, normalizeObject(val, options, depth + 1));
960
1083
  }
961
1084
  return result;
962
1085
  }
@@ -973,6 +1096,111 @@ var REDOS_PATTERNS = [
973
1096
  new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
974
1097
  // nested alternation under a quantifier
975
1098
  ];
1099
+ function hasAdjacentUnbounded(body, flags) {
1100
+ let previousUnboundedAtom = null;
1101
+ for (let i = 0; i < body.length; ) {
1102
+ const start = i;
1103
+ const ch = body[i];
1104
+ let assertion = false;
1105
+ let literal = null;
1106
+ let atom = ch;
1107
+ if (ch === "\\") {
1108
+ const escaped = body[i + 1] ?? "";
1109
+ assertion = /[bBAZz]/.test(escaped);
1110
+ if (!/[bBAZzdswDSWnrtvf0-9pPkKxuc]/.test(escaped)) literal = escaped;
1111
+ i += Math.min(2, body.length - i);
1112
+ atom = body.slice(start, i);
1113
+ } else if (ch === "[") {
1114
+ i++;
1115
+ while (i < body.length) {
1116
+ if (body[i] === "\\") i += 2;
1117
+ else if (body[i++] === "]") break;
1118
+ }
1119
+ atom = body.slice(start, i);
1120
+ } else if (ch === ")") {
1121
+ i++;
1122
+ atom = ".";
1123
+ } else if (ch === "(") {
1124
+ const lookaround = body.startsWith("(?=", i) || body.startsWith("(?!", i) || body.startsWith("(?<=", i) || body.startsWith("(?<!", i);
1125
+ if (lookaround) {
1126
+ i = groupEnd(body, i);
1127
+ continue;
1128
+ }
1129
+ i++;
1130
+ if (body.startsWith("?:", i)) i += 2;
1131
+ else if (body.startsWith("?<", i)) {
1132
+ const nameEnd = body.indexOf(">", i + 2);
1133
+ if (nameEnd !== -1) i = nameEnd + 1;
1134
+ }
1135
+ continue;
1136
+ } else if (ch === "|") {
1137
+ previousUnboundedAtom = null;
1138
+ i++;
1139
+ continue;
1140
+ } else if ("^$".includes(ch)) {
1141
+ i++;
1142
+ continue;
1143
+ } else if ("?*+{}".includes(ch)) {
1144
+ previousUnboundedAtom = null;
1145
+ i++;
1146
+ continue;
1147
+ } else {
1148
+ if (ch !== ".") literal = ch;
1149
+ i++;
1150
+ }
1151
+ if (assertion) continue;
1152
+ let unbounded = false;
1153
+ let mayBeEmpty = false;
1154
+ if (body[i] === "*" || body[i] === "+") {
1155
+ unbounded = true;
1156
+ mayBeEmpty = body[i] === "*";
1157
+ i++;
1158
+ } else if (body[i] === "{") {
1159
+ const quantifier = /^\{(\d+)(?:,(\d*))?\}/.exec(body.slice(i));
1160
+ if (quantifier) {
1161
+ unbounded = quantifier[2] === "";
1162
+ mayBeEmpty = Number(quantifier[1]) === 0;
1163
+ i += quantifier[0].length;
1164
+ }
1165
+ } else if (body[i] === "?") {
1166
+ mayBeEmpty = true;
1167
+ i++;
1168
+ }
1169
+ if (unbounded && (body[i] === "?" || body[i] === "+")) i++;
1170
+ if (unbounded && previousUnboundedAtom !== null) return true;
1171
+ if (unbounded) {
1172
+ previousUnboundedAtom = atom;
1173
+ } else if (previousUnboundedAtom !== null && !mayBeEmpty) {
1174
+ try {
1175
+ const atomFlags = flags.replace(/[^iu]/g, "");
1176
+ const complementaryClass = previousUnboundedAtom === "\\s" && atom === "\\S" || previousUnboundedAtom === "\\S" && atom === "\\s" || previousUnboundedAtom === "\\d" && atom === "\\D" || previousUnboundedAtom === "\\D" && atom === "\\d" || previousUnboundedAtom === "\\w" && atom === "\\W" || previousUnboundedAtom === "\\W" && atom === "\\w";
1177
+ const excludedLiteral = literal !== null && !new RegExp(`^(?:${previousUnboundedAtom})$`, atomFlags).test(literal);
1178
+ if (complementaryClass || excludedLiteral) {
1179
+ previousUnboundedAtom = null;
1180
+ }
1181
+ } catch {
1182
+ }
1183
+ }
1184
+ if (i === start) i++;
1185
+ }
1186
+ return false;
1187
+ }
1188
+ function groupEnd(body, start) {
1189
+ let depth = 0;
1190
+ let inClass = false;
1191
+ for (let i = start; i < body.length; i++) {
1192
+ const ch = body[i];
1193
+ if (ch === "\\") {
1194
+ i++;
1195
+ continue;
1196
+ }
1197
+ if (ch === "[") inClass = true;
1198
+ else if (ch === "]") inClass = false;
1199
+ else if (!inClass && ch === "(") depth++;
1200
+ else if (!inClass && ch === ")" && --depth === 0) return i + 1;
1201
+ }
1202
+ return body.length;
1203
+ }
976
1204
  var warnedRejectedPatterns = /* @__PURE__ */ new Set();
977
1205
  function warnRejectedPatternOnce(pattern) {
978
1206
  const key = String(pattern);
@@ -990,15 +1218,14 @@ function safeRegExp(pattern) {
990
1218
  if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
991
1219
  return null;
992
1220
  }
993
- for (const dangerous of REDOS_PATTERNS) {
994
- if (dangerous.test(pattern)) {
995
- return null;
996
- }
997
- }
998
1221
  const match = pattern.match(/^\/(.+?)\/([gimsuy]*)$/s);
999
1222
  if (!match) {
1000
1223
  return null;
1001
1224
  }
1225
+ for (const dangerous of REDOS_PATTERNS) {
1226
+ if (dangerous.test(match[1])) return null;
1227
+ }
1228
+ if (hasAdjacentUnbounded(match[1], match[2])) return null;
1002
1229
  try {
1003
1230
  return new RegExp(match[1], match[2]);
1004
1231
  } catch {
@@ -1069,7 +1296,7 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
1069
1296
  return out;
1070
1297
  }
1071
1298
  var warnedKeys = /* @__PURE__ */ new Set();
1072
- function warnOnce3(key, message) {
1299
+ function warnOnce2(key, message) {
1073
1300
  if (warnedKeys.has(key)) return;
1074
1301
  warnedKeys.add(key);
1075
1302
  console.warn(message);
@@ -1451,13 +1678,13 @@ var RuleEngine = class {
1451
1678
  this.#whitelistKeys = whitelist_keys;
1452
1679
  this.#onError = onError;
1453
1680
  if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
1454
- warnOnce3(
1681
+ warnOnce2(
1455
1682
  "whitelist-no-rule-id",
1456
1683
  "[patchstack] a whitelist has no `rule_id` \u2014 it suppresses ALL rules when it matches. Scope each whitelist to a specific rule_id, and key it only on values an attacker cannot set."
1457
1684
  );
1458
1685
  }
1459
1686
  if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
1460
- warnOnce3("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
1687
+ warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
1461
1688
  }
1462
1689
  }
1463
1690
  // A mitigation engine must never take down the app it protects: any error while
@@ -1742,6 +1969,16 @@ function operandShapeProblem(shape, value) {
1742
1969
  const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
1743
1970
  const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
1744
1971
  const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
1972
+ const stringLimit = (v) => typeof v === "string" && v.length > LIMITS.maxValueLength ? `must not be longer than ${LIMITS.maxValueLength} chars` : null;
1973
+ const listLimit = (v) => Array.isArray(v) && v.length > LIMITS.maxOperandItems ? `must not have more than ${LIMITS.maxOperandItems} entries` : null;
1974
+ const tooLong = stringLimit(value);
1975
+ if (tooLong) return tooLong;
1976
+ const tooMany = listLimit(value);
1977
+ if (tooMany) return tooMany;
1978
+ if (Array.isArray(value)) {
1979
+ const longMember = value.find((member) => stringLimit(member) !== null);
1980
+ if (longMember !== void 0) return `contains a string longer than ${LIMITS.maxValueLength} chars`;
1981
+ }
1745
1982
  switch (shape) {
1746
1983
  case "positive-number":
1747
1984
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
@@ -1772,8 +2009,11 @@ function operandShapeProblem(shape, value) {
1772
2009
  if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
1773
2010
  const names = Object.keys(value);
1774
2011
  if (names.length === 0) return "must be an object with at least one entry";
2012
+ if (names.length > LIMITS.maxMapEntries) return `must not have more than ${LIMITS.maxMapEntries} entries`;
1775
2013
  const bad = names.find((name) => !isHeaderName(name));
1776
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2014
+ if (bad !== void 0) return `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2015
+ const badValue = Object.values(value).find((entry) => typeof entry !== "string" || entry.length > LIMITS.maxValueLength);
2016
+ return badValue === void 0 ? null : `values must be strings no longer than ${LIMITS.maxValueLength} chars`;
1777
2017
  }
1778
2018
  case "header-name-list": {
1779
2019
  if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
@@ -1919,9 +2159,16 @@ var LIMITS = Object.freeze({
1919
2159
  maxRules: 5e3,
1920
2160
  maxWhitelists: 2e3,
1921
2161
  maxConditionsPerRule: 250,
2162
+ maxConditionNodesPerRule: 1e3,
2163
+ maxConditionNodesPerBundle: 25e3,
1922
2164
  maxNestingDepth: 12,
1923
2165
  maxRegexLength: 1e3,
1924
- maxValueLength: 8192
2166
+ maxValueLength: 8192,
2167
+ maxParameterLength: 512,
2168
+ maxParameterItems: 64,
2169
+ maxOperandItems: 256,
2170
+ maxMapEntries: 128,
2171
+ maxPathSegments: 64
1925
2172
  });
1926
2173
  var GROUP_PARAMETER = "rules";
1927
2174
  function isGroup(condition) {
@@ -1948,6 +2195,9 @@ function parameterProblem(parameter) {
1948
2195
  if (parameter === void 0 || parameter === null) return null;
1949
2196
  if (Array.isArray(parameter)) {
1950
2197
  if (parameter.length === 0) return "parameter list is empty";
2198
+ if (parameter.length > LIMITS.maxParameterItems) {
2199
+ return `parameter list has more than ${LIMITS.maxParameterItems} entries`;
2200
+ }
1951
2201
  for (const member of parameter) {
1952
2202
  if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
1953
2203
  const problem = parameterProblem(member);
@@ -1956,6 +2206,9 @@ function parameterProblem(parameter) {
1956
2206
  return null;
1957
2207
  }
1958
2208
  if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2209
+ if (parameter.length > LIMITS.maxParameterLength) {
2210
+ return `parameter is longer than ${LIMITS.maxParameterLength} chars`;
2211
+ }
1959
2212
  if (parameter === GROUP_PARAMETER) {
1960
2213
  return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
1961
2214
  }
@@ -1991,6 +2244,20 @@ function matchProblem(match, parameter) {
1991
2244
  if (!match || typeof match !== "object") return "condition has no match object";
1992
2245
  if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
1993
2246
  if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2247
+ for (const operand of ["value", "key", "claim"]) {
2248
+ const value = match[operand];
2249
+ if (typeof value === "string" && value.length > LIMITS.maxValueLength) {
2250
+ return `match operand "${operand}" is longer than ${LIMITS.maxValueLength} chars`;
2251
+ }
2252
+ if (Array.isArray(value)) {
2253
+ if (value.length > LIMITS.maxOperandItems) {
2254
+ return `match operand "${operand}" has more than ${LIMITS.maxOperandItems} entries`;
2255
+ }
2256
+ if (value.some((entry) => typeof entry === "string" && entry.length > LIMITS.maxValueLength)) {
2257
+ return `match operand "${operand}" contains a string longer than ${LIMITS.maxValueLength} chars`;
2258
+ }
2259
+ }
2260
+ }
1994
2261
  const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
1995
2262
  if (!parameterless && (parameter === void 0 || parameter === null)) {
1996
2263
  return `match type "${match.type}" needs a parameter`;
@@ -2013,11 +2280,20 @@ function matchProblem(match, parameter) {
2013
2280
  } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2014
2281
  return `match type "${match.type}" needs a value`;
2015
2282
  }
2283
+ if (match.type === "array_key_value") {
2284
+ const paths = Array.isArray(match.key) ? match.key : [match.key];
2285
+ if (paths.some((path) => typeof path === "string" && path.split(".").length > LIMITS.maxPathSegments)) {
2286
+ return `match type "array_key_value" key has more than ${LIMITS.maxPathSegments} path segments`;
2287
+ }
2288
+ }
2016
2289
  return null;
2017
2290
  }
2018
2291
  function mutationsProblem(mutations) {
2019
2292
  if (mutations === void 0 || mutations === null) return null;
2020
2293
  if (!Array.isArray(mutations)) return "mutations must be an array";
2294
+ if (mutations.length > LIMITS.maxOperandItems) {
2295
+ return `mutations has more than ${LIMITS.maxOperandItems} entries`;
2296
+ }
2021
2297
  for (const mutation of mutations) {
2022
2298
  if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2023
2299
  return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
@@ -2037,10 +2313,19 @@ function whenProblem(when) {
2037
2313
  if ("path" in when && !isNonEmptyString(when.path)) {
2038
2314
  return "when.path must be a non-empty string";
2039
2315
  }
2316
+ if (typeof when.path === "string" && when.path.length > LIMITS.maxValueLength) {
2317
+ return `when.path is longer than ${LIMITS.maxValueLength} chars`;
2318
+ }
2040
2319
  if ("method" in when) {
2041
2320
  const methods = Array.isArray(when.method) ? when.method : [when.method];
2042
2321
  if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2322
+ if (methods.length > LIMITS.maxParameterItems) {
2323
+ return `when.method has more than ${LIMITS.maxParameterItems} entries`;
2324
+ }
2043
2325
  if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
2326
+ if (methods.some((method) => method.length > LIMITS.maxParameterLength)) {
2327
+ return `when.method entries must not be longer than ${LIMITS.maxParameterLength} chars`;
2328
+ }
2044
2329
  }
2045
2330
  return null;
2046
2331
  }
@@ -2064,6 +2349,11 @@ function rulePropertyProblem(rule) {
2064
2349
  const problem = operandShapeProblem(shape, rule[property]);
2065
2350
  if (problem) return `"${property}" ${problem}`;
2066
2351
  }
2352
+ for (const property of ["id", "rule_id", "title", "category", "message", "source_revision"]) {
2353
+ if (typeof rule[property] === "string" && rule[property].length > LIMITS.maxValueLength) {
2354
+ return `"${property}" is longer than ${LIMITS.maxValueLength} chars`;
2355
+ }
2356
+ }
2067
2357
  return null;
2068
2358
  }
2069
2359
  function actionProblem(action, rule) {
@@ -2093,46 +2383,76 @@ var PHASES2 = new Set(PHASES);
2093
2383
  var ACTIONS2 = new Set(ACTIONS);
2094
2384
  function validateBundle(bundle, opts = {}) {
2095
2385
  const rejected = [];
2386
+ let omittedRejections = 0;
2387
+ const reject = (entry) => {
2388
+ if (rejected.length < 100) rejected.push(entry);
2389
+ else omittedRejections++;
2390
+ };
2096
2391
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2097
2392
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2393
+ const bundleBudget = { count: 0 };
2098
2394
  const firewall = [];
2099
- for (const rule of inFirewall) {
2100
- if (firewall.length >= LIMITS2.maxRules) {
2101
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2102
- continue;
2103
- }
2104
- const reason = enforceableRuleProblem(rule);
2105
- if (reason) rejected.push({ id: idOf(rule), reason });
2395
+ for (const rule of inFirewall.slice(0, LIMITS2.maxRules)) {
2396
+ const reason = enforceableRuleProblem(rule, bundleBudget);
2397
+ if (reason) reject({ id: idOf(rule), reason });
2106
2398
  else firewall.push(rule);
2107
2399
  }
2400
+ if (inFirewall.length > LIMITS2.maxRules) {
2401
+ reject({ id: "(bundle)", reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2402
+ }
2108
2403
  const whitelists = [];
2109
- for (const wl of inWhitelists) {
2110
- if (whitelists.length >= LIMITS2.maxWhitelists) {
2111
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2112
- continue;
2113
- }
2404
+ for (const wl of inWhitelists.slice(0, LIMITS2.maxWhitelists)) {
2114
2405
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
2115
- rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2406
+ reject({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2116
2407
  continue;
2117
2408
  }
2118
2409
  if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
2119
- rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
2410
+ reject({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
2120
2411
  continue;
2121
2412
  }
2122
- const reason = conditionsProblem(wl?.rule_v2);
2123
- if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2413
+ const reason = conditionsProblem(wl?.rule_v2, 0, { count: 0 }, bundleBudget);
2414
+ if (reason) reject({ id: idOf(wl), reason: `whitelist: ${reason}` });
2124
2415
  else whitelists.push(wl);
2125
2416
  }
2417
+ if (inWhitelists.length > LIMITS2.maxWhitelists) {
2418
+ reject({ id: "(bundle)", reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2419
+ }
2420
+ let whitelistKeys = {};
2421
+ if (bundle?.whitelist_keys !== void 0) {
2422
+ if (!bundle.whitelist_keys || typeof bundle.whitelist_keys !== "object" || Array.isArray(bundle.whitelist_keys)) {
2423
+ reject({ id: "(bundle)", reason: "whitelist_keys must be an object" });
2424
+ } else {
2425
+ const entries = Object.entries(bundle.whitelist_keys);
2426
+ if (entries.length > LIMITS2.maxMapEntries) {
2427
+ reject({ id: "(bundle)", reason: `whitelist_keys has more than ${LIMITS2.maxMapEntries} entries` });
2428
+ } else {
2429
+ const invalid = entries.find(([key, value]) => key.length > LIMITS2.maxParameterLength || !Array.isArray(value) || value.length > LIMITS2.maxOperandItems || value.some((entry) => typeof entry !== "string" || entry.length > LIMITS2.maxValueLength));
2430
+ if (invalid) {
2431
+ reject({ id: "(bundle)", reason: `whitelist_keys entry "${invalid[0].slice(0, 64)}" has an unsupported shape` });
2432
+ } else {
2433
+ whitelistKeys = bundle.whitelist_keys;
2434
+ }
2435
+ }
2436
+ }
2437
+ }
2438
+ if (omittedRejections > 0) {
2439
+ rejected.push({ id: "(bundle)", reason: `${omittedRejections} additional rejected entries omitted` });
2440
+ }
2126
2441
  return {
2127
- bundle: { ...bundle, firewall, whitelists },
2442
+ bundle: { ...bundle, firewall, whitelists, whitelist_keys: whitelistKeys },
2128
2443
  rejected
2129
2444
  };
2130
2445
  }
2131
2446
  function idOf(rule) {
2132
2447
  const id = rule?.id ?? rule?.rule_id;
2133
- return id === void 0 || id === null ? "(unidentified)" : String(id);
2448
+ if (id === void 0 || id === null) return "(unidentified)";
2449
+ try {
2450
+ return String(id).slice(0, 128);
2451
+ } catch {
2452
+ return "(unidentified)";
2453
+ }
2134
2454
  }
2135
- function enforceableRuleProblem(rule) {
2455
+ function enforceableRuleProblem(rule, bundleBudget = null) {
2136
2456
  if (!rule || typeof rule !== "object") return "not an object";
2137
2457
  const nullReason = nullPropertyProblem(rule);
2138
2458
  if (nullReason) return nullReason;
@@ -2143,9 +2463,9 @@ function enforceableRuleProblem(rule) {
2143
2463
  if (scopeReason) return scopeReason;
2144
2464
  const propertyReason = rulePropertyProblem(rule);
2145
2465
  if (propertyReason) return propertyReason;
2146
- return conditionsProblem(rule.rule_v2);
2466
+ return conditionsProblem(rule.rule_v2, 0, { count: 0 }, bundleBudget);
2147
2467
  }
2148
- function conditionsProblem(conditions, depth = 0) {
2468
+ function conditionsProblem(conditions, depth = 0, ruleBudget = { count: 0 }, bundleBudget = null) {
2149
2469
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2150
2470
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2151
2471
  if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
@@ -2153,10 +2473,20 @@ function conditionsProblem(conditions, depth = 0) {
2153
2473
  return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
2154
2474
  }
2155
2475
  for (const c of conditions) {
2476
+ ruleBudget.count++;
2477
+ if (ruleBudget.count > LIMITS2.maxConditionNodesPerRule) {
2478
+ return `more than ${LIMITS2.maxConditionNodesPerRule} total condition nodes`;
2479
+ }
2480
+ if (bundleBudget) {
2481
+ bundleBudget.count++;
2482
+ if (bundleBudget.count > LIMITS2.maxConditionNodesPerBundle) {
2483
+ return `bundle exceeds ${LIMITS2.maxConditionNodesPerBundle} total condition nodes`;
2484
+ }
2485
+ }
2156
2486
  const shapeReason = conditionShapeProblem(c);
2157
2487
  if (shapeReason) return shapeReason;
2158
2488
  if (isGroup(c)) {
2159
- const nested = conditionsProblem(c.rules, depth + 1);
2489
+ const nested = conditionsProblem(c.rules, depth + 1, ruleBudget, bundleBudget);
2160
2490
  if (nested) return nested;
2161
2491
  continue;
2162
2492
  }
@@ -2170,11 +2500,17 @@ function conditionsProblem(conditions, depth = 0) {
2170
2500
  if (m.type === "regex") {
2171
2501
  if (typeof m.value !== "string") return "regex match.value must be a string";
2172
2502
  if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2503
+ if (safeRegExp(m.value) === null) return "regex is invalid or has an unsafe repetition shape";
2173
2504
  } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2174
2505
  return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2175
2506
  }
2176
2507
  if (m.match) {
2177
- const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2508
+ const nested = conditionsProblem(
2509
+ [{ parameter: c.parameter, match: m.match }],
2510
+ depth + 1,
2511
+ ruleBudget,
2512
+ bundleBudget
2513
+ );
2178
2514
  if (nested) return nested;
2179
2515
  }
2180
2516
  }
@@ -2392,6 +2728,7 @@ var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2392
2728
  var DEFAULT_CACHE_TTL2 = 3e5;
2393
2729
  var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
2394
2730
  var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
2731
+ var PER_RULE_DRY_RUN_HEADER = "X-Patchstack-Per-Rule-Dry-Run";
2395
2732
  var JITTER_FRACTION2 = 0.1;
2396
2733
  var PulseRuleClient = class {
2397
2734
  #siteUuid;
@@ -2436,6 +2773,9 @@ var PulseRuleClient = class {
2436
2773
  if (this.#buildId !== null && typeof auth.Authorization === "string") {
2437
2774
  headers["X-Patchstack-Build"] = this.#buildId;
2438
2775
  }
2776
+ if (typeof auth.Authorization === "string") {
2777
+ headers[PER_RULE_DRY_RUN_HEADER] = "1";
2778
+ }
2439
2779
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2440
2780
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2441
2781
  if (response.status === 304) {
@@ -2454,7 +2794,7 @@ var PulseRuleClient = class {
2454
2794
  if (!response.ok) {
2455
2795
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
2456
2796
  }
2457
- const data = await response.json();
2797
+ const data = await readBoundedJson2(response);
2458
2798
  if (!data || !Array.isArray(data.firewall)) {
2459
2799
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2460
2800
  }
@@ -2515,19 +2855,18 @@ async function fromFetchRequest(request, options = {}) {
2515
2855
  const method = (request.method || "GET").toUpperCase();
2516
2856
  const headers = {};
2517
2857
  request.headers.forEach((value, key) => {
2518
- headers[key.toLowerCase()] = value;
2858
+ setOwn(headers, key.toLowerCase(), value);
2519
2859
  });
2520
2860
  const query = {};
2521
2861
  for (const [key, value] of url.searchParams) {
2522
- if (key in query) {
2523
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2524
- } else {
2525
- query[key] = value;
2526
- }
2862
+ appendOwn(query, key, value);
2527
2863
  }
2528
2864
  let rawBody = "";
2865
+ let bodyInspectionSkip = null;
2529
2866
  if (method !== "GET" && method !== "HEAD") {
2530
- rawBody = await readCappedText(request, maxBodyBytes);
2867
+ const read = await readCappedText(request, maxBodyBytes);
2868
+ rawBody = read.text;
2869
+ bodyInspectionSkip = read.skip;
2531
2870
  }
2532
2871
  const contentType = headers["content-type"] || "";
2533
2872
  let body = {};
@@ -2554,7 +2893,8 @@ async function fromFetchRequest(request, options = {}) {
2554
2893
  cookies: parseCookies(headers.cookie),
2555
2894
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
2556
2895
  // drops, so prototype-pollution rules on `raw` are robust.
2557
- _rawBody: rawBody
2896
+ _rawBody: rawBody,
2897
+ ...bodyInspectionSkip === null ? {} : { _bodyInspectionSkip: bodyInspectionSkip }
2558
2898
  };
2559
2899
  }
2560
2900
  async function readCappedText(request, max) {
@@ -2562,7 +2902,7 @@ async function readCappedText(request, max) {
2562
2902
  try {
2563
2903
  clone = request.clone();
2564
2904
  } catch {
2565
- return "";
2905
+ return { text: "", skip: "clone-failed" };
2566
2906
  }
2567
2907
  const body = clone.body;
2568
2908
  if (body && typeof body.getReader === "function") {
@@ -2573,23 +2913,30 @@ async function readCappedText(request, max) {
2573
2913
  for (; ; ) {
2574
2914
  const { done, value } = await reader.read();
2575
2915
  if (done) break;
2576
- if (!value || buffered >= max) continue;
2916
+ if (!value) continue;
2577
2917
  const take = Math.min(value.byteLength, max - buffered);
2578
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2918
+ if (take > 0) chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2579
2919
  buffered += take;
2920
+ if (buffered >= max) {
2921
+ void reader.cancel().catch(() => {
2922
+ });
2923
+ return { text: decodeChunks(chunks, buffered), skip: "body-cap" };
2924
+ }
2580
2925
  }
2581
2926
  } catch {
2582
- return "";
2583
- }
2584
- try {
2585
- return new TextDecoder().decode(concatChunks(chunks, buffered));
2586
- } catch {
2587
- return "";
2927
+ void reader.cancel().catch(() => {
2928
+ });
2929
+ return { text: "", skip: "read-failed" };
2930
+ } finally {
2931
+ reader.releaseLock();
2588
2932
  }
2933
+ return { text: decodeChunks(chunks, buffered), skip: null };
2589
2934
  }
2935
+ return { text: "", skip: "unreadable-body" };
2936
+ }
2937
+ function decodeChunks(chunks, total) {
2590
2938
  try {
2591
- const text = await clone.text();
2592
- return text.length > max ? text.slice(0, max) : text;
2939
+ return new TextDecoder().decode(concatChunks(chunks, total));
2593
2940
  } catch {
2594
2941
  return "";
2595
2942
  }
@@ -2616,7 +2963,7 @@ function parseBody(rawBody, contentType) {
2616
2963
  }
2617
2964
  if (isForm) {
2618
2965
  const body = {};
2619
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
2966
+ for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
2620
2967
  return { body, files: void 0 };
2621
2968
  }
2622
2969
  if (isJson || isAmbiguous) {
@@ -2644,9 +2991,9 @@ function parseMultipart(rawBody, boundary) {
2644
2991
  if (filename !== void 0) {
2645
2992
  const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2646
2993
  const file = { filename, type: partType, content };
2647
- files[name] = name in files ? [].concat(files[name], file) : file;
2994
+ appendOwn(files, name, file);
2648
2995
  } else {
2649
- body[name] = name in body ? [].concat(body[name], content) : content;
2996
+ appendOwn(body, name, content);
2650
2997
  }
2651
2998
  }
2652
2999
  return { body, files };
@@ -2661,7 +3008,7 @@ function parseCookies(header) {
2661
3008
  if (idx === -1) {
2662
3009
  continue;
2663
3010
  }
2664
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3011
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2665
3012
  }
2666
3013
  return cookies;
2667
3014
  }
@@ -2671,7 +3018,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2671
3018
  const method = (req.method || "GET").toUpperCase();
2672
3019
  const headers = {};
2673
3020
  for (const [key, value] of Object.entries(req.headers || {})) {
2674
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
3021
+ setOwn(headers, key.toLowerCase(), Array.isArray(value) ? value.join(", ") : value);
2675
3022
  }
2676
3023
  const host = headers.host || "localhost";
2677
3024
  let url;
@@ -2686,11 +3033,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2686
3033
  }
2687
3034
  const query = {};
2688
3035
  for (const [key, value] of url.searchParams) {
2689
- if (key in query) {
2690
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2691
- } else {
2692
- query[key] = value;
2693
- }
3036
+ appendOwn(query, key, value);
2694
3037
  }
2695
3038
  const contentType = headers["content-type"] || "";
2696
3039
  let body = {};
@@ -2731,7 +3074,7 @@ function parseCookies2(header) {
2731
3074
  if (idx === -1) {
2732
3075
  continue;
2733
3076
  }
2734
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3077
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2735
3078
  }
2736
3079
  return cookies;
2737
3080
  }
@@ -2787,6 +3130,67 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2787
3130
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2788
3131
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
2789
3132
  const MAX_REDIRECTS = 20;
3133
+ const MAX_REPLAY_BODY_BYTES = 1024 * 1024;
3134
+ const captureReplayBody = (request) => {
3135
+ if (request.method === "GET" || request.method === "HEAD" || !request.body) {
3136
+ return { result: Promise.resolve({ body: void 0, tooLarge: false }), cancel() {
3137
+ } };
3138
+ }
3139
+ let reader;
3140
+ try {
3141
+ reader = request.clone().body?.getReader();
3142
+ } catch {
3143
+ reader = null;
3144
+ }
3145
+ if (!reader) {
3146
+ return { result: Promise.resolve({ body: void 0, tooLarge: true }), cancel() {
3147
+ } };
3148
+ }
3149
+ let cancelled = false;
3150
+ const result = (async () => {
3151
+ const chunks = [];
3152
+ let total = 0;
3153
+ try {
3154
+ for (; ; ) {
3155
+ const { done, value } = await reader.read();
3156
+ if (done || cancelled) break;
3157
+ total += value.byteLength;
3158
+ if (total > MAX_REPLAY_BODY_BYTES) {
3159
+ void reader.cancel().catch(() => {
3160
+ });
3161
+ return { body: void 0, tooLarge: true };
3162
+ }
3163
+ chunks.push(value);
3164
+ }
3165
+ if (cancelled) return { body: void 0, tooLarge: false };
3166
+ const body = new Uint8Array(total);
3167
+ let offset = 0;
3168
+ for (const chunk of chunks) {
3169
+ body.set(chunk, offset);
3170
+ offset += chunk.byteLength;
3171
+ }
3172
+ return { body, tooLarge: false };
3173
+ } catch {
3174
+ return { body: void 0, tooLarge: true };
3175
+ } finally {
3176
+ reader.releaseLock();
3177
+ }
3178
+ })();
3179
+ return {
3180
+ result,
3181
+ cancel() {
3182
+ cancelled = true;
3183
+ void reader.cancel().catch(() => {
3184
+ });
3185
+ }
3186
+ };
3187
+ };
3188
+ const discardResponseBody = async (response) => {
3189
+ try {
3190
+ await response?.body?.cancel?.();
3191
+ } catch {
3192
+ }
3193
+ };
2790
3194
  const screenUrl = async (u, method) => {
2791
3195
  let host = null;
2792
3196
  try {
@@ -2812,20 +3216,43 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2812
3216
  if (callerRedirect !== "follow") return originalFetch(input, init);
2813
3217
  const headers = new Headers(cur.headers);
2814
3218
  const signal = cur.signal;
2815
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
3219
+ const replay = captureReplayBody(cur);
3220
+ let body;
2816
3221
  for (let hop = 0; ; hop++) {
2817
- const resp = await originalFetch(
2818
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
2819
- );
3222
+ let resp;
3223
+ try {
3224
+ resp = await originalFetch(
3225
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3226
+ );
3227
+ } catch (error) {
3228
+ replay.cancel();
3229
+ throw error;
3230
+ }
2820
3231
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
2821
- if (!location) return resp;
2822
- if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
3232
+ if (!location) {
3233
+ replay.cancel();
3234
+ return resp;
3235
+ }
3236
+ await discardResponseBody(resp);
3237
+ if (hop >= MAX_REDIRECTS) {
3238
+ replay.cancel();
3239
+ throw new Error("Patchstack blocked an outbound request: too many redirects");
3240
+ }
2823
3241
  const next = new URL(location, url).href;
2824
3242
  if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
2825
3243
  method = "GET";
2826
3244
  body = void 0;
3245
+ replay.cancel();
2827
3246
  headers.delete("content-type");
2828
3247
  headers.delete("content-length");
3248
+ } else if (body === void 0 && method !== "GET" && method !== "HEAD") {
3249
+ const captured = await replay.result;
3250
+ if (captured.tooLarge) {
3251
+ throw new Error(
3252
+ `Patchstack blocked a redirect that required replaying more than ${MAX_REPLAY_BODY_BYTES} bytes`
3253
+ );
3254
+ }
3255
+ body = captured.body;
2829
3256
  }
2830
3257
  if (new URL(next).origin !== new URL(url).origin) {
2831
3258
  headers.delete("authorization");
@@ -3136,7 +3563,7 @@ var DEFAULT_RESPONSE_RULES = [
3136
3563
  //
3137
3564
  // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
3138
3565
  // body, where the newline is the two characters `\` and `n`.
3139
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3566
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+\\S[^\\r\\n(]*\\((?:node:|[A-Za-z]:[\\\\/])?[^:\\r\\n]+:\\d+:\\d+\\)/" } }]
3140
3567
  },
3141
3568
  {
3142
3569
  id: "resp-sql-error",
@@ -3269,9 +3696,18 @@ async function loadFs() {
3269
3696
  try {
3270
3697
  const [fs, path] = await Promise.all([import("fs"), import("path")]);
3271
3698
  fsMod = {
3699
+ chmodSync: fs.chmodSync,
3700
+ closeSync: fs.closeSync,
3701
+ constants: fs.constants,
3702
+ fstatSync: fs.fstatSync,
3703
+ fsyncSync: fs.fsyncSync,
3704
+ lstatSync: fs.lstatSync,
3705
+ openSync: fs.openSync,
3272
3706
  readFileSync: fs.readFileSync,
3273
- writeFileSync: fs.writeFileSync,
3274
3707
  mkdirSync: fs.mkdirSync,
3708
+ renameSync: fs.renameSync,
3709
+ unlinkSync: fs.unlinkSync,
3710
+ writeFileSync: fs.writeFileSync,
3275
3711
  join: path.join
3276
3712
  };
3277
3713
  } catch {
@@ -3324,22 +3760,84 @@ async function cacheWrite(dir, env) {
3324
3760
  if (!dir) return;
3325
3761
  const fs = await loadFs();
3326
3762
  if (!fs) return;
3763
+ const target = fs.join(dir, "patchstack-rules.json");
3764
+ const temporary = fs.join(dir, `.patchstack-rules-${randomSuffix()}.tmp`);
3765
+ let descriptor = null;
3327
3766
  try {
3328
3767
  fs.mkdirSync(dir, { recursive: true });
3329
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
3768
+ const directory = fs.lstatSync(dir);
3769
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return;
3770
+ let mode;
3771
+ try {
3772
+ const current = fs.lstatSync(target);
3773
+ if (current.isSymbolicLink() || !current.isFile()) return;
3774
+ mode = current.mode & 4095;
3775
+ } catch (error) {
3776
+ if (error?.code !== "ENOENT") return;
3777
+ }
3778
+ descriptor = fs.openSync(temporary, "wx", mode ?? 384);
3779
+ fs.writeFileSync(descriptor, JSON.stringify(env), "utf8");
3780
+ fs.fsyncSync(descriptor);
3781
+ fs.closeSync(descriptor);
3782
+ descriptor = null;
3783
+ if (mode !== void 0) fs.chmodSync(temporary, mode);
3784
+ try {
3785
+ fs.renameSync(temporary, target);
3786
+ } catch (error) {
3787
+ if (typeof process === "undefined" || process.platform !== "win32" || error?.code !== "EEXIST" && error?.code !== "EPERM") throw error;
3788
+ try {
3789
+ fs.unlinkSync(target);
3790
+ } catch (unlinkError) {
3791
+ if (unlinkError?.code !== "ENOENT") throw unlinkError;
3792
+ }
3793
+ fs.renameSync(temporary, target);
3794
+ }
3330
3795
  } catch {
3796
+ } finally {
3797
+ if (descriptor !== null) {
3798
+ try {
3799
+ fs.closeSync(descriptor);
3800
+ } catch {
3801
+ }
3802
+ }
3803
+ try {
3804
+ fs.unlinkSync(temporary);
3805
+ } catch {
3806
+ }
3331
3807
  }
3332
3808
  }
3333
3809
  async function cacheRead(dir) {
3334
3810
  if (!dir) return null;
3335
3811
  const fs = await loadFs();
3336
3812
  if (!fs) return null;
3813
+ const target = fs.join(dir, "patchstack-rules.json");
3814
+ let descriptor = null;
3337
3815
  try {
3338
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
3816
+ const entry = fs.lstatSync(target);
3817
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 16 * 1024 * 1024) return null;
3818
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
3819
+ descriptor = fs.openSync(target, flags);
3820
+ const opened = fs.fstatSync(descriptor);
3821
+ if (!opened.isFile() || opened.size > 16 * 1024 * 1024) return null;
3822
+ return toEnvelope(JSON.parse(fs.readFileSync(descriptor, "utf8")));
3339
3823
  } catch {
3340
3824
  return null;
3825
+ } finally {
3826
+ if (descriptor !== null) {
3827
+ try {
3828
+ fs.closeSync(descriptor);
3829
+ } catch {
3830
+ }
3831
+ }
3341
3832
  }
3342
3833
  }
3834
+ function randomSuffix() {
3835
+ try {
3836
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
3837
+ } catch {
3838
+ }
3839
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3840
+ }
3343
3841
  function toEnvelope(value) {
3344
3842
  if (!value || typeof value !== "object") return null;
3345
3843
  if (value.bundle && typeof value.bundle === "object") {
@@ -3364,7 +3862,11 @@ function toEnvelope(value) {
3364
3862
  function liveUpdateRejections(res, options) {
3365
3863
  if (options.acceptPartialBundle) return [];
3366
3864
  const { rejected } = validateBundle(
3367
- { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
3865
+ {
3866
+ firewall: Array.isArray(res.firewall) ? res.firewall : [],
3867
+ whitelists: Array.isArray(res.whitelists) ? res.whitelists : [],
3868
+ whitelist_keys: res.whitelist_keys
3869
+ },
3368
3870
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3369
3871
  );
3370
3872
  return rejected;
@@ -3535,7 +4037,8 @@ function normalizeBundle(b, options = {}) {
3535
4037
  const { bundle: checked, rejected } = validateBundle(
3536
4038
  {
3537
4039
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
3538
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
4040
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
4041
+ whitelist_keys: b.whitelist_keys
3539
4042
  },
3540
4043
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3541
4044
  );
@@ -3555,7 +4058,7 @@ function normalizeBundle(b, options = {}) {
3555
4058
  return {
3556
4059
  firewall: checked.firewall,
3557
4060
  whitelists: checked.whitelists,
3558
- whitelist_keys: b.whitelist_keys ?? {},
4061
+ whitelist_keys: checked.whitelist_keys,
3559
4062
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
3560
4063
  };
3561
4064
  }
@@ -3599,18 +4102,23 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3599
4102
  };
3600
4103
  }
3601
4104
  function makeRefreshHandler(tick, secret) {
4105
+ let inflight = null;
4106
+ const runOnce = () => {
4107
+ if (inflight) return inflight;
4108
+ let current;
4109
+ current = Promise.resolve().then(() => tick()).finally(() => {
4110
+ if (inflight === current) inflight = null;
4111
+ });
4112
+ inflight = current;
4113
+ return current;
4114
+ };
3602
4115
  return async (request) => {
3603
4116
  if (!secret) return new Response("not found", { status: 404 });
3604
- let provided = null;
3605
- try {
3606
- provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
3607
- } catch {
3608
- provided = null;
3609
- }
4117
+ const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
3610
4118
  if (provided !== secret) return new Response("forbidden", { status: 403 });
3611
4119
  let refreshed = true;
3612
4120
  try {
3613
- const status = await tick();
4121
+ const status = await runOnce();
3614
4122
  if (status && status.ok === false) refreshed = false;
3615
4123
  } catch {
3616
4124
  refreshed = false;
@@ -4359,8 +4867,10 @@ function hardensWithoutBody(rule) {
4359
4867
  // src/protect/firewall-log.js
4360
4868
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4361
4869
  var STOP_BUDGET_MS2 = 5e3;
4870
+ var ATTEMPT_TIMEOUT_MS2 = 1e4;
4362
4871
  var DEFAULT_FLUSH_MS2 = 1e3;
4363
4872
  var MAX_BATCH2 = 50;
4873
+ var MAX_QUEUE2 = 500;
4364
4874
  var TOKEN_SKEW_MS = 6e4;
4365
4875
  function parseApiKey(apiKey) {
4366
4876
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -4382,14 +4892,16 @@ function resolveApiBase(pulseOrManifestUrl) {
4382
4892
  }
4383
4893
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
4384
4894
  try {
4385
- return new URL(pulseOrManifestUrl).origin;
4895
+ const candidate = new URL(pulseOrManifestUrl).origin;
4896
+ if (isSafeOrigin(candidate)) return candidate;
4386
4897
  } catch {
4387
4898
  }
4388
4899
  }
4389
4900
  const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
4390
4901
  if (typeof endpoint === "string" && endpoint.length > 0) {
4391
4902
  try {
4392
- return new URL(endpoint).origin;
4903
+ const candidate = new URL(endpoint).origin;
4904
+ if (isSafeOrigin(candidate)) return candidate;
4393
4905
  } catch {
4394
4906
  }
4395
4907
  }
@@ -4401,16 +4913,16 @@ function createFirewallLogReporter(opts) {
4401
4913
  return { record() {
4402
4914
  }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
4403
4915
  }
4404
- const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
4916
+ const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
4405
4917
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
4406
4918
  const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
4407
4919
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
4408
4920
  let queue = [];
4409
4921
  let timer = null;
4410
4922
  let stopped = false;
4411
- const outstanding = /* @__PURE__ */ new Set();
4923
+ let inFlight = null;
4412
4924
  let drainPromise = null;
4413
- const lifetime = typeof AbortController === "function" ? new AbortController() : null;
4925
+ let activeController = null;
4414
4926
  let ended = false;
4415
4927
  let cachedToken = null;
4416
4928
  let tokenInflight = null;
@@ -4436,9 +4948,9 @@ function createFirewallLogReporter(opts) {
4436
4948
  })
4437
4949
  });
4438
4950
  if (!res || !res.ok) return null;
4439
- const body = await res.json();
4951
+ const body = await readBoundedJson(res);
4440
4952
  const token = body?.access_token;
4441
- if (typeof token !== "string" || token.length === 0) return null;
4953
+ if (typeof token !== "string" || token.length === 0 || token.length > 8192 || /[\u0000-\u0020\u007f-\u009f]/.test(token)) return null;
4442
4954
  const expiresIn = Number(body?.expires_in);
4443
4955
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
4444
4956
  cachedToken = { token, expiresAt: Date.now() + ttlMs };
@@ -4456,12 +4968,18 @@ function createFirewallLogReporter(opts) {
4456
4968
  clearTimeout(timer);
4457
4969
  timer = null;
4458
4970
  }
4459
- if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
4971
+ if (ended || typeof fetchImpl !== "function") return Promise.resolve();
4972
+ if (inFlight) return inFlight;
4973
+ if (queue.length === 0) return Promise.resolve();
4460
4974
  const batch = queue.splice(0, MAX_BATCH2);
4975
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
4976
+ activeController = controller;
4977
+ const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
4978
+ attemptTimer?.unref?.();
4461
4979
  let entry;
4462
- const send = (async () => {
4980
+ entry = (async () => {
4463
4981
  try {
4464
- const token = await fetchAccessToken(lifetime?.signal);
4982
+ const token = await fetchAccessToken(controller?.signal);
4465
4983
  if (!token || ended) return;
4466
4984
  const body = new URLSearchParams();
4467
4985
  body.set("type", "firewall");
@@ -4476,19 +4994,24 @@ function createFirewallLogReporter(opts) {
4476
4994
  ...sourceHost ? { "Source-Host": sourceHost } : {}
4477
4995
  },
4478
4996
  body,
4479
- // Both phases carry it, so a shutdown that runs out of time can end either one.
4480
- ...lifetime ? { signal: lifetime.signal } : {}
4997
+ // Both phases carry the attempt controller, so slow transports cannot accumulate work.
4998
+ ...controller ? { signal: controller.signal } : {}
4481
4999
  });
4482
5000
  if (p && typeof p.then === "function") await p.catch(() => {
4483
5001
  });
4484
5002
  } catch {
4485
5003
  } finally {
4486
- outstanding.delete(entry);
5004
+ clearTimeout(attemptTimer);
5005
+ if (activeController === controller) activeController = null;
5006
+ if (inFlight === entry) inFlight = null;
5007
+ if (!stopped && !ended && queue.length > 0 && !timer) {
5008
+ timer = setTimeout(flush, queue.length >= MAX_BATCH2 ? 0 : flushMs);
5009
+ timer?.unref?.();
5010
+ }
4487
5011
  }
4488
5012
  })();
4489
- entry = send;
4490
- outstanding.add(send);
4491
- return send;
5013
+ inFlight = entry;
5014
+ return entry;
4492
5015
  };
4493
5016
  return {
4494
5017
  /**
@@ -4504,6 +5027,7 @@ function createFirewallLogReporter(opts) {
4504
5027
  if (stopped) return;
4505
5028
  const fid = event?.rule?.id;
4506
5029
  if (fid === void 0 || fid === null || fid === "") return;
5030
+ if (queue.length >= MAX_QUEUE2) return;
4507
5031
  queue.push({
4508
5032
  fid,
4509
5033
  method: event.method ?? null,
@@ -4535,9 +5059,10 @@ function createFirewallLogReporter(opts) {
4535
5059
  }
4536
5060
  const terminate = () => {
4537
5061
  ended = true;
4538
- lifetime?.abort();
5062
+ activeController?.abort();
5063
+ activeController = null;
4539
5064
  queue = [];
4540
- outstanding.clear();
5065
+ inFlight = null;
4541
5066
  };
4542
5067
  let budget = null;
4543
5068
  const spent = new Promise((resolve) => {
@@ -4549,8 +5074,8 @@ function createFirewallLogReporter(opts) {
4549
5074
  });
4550
5075
  const work = (async () => {
4551
5076
  while (!ended) {
4552
- if (outstanding.size > 0) {
4553
- await Promise.all([...outstanding]);
5077
+ if (inFlight) {
5078
+ await inFlight;
4554
5079
  continue;
4555
5080
  }
4556
5081
  if (queue.length === 0) break;
@@ -4574,10 +5099,107 @@ function telemetryEnabled() {
4574
5099
 
4575
5100
  // src/protect/supabase-guard.js
4576
5101
  var GUARD_PATH = "/_patchstack/guard";
4577
- var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
4578
- function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
5102
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5103
+ var DEFAULT_TIMEOUT_MS = 3e4;
5104
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5105
+ "connection",
5106
+ "cookie",
5107
+ "cookie2",
5108
+ "forwarded",
5109
+ "host",
5110
+ "keep-alive",
5111
+ "proxy-authenticate",
5112
+ "proxy-authorization",
5113
+ "te",
5114
+ "trailer",
5115
+ "transfer-encoding",
5116
+ "upgrade",
5117
+ "x-forwarded-for",
5118
+ "x-forwarded-host",
5119
+ "x-forwarded-port",
5120
+ "x-forwarded-proto",
5121
+ "x-ps-target",
5122
+ "x-real-ip"
5123
+ ]);
5124
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5125
+ "connection",
5126
+ "content-encoding",
5127
+ "content-length",
5128
+ "keep-alive",
5129
+ "proxy-authenticate",
5130
+ "proxy-authorization",
5131
+ "set-cookie",
5132
+ "set-cookie2",
5133
+ "te",
5134
+ "trailer",
5135
+ "transfer-encoding",
5136
+ "upgrade"
5137
+ ]);
5138
+ var BodyTooLargeError = class extends Error {
5139
+ };
5140
+ async function boundedBody(request, maxBytes) {
5141
+ const stated = Number(request.headers.get("content-length"));
5142
+ if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5143
+ if (!request.body) return void 0;
5144
+ const reader = request.body.getReader();
5145
+ const chunks = [];
5146
+ let total = 0;
5147
+ try {
5148
+ for (; ; ) {
5149
+ const { done, value } = await reader.read();
5150
+ if (done) break;
5151
+ total += value.byteLength;
5152
+ if (total > maxBytes) {
5153
+ void reader.cancel().catch(() => {
5154
+ });
5155
+ throw new BodyTooLargeError();
5156
+ }
5157
+ chunks.push(value);
5158
+ }
5159
+ } finally {
5160
+ reader.releaseLock();
5161
+ }
5162
+ const body = new Uint8Array(total);
5163
+ let offset = 0;
5164
+ for (const chunk of chunks) {
5165
+ body.set(chunk, offset);
5166
+ offset += chunk.byteLength;
5167
+ }
5168
+ return body;
5169
+ }
5170
+ function requestHeaders(input) {
5171
+ const output = new Headers();
5172
+ const connectionTokens = new Set(
5173
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5174
+ );
5175
+ input.forEach((value, key) => {
5176
+ const lower = key.toLowerCase();
5177
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5178
+ });
5179
+ return output;
5180
+ }
5181
+ function responseHeaders(input) {
5182
+ const output = new Headers();
5183
+ const connectionTokens = new Set(
5184
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5185
+ );
5186
+ input.forEach((value, key) => {
5187
+ const lower = key.toLowerCase();
5188
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5189
+ });
5190
+ return output;
5191
+ }
5192
+ function createSupabaseGuard({
5193
+ protection,
5194
+ supabaseUrl,
5195
+ fetchImpl = fetch,
5196
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5197
+ timeoutMs = DEFAULT_TIMEOUT_MS
5198
+ }) {
4579
5199
  const guard = protection.fetchGuard();
4580
5200
  const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5201
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5202
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
4581
5203
  return async function handleGuardRequest(request) {
4582
5204
  const target = request.headers.get("x-ps-target");
4583
5205
  if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
@@ -4591,29 +5213,45 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
4591
5213
  return new Response("patchstack: target not allowed", { status: 403 });
4592
5214
  }
4593
5215
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
4594
- const bodyText = hasBody ? await request.text() : "";
5216
+ let body;
5217
+ try {
5218
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5219
+ } catch (error) {
5220
+ if (error instanceof BodyTooLargeError) {
5221
+ return new Response("patchstack: request body too large", { status: 413 });
5222
+ }
5223
+ throw error;
5224
+ }
5225
+ const forwardHeaders = requestHeaders(request.headers);
4595
5226
  const evalReq = new Request(targetUrl.toString(), {
4596
5227
  method: request.method,
5228
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5229
+ // sanitized at the cross-origin boundary below.
4597
5230
  headers: request.headers,
4598
- body: hasBody ? bodyText : void 0
5231
+ body
4599
5232
  });
4600
5233
  const blocked = await guard(evalReq);
4601
5234
  if (blocked) return blocked;
4602
- const forwardHeaders = new Headers(request.headers);
4603
- forwardHeaders.delete("x-ps-target");
4604
- forwardHeaders.delete("host");
4605
- const upstream = await fetchImpl(targetUrl.toString(), {
4606
- method: request.method,
4607
- headers: forwardHeaders,
4608
- body: hasBody ? bodyText : void 0,
4609
- redirect: "manual"
4610
- });
4611
- const outHeaders = new Headers();
4612
- upstream.headers.forEach((value, key) => {
4613
- if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
5235
+ let upstream;
5236
+ try {
5237
+ upstream = await fetchImpl(targetUrl.toString(), {
5238
+ method: request.method,
5239
+ headers: forwardHeaders,
5240
+ body,
5241
+ redirect: "manual",
5242
+ signal: AbortSignal.timeout(upstreamTimeout)
5243
+ });
5244
+ } catch (error) {
5245
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5246
+ return new Response("patchstack: upstream timed out", { status: 504 });
5247
+ }
5248
+ throw error;
5249
+ }
5250
+ const forwarded = new Response(upstream.body, {
5251
+ status: upstream.status,
5252
+ statusText: upstream.statusText,
5253
+ headers: responseHeaders(upstream.headers)
4614
5254
  });
4615
- const buf = await upstream.arrayBuffer();
4616
- const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
4617
5255
  return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
4618
5256
  };
4619
5257
  }
@@ -4916,9 +5554,13 @@ async function createProtection(options = {}) {
4916
5554
  for (const name of Object.keys(headers)) {
4917
5555
  const value = headers[name];
4918
5556
  if (typeof value === "string") {
4919
- headers[name] = applyRedactors(value, spanRedactors, mask);
5557
+ setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
4920
5558
  } else if (Array.isArray(value)) {
4921
- headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
5559
+ setOwn(
5560
+ headers,
5561
+ name,
5562
+ value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
5563
+ );
4922
5564
  }
4923
5565
  }
4924
5566
  }
@@ -4968,7 +5610,13 @@ async function createProtection(options = {}) {
4968
5610
  let result;
4969
5611
  let shaped;
4970
5612
  try {
4971
- shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
5613
+ shaped = await fromFetchRequest(request, {
5614
+ trustedProxy: options.trustedProxy,
5615
+ maxBodyBytes: options.maxBodyBytes
5616
+ });
5617
+ if (shaped?._bodyInspectionSkip) {
5618
+ recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
5619
+ }
4972
5620
  result = engine.evaluate(shaped);
4973
5621
  } catch (err) {
4974
5622
  notify(onError, err, "onError");
@@ -5058,7 +5706,7 @@ async function createProtection(options = {}) {
5058
5706
  const out = {};
5059
5707
  for (const [name, value] of writeHeadEntries(headers)) {
5060
5708
  const key = name.toLowerCase();
5061
- out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
5709
+ appendOwn(out, key, value);
5062
5710
  }
5063
5711
  return out;
5064
5712
  };
@@ -5455,7 +6103,7 @@ async function createProtection(options = {}) {
5455
6103
  let reporter = null;
5456
6104
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
5457
6105
  try {
5458
- ({ reportManifest: reporter } = await import("./refresh-manifest-2ALIJGZ3.js"));
6106
+ ({ reportManifest: reporter } = await import("./refresh-manifest-IE6SPHG6.js"));
5459
6107
  } catch (err) {
5460
6108
  notify(onError, err, "onError");
5461
6109
  }
@@ -5594,7 +6242,6 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5594
6242
  const reader = body.getReader();
5595
6243
  const chunks = [];
5596
6244
  let size = 0;
5597
- let over = false;
5598
6245
  let sniffed = !sniff;
5599
6246
  try {
5600
6247
  for (; ; ) {
@@ -5603,20 +6250,27 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5603
6250
  if (!value) continue;
5604
6251
  if (!sniffed) {
5605
6252
  sniffed = true;
5606
- if (looksBinary(value)) return { skip: "binary-body" };
6253
+ if (looksBinary(value)) {
6254
+ void reader.cancel().catch(() => {
6255
+ });
6256
+ return { skip: "binary-body" };
6257
+ }
5607
6258
  }
5608
6259
  size += value.byteLength;
5609
- if (over) continue;
5610
- if (size > cap) {
5611
- over = true;
5612
- continue;
6260
+ if (size >= cap) {
6261
+ void reader.cancel().catch(() => {
6262
+ });
6263
+ return { skip: "body-cap" };
5613
6264
  }
5614
6265
  chunks.push(value);
5615
6266
  }
5616
6267
  } catch {
6268
+ void reader.cancel().catch(() => {
6269
+ });
5617
6270
  return { skip: "read-failed" };
6271
+ } finally {
6272
+ reader.releaseLock();
5618
6273
  }
5619
- if (over) return { skip: "body-cap" };
5620
6274
  try {
5621
6275
  return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
5622
6276
  } catch {
@@ -5644,10 +6298,10 @@ function concatBytes(chunks, total) {
5644
6298
  function headerObject(headers) {
5645
6299
  const out = {};
5646
6300
  headers?.forEach?.((v, k) => {
5647
- out[k.toLowerCase()] = v;
6301
+ setOwn(out, k.toLowerCase(), v);
5648
6302
  });
5649
6303
  const setCookies = headers?.getSetCookie?.();
5650
- if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
6304
+ if (setCookies && setCookies.length) setOwn(out, "set-cookie", setCookies);
5651
6305
  return out;
5652
6306
  }
5653
6307
  function hasSpanMutations(rule) {
@@ -5816,7 +6470,7 @@ function isHeaderMutation(action) {
5816
6470
  }
5817
6471
  function applyHeaderMutation(headers, rule) {
5818
6472
  if (rule.action === "remove-header") {
5819
- for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
6473
+ for (const name of rule.remove_headers ?? []) setOwn(headers, String(name).toLowerCase(), null);
5820
6474
  return;
5821
6475
  }
5822
6476
  if (rule.action === "set-header") {
@@ -5825,7 +6479,7 @@ function applyHeaderMutation(headers, rule) {
5825
6479
  const key = String(name).toLowerCase();
5826
6480
  const present = headers[key] != null && headers[key] !== "";
5827
6481
  if (ensure && present) continue;
5828
- headers[key] = String(value);
6482
+ setOwn(headers, key, String(value));
5829
6483
  }
5830
6484
  return;
5831
6485
  }