@patchstack/connect 0.5.6 → 0.5.7

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-2K5SHFUM.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
  }
@@ -2454,7 +2790,7 @@ var PulseRuleClient = class {
2454
2790
  if (!response.ok) {
2455
2791
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
2456
2792
  }
2457
- const data = await response.json();
2793
+ const data = await readBoundedJson2(response);
2458
2794
  if (!data || !Array.isArray(data.firewall)) {
2459
2795
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2460
2796
  }
@@ -2515,19 +2851,18 @@ async function fromFetchRequest(request, options = {}) {
2515
2851
  const method = (request.method || "GET").toUpperCase();
2516
2852
  const headers = {};
2517
2853
  request.headers.forEach((value, key) => {
2518
- headers[key.toLowerCase()] = value;
2854
+ setOwn(headers, key.toLowerCase(), value);
2519
2855
  });
2520
2856
  const query = {};
2521
2857
  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
- }
2858
+ appendOwn(query, key, value);
2527
2859
  }
2528
2860
  let rawBody = "";
2861
+ let bodyInspectionSkip = null;
2529
2862
  if (method !== "GET" && method !== "HEAD") {
2530
- rawBody = await readCappedText(request, maxBodyBytes);
2863
+ const read = await readCappedText(request, maxBodyBytes);
2864
+ rawBody = read.text;
2865
+ bodyInspectionSkip = read.skip;
2531
2866
  }
2532
2867
  const contentType = headers["content-type"] || "";
2533
2868
  let body = {};
@@ -2554,7 +2889,8 @@ async function fromFetchRequest(request, options = {}) {
2554
2889
  cookies: parseCookies(headers.cookie),
2555
2890
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
2556
2891
  // drops, so prototype-pollution rules on `raw` are robust.
2557
- _rawBody: rawBody
2892
+ _rawBody: rawBody,
2893
+ ...bodyInspectionSkip === null ? {} : { _bodyInspectionSkip: bodyInspectionSkip }
2558
2894
  };
2559
2895
  }
2560
2896
  async function readCappedText(request, max) {
@@ -2562,7 +2898,7 @@ async function readCappedText(request, max) {
2562
2898
  try {
2563
2899
  clone = request.clone();
2564
2900
  } catch {
2565
- return "";
2901
+ return { text: "", skip: "clone-failed" };
2566
2902
  }
2567
2903
  const body = clone.body;
2568
2904
  if (body && typeof body.getReader === "function") {
@@ -2573,23 +2909,30 @@ async function readCappedText(request, max) {
2573
2909
  for (; ; ) {
2574
2910
  const { done, value } = await reader.read();
2575
2911
  if (done) break;
2576
- if (!value || buffered >= max) continue;
2912
+ if (!value) continue;
2577
2913
  const take = Math.min(value.byteLength, max - buffered);
2578
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2914
+ if (take > 0) chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2579
2915
  buffered += take;
2916
+ if (buffered >= max) {
2917
+ void reader.cancel().catch(() => {
2918
+ });
2919
+ return { text: decodeChunks(chunks, buffered), skip: "body-cap" };
2920
+ }
2580
2921
  }
2581
2922
  } catch {
2582
- return "";
2583
- }
2584
- try {
2585
- return new TextDecoder().decode(concatChunks(chunks, buffered));
2586
- } catch {
2587
- return "";
2923
+ void reader.cancel().catch(() => {
2924
+ });
2925
+ return { text: "", skip: "read-failed" };
2926
+ } finally {
2927
+ reader.releaseLock();
2588
2928
  }
2929
+ return { text: decodeChunks(chunks, buffered), skip: null };
2589
2930
  }
2931
+ return { text: "", skip: "unreadable-body" };
2932
+ }
2933
+ function decodeChunks(chunks, total) {
2590
2934
  try {
2591
- const text = await clone.text();
2592
- return text.length > max ? text.slice(0, max) : text;
2935
+ return new TextDecoder().decode(concatChunks(chunks, total));
2593
2936
  } catch {
2594
2937
  return "";
2595
2938
  }
@@ -2616,7 +2959,7 @@ function parseBody(rawBody, contentType) {
2616
2959
  }
2617
2960
  if (isForm) {
2618
2961
  const body = {};
2619
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
2962
+ for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
2620
2963
  return { body, files: void 0 };
2621
2964
  }
2622
2965
  if (isJson || isAmbiguous) {
@@ -2644,9 +2987,9 @@ function parseMultipart(rawBody, boundary) {
2644
2987
  if (filename !== void 0) {
2645
2988
  const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2646
2989
  const file = { filename, type: partType, content };
2647
- files[name] = name in files ? [].concat(files[name], file) : file;
2990
+ appendOwn(files, name, file);
2648
2991
  } else {
2649
- body[name] = name in body ? [].concat(body[name], content) : content;
2992
+ appendOwn(body, name, content);
2650
2993
  }
2651
2994
  }
2652
2995
  return { body, files };
@@ -2661,7 +3004,7 @@ function parseCookies(header) {
2661
3004
  if (idx === -1) {
2662
3005
  continue;
2663
3006
  }
2664
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3007
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2665
3008
  }
2666
3009
  return cookies;
2667
3010
  }
@@ -2671,7 +3014,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2671
3014
  const method = (req.method || "GET").toUpperCase();
2672
3015
  const headers = {};
2673
3016
  for (const [key, value] of Object.entries(req.headers || {})) {
2674
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
3017
+ setOwn(headers, key.toLowerCase(), Array.isArray(value) ? value.join(", ") : value);
2675
3018
  }
2676
3019
  const host = headers.host || "localhost";
2677
3020
  let url;
@@ -2686,11 +3029,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2686
3029
  }
2687
3030
  const query = {};
2688
3031
  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
- }
3032
+ appendOwn(query, key, value);
2694
3033
  }
2695
3034
  const contentType = headers["content-type"] || "";
2696
3035
  let body = {};
@@ -2731,7 +3070,7 @@ function parseCookies2(header) {
2731
3070
  if (idx === -1) {
2732
3071
  continue;
2733
3072
  }
2734
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3073
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2735
3074
  }
2736
3075
  return cookies;
2737
3076
  }
@@ -2787,6 +3126,67 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2787
3126
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2788
3127
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
2789
3128
  const MAX_REDIRECTS = 20;
3129
+ const MAX_REPLAY_BODY_BYTES = 1024 * 1024;
3130
+ const captureReplayBody = (request) => {
3131
+ if (request.method === "GET" || request.method === "HEAD" || !request.body) {
3132
+ return { result: Promise.resolve({ body: void 0, tooLarge: false }), cancel() {
3133
+ } };
3134
+ }
3135
+ let reader;
3136
+ try {
3137
+ reader = request.clone().body?.getReader();
3138
+ } catch {
3139
+ reader = null;
3140
+ }
3141
+ if (!reader) {
3142
+ return { result: Promise.resolve({ body: void 0, tooLarge: true }), cancel() {
3143
+ } };
3144
+ }
3145
+ let cancelled = false;
3146
+ const result = (async () => {
3147
+ const chunks = [];
3148
+ let total = 0;
3149
+ try {
3150
+ for (; ; ) {
3151
+ const { done, value } = await reader.read();
3152
+ if (done || cancelled) break;
3153
+ total += value.byteLength;
3154
+ if (total > MAX_REPLAY_BODY_BYTES) {
3155
+ void reader.cancel().catch(() => {
3156
+ });
3157
+ return { body: void 0, tooLarge: true };
3158
+ }
3159
+ chunks.push(value);
3160
+ }
3161
+ if (cancelled) return { body: void 0, tooLarge: false };
3162
+ const body = new Uint8Array(total);
3163
+ let offset = 0;
3164
+ for (const chunk of chunks) {
3165
+ body.set(chunk, offset);
3166
+ offset += chunk.byteLength;
3167
+ }
3168
+ return { body, tooLarge: false };
3169
+ } catch {
3170
+ return { body: void 0, tooLarge: true };
3171
+ } finally {
3172
+ reader.releaseLock();
3173
+ }
3174
+ })();
3175
+ return {
3176
+ result,
3177
+ cancel() {
3178
+ cancelled = true;
3179
+ void reader.cancel().catch(() => {
3180
+ });
3181
+ }
3182
+ };
3183
+ };
3184
+ const discardResponseBody = async (response) => {
3185
+ try {
3186
+ await response?.body?.cancel?.();
3187
+ } catch {
3188
+ }
3189
+ };
2790
3190
  const screenUrl = async (u, method) => {
2791
3191
  let host = null;
2792
3192
  try {
@@ -2812,20 +3212,43 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2812
3212
  if (callerRedirect !== "follow") return originalFetch(input, init);
2813
3213
  const headers = new Headers(cur.headers);
2814
3214
  const signal = cur.signal;
2815
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
3215
+ const replay = captureReplayBody(cur);
3216
+ let body;
2816
3217
  for (let hop = 0; ; hop++) {
2817
- const resp = await originalFetch(
2818
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
2819
- );
3218
+ let resp;
3219
+ try {
3220
+ resp = await originalFetch(
3221
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3222
+ );
3223
+ } catch (error) {
3224
+ replay.cancel();
3225
+ throw error;
3226
+ }
2820
3227
  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");
3228
+ if (!location) {
3229
+ replay.cancel();
3230
+ return resp;
3231
+ }
3232
+ await discardResponseBody(resp);
3233
+ if (hop >= MAX_REDIRECTS) {
3234
+ replay.cancel();
3235
+ throw new Error("Patchstack blocked an outbound request: too many redirects");
3236
+ }
2823
3237
  const next = new URL(location, url).href;
2824
3238
  if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
2825
3239
  method = "GET";
2826
3240
  body = void 0;
3241
+ replay.cancel();
2827
3242
  headers.delete("content-type");
2828
3243
  headers.delete("content-length");
3244
+ } else if (body === void 0 && method !== "GET" && method !== "HEAD") {
3245
+ const captured = await replay.result;
3246
+ if (captured.tooLarge) {
3247
+ throw new Error(
3248
+ `Patchstack blocked a redirect that required replaying more than ${MAX_REPLAY_BODY_BYTES} bytes`
3249
+ );
3250
+ }
3251
+ body = captured.body;
2829
3252
  }
2830
3253
  if (new URL(next).origin !== new URL(url).origin) {
2831
3254
  headers.delete("authorization");
@@ -3136,7 +3559,7 @@ var DEFAULT_RESPONSE_RULES = [
3136
3559
  //
3137
3560
  // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
3138
3561
  // 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+\\)/" } }]
3562
+ 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
3563
  },
3141
3564
  {
3142
3565
  id: "resp-sql-error",
@@ -3269,9 +3692,18 @@ async function loadFs() {
3269
3692
  try {
3270
3693
  const [fs, path] = await Promise.all([import("fs"), import("path")]);
3271
3694
  fsMod = {
3695
+ chmodSync: fs.chmodSync,
3696
+ closeSync: fs.closeSync,
3697
+ constants: fs.constants,
3698
+ fstatSync: fs.fstatSync,
3699
+ fsyncSync: fs.fsyncSync,
3700
+ lstatSync: fs.lstatSync,
3701
+ openSync: fs.openSync,
3272
3702
  readFileSync: fs.readFileSync,
3273
- writeFileSync: fs.writeFileSync,
3274
3703
  mkdirSync: fs.mkdirSync,
3704
+ renameSync: fs.renameSync,
3705
+ unlinkSync: fs.unlinkSync,
3706
+ writeFileSync: fs.writeFileSync,
3275
3707
  join: path.join
3276
3708
  };
3277
3709
  } catch {
@@ -3324,22 +3756,84 @@ async function cacheWrite(dir, env) {
3324
3756
  if (!dir) return;
3325
3757
  const fs = await loadFs();
3326
3758
  if (!fs) return;
3759
+ const target = fs.join(dir, "patchstack-rules.json");
3760
+ const temporary = fs.join(dir, `.patchstack-rules-${randomSuffix()}.tmp`);
3761
+ let descriptor = null;
3327
3762
  try {
3328
3763
  fs.mkdirSync(dir, { recursive: true });
3329
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
3764
+ const directory = fs.lstatSync(dir);
3765
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return;
3766
+ let mode;
3767
+ try {
3768
+ const current = fs.lstatSync(target);
3769
+ if (current.isSymbolicLink() || !current.isFile()) return;
3770
+ mode = current.mode & 4095;
3771
+ } catch (error) {
3772
+ if (error?.code !== "ENOENT") return;
3773
+ }
3774
+ descriptor = fs.openSync(temporary, "wx", mode ?? 384);
3775
+ fs.writeFileSync(descriptor, JSON.stringify(env), "utf8");
3776
+ fs.fsyncSync(descriptor);
3777
+ fs.closeSync(descriptor);
3778
+ descriptor = null;
3779
+ if (mode !== void 0) fs.chmodSync(temporary, mode);
3780
+ try {
3781
+ fs.renameSync(temporary, target);
3782
+ } catch (error) {
3783
+ if (typeof process === "undefined" || process.platform !== "win32" || error?.code !== "EEXIST" && error?.code !== "EPERM") throw error;
3784
+ try {
3785
+ fs.unlinkSync(target);
3786
+ } catch (unlinkError) {
3787
+ if (unlinkError?.code !== "ENOENT") throw unlinkError;
3788
+ }
3789
+ fs.renameSync(temporary, target);
3790
+ }
3330
3791
  } catch {
3792
+ } finally {
3793
+ if (descriptor !== null) {
3794
+ try {
3795
+ fs.closeSync(descriptor);
3796
+ } catch {
3797
+ }
3798
+ }
3799
+ try {
3800
+ fs.unlinkSync(temporary);
3801
+ } catch {
3802
+ }
3331
3803
  }
3332
3804
  }
3333
3805
  async function cacheRead(dir) {
3334
3806
  if (!dir) return null;
3335
3807
  const fs = await loadFs();
3336
3808
  if (!fs) return null;
3809
+ const target = fs.join(dir, "patchstack-rules.json");
3810
+ let descriptor = null;
3337
3811
  try {
3338
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
3812
+ const entry = fs.lstatSync(target);
3813
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 16 * 1024 * 1024) return null;
3814
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
3815
+ descriptor = fs.openSync(target, flags);
3816
+ const opened = fs.fstatSync(descriptor);
3817
+ if (!opened.isFile() || opened.size > 16 * 1024 * 1024) return null;
3818
+ return toEnvelope(JSON.parse(fs.readFileSync(descriptor, "utf8")));
3339
3819
  } catch {
3340
3820
  return null;
3821
+ } finally {
3822
+ if (descriptor !== null) {
3823
+ try {
3824
+ fs.closeSync(descriptor);
3825
+ } catch {
3826
+ }
3827
+ }
3341
3828
  }
3342
3829
  }
3830
+ function randomSuffix() {
3831
+ try {
3832
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
3833
+ } catch {
3834
+ }
3835
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3836
+ }
3343
3837
  function toEnvelope(value) {
3344
3838
  if (!value || typeof value !== "object") return null;
3345
3839
  if (value.bundle && typeof value.bundle === "object") {
@@ -3364,7 +3858,11 @@ function toEnvelope(value) {
3364
3858
  function liveUpdateRejections(res, options) {
3365
3859
  if (options.acceptPartialBundle) return [];
3366
3860
  const { rejected } = validateBundle(
3367
- { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
3861
+ {
3862
+ firewall: Array.isArray(res.firewall) ? res.firewall : [],
3863
+ whitelists: Array.isArray(res.whitelists) ? res.whitelists : [],
3864
+ whitelist_keys: res.whitelist_keys
3865
+ },
3368
3866
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3369
3867
  );
3370
3868
  return rejected;
@@ -3535,7 +4033,8 @@ function normalizeBundle(b, options = {}) {
3535
4033
  const { bundle: checked, rejected } = validateBundle(
3536
4034
  {
3537
4035
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
3538
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
4036
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
4037
+ whitelist_keys: b.whitelist_keys
3539
4038
  },
3540
4039
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3541
4040
  );
@@ -3555,7 +4054,7 @@ function normalizeBundle(b, options = {}) {
3555
4054
  return {
3556
4055
  firewall: checked.firewall,
3557
4056
  whitelists: checked.whitelists,
3558
- whitelist_keys: b.whitelist_keys ?? {},
4057
+ whitelist_keys: checked.whitelist_keys,
3559
4058
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
3560
4059
  };
3561
4060
  }
@@ -3599,18 +4098,23 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3599
4098
  };
3600
4099
  }
3601
4100
  function makeRefreshHandler(tick, secret) {
4101
+ let inflight = null;
4102
+ const runOnce = () => {
4103
+ if (inflight) return inflight;
4104
+ let current;
4105
+ current = Promise.resolve().then(() => tick()).finally(() => {
4106
+ if (inflight === current) inflight = null;
4107
+ });
4108
+ inflight = current;
4109
+ return current;
4110
+ };
3602
4111
  return async (request) => {
3603
4112
  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
- }
4113
+ const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
3610
4114
  if (provided !== secret) return new Response("forbidden", { status: 403 });
3611
4115
  let refreshed = true;
3612
4116
  try {
3613
- const status = await tick();
4117
+ const status = await runOnce();
3614
4118
  if (status && status.ok === false) refreshed = false;
3615
4119
  } catch {
3616
4120
  refreshed = false;
@@ -4359,8 +4863,10 @@ function hardensWithoutBody(rule) {
4359
4863
  // src/protect/firewall-log.js
4360
4864
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4361
4865
  var STOP_BUDGET_MS2 = 5e3;
4866
+ var ATTEMPT_TIMEOUT_MS2 = 1e4;
4362
4867
  var DEFAULT_FLUSH_MS2 = 1e3;
4363
4868
  var MAX_BATCH2 = 50;
4869
+ var MAX_QUEUE2 = 500;
4364
4870
  var TOKEN_SKEW_MS = 6e4;
4365
4871
  function parseApiKey(apiKey) {
4366
4872
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -4382,14 +4888,16 @@ function resolveApiBase(pulseOrManifestUrl) {
4382
4888
  }
4383
4889
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
4384
4890
  try {
4385
- return new URL(pulseOrManifestUrl).origin;
4891
+ const candidate = new URL(pulseOrManifestUrl).origin;
4892
+ if (isSafeOrigin(candidate)) return candidate;
4386
4893
  } catch {
4387
4894
  }
4388
4895
  }
4389
4896
  const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
4390
4897
  if (typeof endpoint === "string" && endpoint.length > 0) {
4391
4898
  try {
4392
- return new URL(endpoint).origin;
4899
+ const candidate = new URL(endpoint).origin;
4900
+ if (isSafeOrigin(candidate)) return candidate;
4393
4901
  } catch {
4394
4902
  }
4395
4903
  }
@@ -4401,16 +4909,16 @@ function createFirewallLogReporter(opts) {
4401
4909
  return { record() {
4402
4910
  }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
4403
4911
  }
4404
- const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
4912
+ const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
4405
4913
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
4406
4914
  const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
4407
4915
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
4408
4916
  let queue = [];
4409
4917
  let timer = null;
4410
4918
  let stopped = false;
4411
- const outstanding = /* @__PURE__ */ new Set();
4919
+ let inFlight = null;
4412
4920
  let drainPromise = null;
4413
- const lifetime = typeof AbortController === "function" ? new AbortController() : null;
4921
+ let activeController = null;
4414
4922
  let ended = false;
4415
4923
  let cachedToken = null;
4416
4924
  let tokenInflight = null;
@@ -4436,9 +4944,9 @@ function createFirewallLogReporter(opts) {
4436
4944
  })
4437
4945
  });
4438
4946
  if (!res || !res.ok) return null;
4439
- const body = await res.json();
4947
+ const body = await readBoundedJson(res);
4440
4948
  const token = body?.access_token;
4441
- if (typeof token !== "string" || token.length === 0) return null;
4949
+ if (typeof token !== "string" || token.length === 0 || token.length > 8192 || /[\u0000-\u0020\u007f-\u009f]/.test(token)) return null;
4442
4950
  const expiresIn = Number(body?.expires_in);
4443
4951
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
4444
4952
  cachedToken = { token, expiresAt: Date.now() + ttlMs };
@@ -4456,12 +4964,18 @@ function createFirewallLogReporter(opts) {
4456
4964
  clearTimeout(timer);
4457
4965
  timer = null;
4458
4966
  }
4459
- if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
4967
+ if (ended || typeof fetchImpl !== "function") return Promise.resolve();
4968
+ if (inFlight) return inFlight;
4969
+ if (queue.length === 0) return Promise.resolve();
4460
4970
  const batch = queue.splice(0, MAX_BATCH2);
4971
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
4972
+ activeController = controller;
4973
+ const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
4974
+ attemptTimer?.unref?.();
4461
4975
  let entry;
4462
- const send = (async () => {
4976
+ entry = (async () => {
4463
4977
  try {
4464
- const token = await fetchAccessToken(lifetime?.signal);
4978
+ const token = await fetchAccessToken(controller?.signal);
4465
4979
  if (!token || ended) return;
4466
4980
  const body = new URLSearchParams();
4467
4981
  body.set("type", "firewall");
@@ -4476,19 +4990,24 @@ function createFirewallLogReporter(opts) {
4476
4990
  ...sourceHost ? { "Source-Host": sourceHost } : {}
4477
4991
  },
4478
4992
  body,
4479
- // Both phases carry it, so a shutdown that runs out of time can end either one.
4480
- ...lifetime ? { signal: lifetime.signal } : {}
4993
+ // Both phases carry the attempt controller, so slow transports cannot accumulate work.
4994
+ ...controller ? { signal: controller.signal } : {}
4481
4995
  });
4482
4996
  if (p && typeof p.then === "function") await p.catch(() => {
4483
4997
  });
4484
4998
  } catch {
4485
4999
  } finally {
4486
- outstanding.delete(entry);
5000
+ clearTimeout(attemptTimer);
5001
+ if (activeController === controller) activeController = null;
5002
+ if (inFlight === entry) inFlight = null;
5003
+ if (!stopped && !ended && queue.length > 0 && !timer) {
5004
+ timer = setTimeout(flush, queue.length >= MAX_BATCH2 ? 0 : flushMs);
5005
+ timer?.unref?.();
5006
+ }
4487
5007
  }
4488
5008
  })();
4489
- entry = send;
4490
- outstanding.add(send);
4491
- return send;
5009
+ inFlight = entry;
5010
+ return entry;
4492
5011
  };
4493
5012
  return {
4494
5013
  /**
@@ -4504,6 +5023,7 @@ function createFirewallLogReporter(opts) {
4504
5023
  if (stopped) return;
4505
5024
  const fid = event?.rule?.id;
4506
5025
  if (fid === void 0 || fid === null || fid === "") return;
5026
+ if (queue.length >= MAX_QUEUE2) return;
4507
5027
  queue.push({
4508
5028
  fid,
4509
5029
  method: event.method ?? null,
@@ -4535,9 +5055,10 @@ function createFirewallLogReporter(opts) {
4535
5055
  }
4536
5056
  const terminate = () => {
4537
5057
  ended = true;
4538
- lifetime?.abort();
5058
+ activeController?.abort();
5059
+ activeController = null;
4539
5060
  queue = [];
4540
- outstanding.clear();
5061
+ inFlight = null;
4541
5062
  };
4542
5063
  let budget = null;
4543
5064
  const spent = new Promise((resolve) => {
@@ -4549,8 +5070,8 @@ function createFirewallLogReporter(opts) {
4549
5070
  });
4550
5071
  const work = (async () => {
4551
5072
  while (!ended) {
4552
- if (outstanding.size > 0) {
4553
- await Promise.all([...outstanding]);
5073
+ if (inFlight) {
5074
+ await inFlight;
4554
5075
  continue;
4555
5076
  }
4556
5077
  if (queue.length === 0) break;
@@ -4574,10 +5095,107 @@ function telemetryEnabled() {
4574
5095
 
4575
5096
  // src/protect/supabase-guard.js
4576
5097
  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 }) {
5098
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5099
+ var DEFAULT_TIMEOUT_MS = 3e4;
5100
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5101
+ "connection",
5102
+ "cookie",
5103
+ "cookie2",
5104
+ "forwarded",
5105
+ "host",
5106
+ "keep-alive",
5107
+ "proxy-authenticate",
5108
+ "proxy-authorization",
5109
+ "te",
5110
+ "trailer",
5111
+ "transfer-encoding",
5112
+ "upgrade",
5113
+ "x-forwarded-for",
5114
+ "x-forwarded-host",
5115
+ "x-forwarded-port",
5116
+ "x-forwarded-proto",
5117
+ "x-ps-target",
5118
+ "x-real-ip"
5119
+ ]);
5120
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5121
+ "connection",
5122
+ "content-encoding",
5123
+ "content-length",
5124
+ "keep-alive",
5125
+ "proxy-authenticate",
5126
+ "proxy-authorization",
5127
+ "set-cookie",
5128
+ "set-cookie2",
5129
+ "te",
5130
+ "trailer",
5131
+ "transfer-encoding",
5132
+ "upgrade"
5133
+ ]);
5134
+ var BodyTooLargeError = class extends Error {
5135
+ };
5136
+ async function boundedBody(request, maxBytes) {
5137
+ const stated = Number(request.headers.get("content-length"));
5138
+ if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5139
+ if (!request.body) return void 0;
5140
+ const reader = request.body.getReader();
5141
+ const chunks = [];
5142
+ let total = 0;
5143
+ try {
5144
+ for (; ; ) {
5145
+ const { done, value } = await reader.read();
5146
+ if (done) break;
5147
+ total += value.byteLength;
5148
+ if (total > maxBytes) {
5149
+ void reader.cancel().catch(() => {
5150
+ });
5151
+ throw new BodyTooLargeError();
5152
+ }
5153
+ chunks.push(value);
5154
+ }
5155
+ } finally {
5156
+ reader.releaseLock();
5157
+ }
5158
+ const body = new Uint8Array(total);
5159
+ let offset = 0;
5160
+ for (const chunk of chunks) {
5161
+ body.set(chunk, offset);
5162
+ offset += chunk.byteLength;
5163
+ }
5164
+ return body;
5165
+ }
5166
+ function requestHeaders(input) {
5167
+ const output = new Headers();
5168
+ const connectionTokens = new Set(
5169
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5170
+ );
5171
+ input.forEach((value, key) => {
5172
+ const lower = key.toLowerCase();
5173
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5174
+ });
5175
+ return output;
5176
+ }
5177
+ function responseHeaders(input) {
5178
+ const output = new Headers();
5179
+ const connectionTokens = new Set(
5180
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5181
+ );
5182
+ input.forEach((value, key) => {
5183
+ const lower = key.toLowerCase();
5184
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5185
+ });
5186
+ return output;
5187
+ }
5188
+ function createSupabaseGuard({
5189
+ protection,
5190
+ supabaseUrl,
5191
+ fetchImpl = fetch,
5192
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5193
+ timeoutMs = DEFAULT_TIMEOUT_MS
5194
+ }) {
4579
5195
  const guard = protection.fetchGuard();
4580
5196
  const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5197
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5198
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
4581
5199
  return async function handleGuardRequest(request) {
4582
5200
  const target = request.headers.get("x-ps-target");
4583
5201
  if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
@@ -4591,29 +5209,45 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
4591
5209
  return new Response("patchstack: target not allowed", { status: 403 });
4592
5210
  }
4593
5211
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
4594
- const bodyText = hasBody ? await request.text() : "";
5212
+ let body;
5213
+ try {
5214
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5215
+ } catch (error) {
5216
+ if (error instanceof BodyTooLargeError) {
5217
+ return new Response("patchstack: request body too large", { status: 413 });
5218
+ }
5219
+ throw error;
5220
+ }
5221
+ const forwardHeaders = requestHeaders(request.headers);
4595
5222
  const evalReq = new Request(targetUrl.toString(), {
4596
5223
  method: request.method,
5224
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5225
+ // sanitized at the cross-origin boundary below.
4597
5226
  headers: request.headers,
4598
- body: hasBody ? bodyText : void 0
5227
+ body
4599
5228
  });
4600
5229
  const blocked = await guard(evalReq);
4601
5230
  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);
5231
+ let upstream;
5232
+ try {
5233
+ upstream = await fetchImpl(targetUrl.toString(), {
5234
+ method: request.method,
5235
+ headers: forwardHeaders,
5236
+ body,
5237
+ redirect: "manual",
5238
+ signal: AbortSignal.timeout(upstreamTimeout)
5239
+ });
5240
+ } catch (error) {
5241
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5242
+ return new Response("patchstack: upstream timed out", { status: 504 });
5243
+ }
5244
+ throw error;
5245
+ }
5246
+ const forwarded = new Response(upstream.body, {
5247
+ status: upstream.status,
5248
+ statusText: upstream.statusText,
5249
+ headers: responseHeaders(upstream.headers)
4614
5250
  });
4615
- const buf = await upstream.arrayBuffer();
4616
- const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
4617
5251
  return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
4618
5252
  };
4619
5253
  }
@@ -4916,9 +5550,13 @@ async function createProtection(options = {}) {
4916
5550
  for (const name of Object.keys(headers)) {
4917
5551
  const value = headers[name];
4918
5552
  if (typeof value === "string") {
4919
- headers[name] = applyRedactors(value, spanRedactors, mask);
5553
+ setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
4920
5554
  } else if (Array.isArray(value)) {
4921
- headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
5555
+ setOwn(
5556
+ headers,
5557
+ name,
5558
+ value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
5559
+ );
4922
5560
  }
4923
5561
  }
4924
5562
  }
@@ -4968,7 +5606,13 @@ async function createProtection(options = {}) {
4968
5606
  let result;
4969
5607
  let shaped;
4970
5608
  try {
4971
- shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
5609
+ shaped = await fromFetchRequest(request, {
5610
+ trustedProxy: options.trustedProxy,
5611
+ maxBodyBytes: options.maxBodyBytes
5612
+ });
5613
+ if (shaped?._bodyInspectionSkip) {
5614
+ recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
5615
+ }
4972
5616
  result = engine.evaluate(shaped);
4973
5617
  } catch (err) {
4974
5618
  notify(onError, err, "onError");
@@ -5058,7 +5702,7 @@ async function createProtection(options = {}) {
5058
5702
  const out = {};
5059
5703
  for (const [name, value] of writeHeadEntries(headers)) {
5060
5704
  const key = name.toLowerCase();
5061
- out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
5705
+ appendOwn(out, key, value);
5062
5706
  }
5063
5707
  return out;
5064
5708
  };
@@ -5455,7 +6099,7 @@ async function createProtection(options = {}) {
5455
6099
  let reporter = null;
5456
6100
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
5457
6101
  try {
5458
- ({ reportManifest: reporter } = await import("./refresh-manifest-2ALIJGZ3.js"));
6102
+ ({ reportManifest: reporter } = await import("./refresh-manifest-3YWIYENU.js"));
5459
6103
  } catch (err) {
5460
6104
  notify(onError, err, "onError");
5461
6105
  }
@@ -5594,7 +6238,6 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5594
6238
  const reader = body.getReader();
5595
6239
  const chunks = [];
5596
6240
  let size = 0;
5597
- let over = false;
5598
6241
  let sniffed = !sniff;
5599
6242
  try {
5600
6243
  for (; ; ) {
@@ -5603,20 +6246,27 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5603
6246
  if (!value) continue;
5604
6247
  if (!sniffed) {
5605
6248
  sniffed = true;
5606
- if (looksBinary(value)) return { skip: "binary-body" };
6249
+ if (looksBinary(value)) {
6250
+ void reader.cancel().catch(() => {
6251
+ });
6252
+ return { skip: "binary-body" };
6253
+ }
5607
6254
  }
5608
6255
  size += value.byteLength;
5609
- if (over) continue;
5610
- if (size > cap) {
5611
- over = true;
5612
- continue;
6256
+ if (size >= cap) {
6257
+ void reader.cancel().catch(() => {
6258
+ });
6259
+ return { skip: "body-cap" };
5613
6260
  }
5614
6261
  chunks.push(value);
5615
6262
  }
5616
6263
  } catch {
6264
+ void reader.cancel().catch(() => {
6265
+ });
5617
6266
  return { skip: "read-failed" };
6267
+ } finally {
6268
+ reader.releaseLock();
5618
6269
  }
5619
- if (over) return { skip: "body-cap" };
5620
6270
  try {
5621
6271
  return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
5622
6272
  } catch {
@@ -5644,10 +6294,10 @@ function concatBytes(chunks, total) {
5644
6294
  function headerObject(headers) {
5645
6295
  const out = {};
5646
6296
  headers?.forEach?.((v, k) => {
5647
- out[k.toLowerCase()] = v;
6297
+ setOwn(out, k.toLowerCase(), v);
5648
6298
  });
5649
6299
  const setCookies = headers?.getSetCookie?.();
5650
- if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
6300
+ if (setCookies && setCookies.length) setOwn(out, "set-cookie", setCookies);
5651
6301
  return out;
5652
6302
  }
5653
6303
  function hasSpanMutations(rule) {
@@ -5816,7 +6466,7 @@ function isHeaderMutation(action) {
5816
6466
  }
5817
6467
  function applyHeaderMutation(headers, rule) {
5818
6468
  if (rule.action === "remove-header") {
5819
- for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
6469
+ for (const name of rule.remove_headers ?? []) setOwn(headers, String(name).toLowerCase(), null);
5820
6470
  return;
5821
6471
  }
5822
6472
  if (rule.action === "set-header") {
@@ -5825,7 +6475,7 @@ function applyHeaderMutation(headers, rule) {
5825
6475
  const key = String(name).toLowerCase();
5826
6476
  const present = headers[key] != null && headers[key] !== "";
5827
6477
  if (ensure && present) continue;
5828
- headers[key] = String(value);
6478
+ setOwn(headers, key, String(value));
5829
6479
  }
5830
6480
  return;
5831
6481
  }