@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.
@@ -272,6 +272,51 @@ function warnOnce(key, message) {
272
272
  console.warn(message);
273
273
  }
274
274
 
275
+ // src/protect/engine/response-json.js
276
+ var MAX_RULE_RESPONSE_BYTES = 5 * 1024 * 1024;
277
+ async function readBoundedJson(response, maxBytes = MAX_RULE_RESPONSE_BYTES) {
278
+ const stated = Number(response.headers?.get?.("content-length"));
279
+ if (Number.isFinite(stated) && stated > maxBytes) {
280
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
281
+ }
282
+ const stream = response.body;
283
+ if (!stream || typeof stream.getReader !== "function") {
284
+ if (typeof response.text === "function") {
285
+ const text = await response.text();
286
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
287
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
288
+ }
289
+ return JSON.parse(text);
290
+ }
291
+ throw new Error("rule response body cannot be read with a size bound");
292
+ }
293
+ const reader = stream.getReader();
294
+ const chunks = [];
295
+ let total = 0;
296
+ try {
297
+ for (; ; ) {
298
+ const { done, value } = await reader.read();
299
+ if (done) break;
300
+ total += value.byteLength;
301
+ if (total > maxBytes) {
302
+ void reader.cancel().catch(() => {
303
+ });
304
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
305
+ }
306
+ chunks.push(value);
307
+ }
308
+ } finally {
309
+ reader.releaseLock();
310
+ }
311
+ const bytes = new Uint8Array(total);
312
+ let offset = 0;
313
+ for (const chunk of chunks) {
314
+ bytes.set(chunk, offset);
315
+ offset += chunk.byteLength;
316
+ }
317
+ return JSON.parse(new TextDecoder().decode(bytes));
318
+ }
319
+
275
320
  // src/protect/engine/client.js
276
321
  var DEFAULT_BASE_URL = "https://api.patchstack.com";
277
322
  var DEFAULT_CACHE_TTL = 3e5;
@@ -328,7 +373,7 @@ var PatchstackRuleClient = class {
328
373
  whitelist_keys: {}
329
374
  };
330
375
  }
331
- const data = await response.json();
376
+ const data = await readBoundedJson(response);
332
377
  if (!data || !Array.isArray(data.firewall)) {
333
378
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
334
379
  }
@@ -365,6 +410,22 @@ var PatchstackRuleClient = class {
365
410
  }
366
411
  };
367
412
 
413
+ // src/protect/engine/own.js
414
+ function setOwn(target, key, value) {
415
+ Object.defineProperty(target, key, {
416
+ configurable: true,
417
+ enumerable: true,
418
+ writable: true,
419
+ value
420
+ });
421
+ return value;
422
+ }
423
+ function appendOwn(target, key, value) {
424
+ if (!Object.hasOwn(target, key)) return setOwn(target, key, value);
425
+ const previous = target[key];
426
+ return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
427
+ }
428
+
368
429
  // src/protect/engine/request.js
369
430
  var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
370
431
  var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
@@ -483,8 +544,8 @@ var RequestResolver = class {
483
544
  }
484
545
  if (key.startsWith("header.")) {
485
546
  const name = key.slice("header.".length).toLowerCase();
486
- const value = (resp.headers ?? {})[name];
487
- return value !== void 0 ? [value] : [];
547
+ const headers = resp.headers ?? {};
548
+ return Object.hasOwn(headers, name) ? [headers[name]] : [];
488
549
  }
489
550
  return [];
490
551
  }
@@ -579,7 +640,7 @@ var RequestResolver = class {
579
640
  ...this.#resolveWildcard(cookies, key)
580
641
  ];
581
642
  }
582
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? cookies[key];
643
+ const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
583
644
  return value !== void 0 ? [value] : [];
584
645
  }
585
646
  #resolveCookie(key) {
@@ -587,8 +648,7 @@ var RequestResolver = class {
587
648
  if (key.endsWith("*")) {
588
649
  return this.#resolveWildcard(cookies, key);
589
650
  }
590
- const value = cookies[key];
591
- return value !== void 0 ? [value] : [];
651
+ return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
592
652
  }
593
653
  #resolveServer(key) {
594
654
  const req = this.#req;
@@ -764,7 +824,7 @@ var RequestResolver = class {
764
824
  }
765
825
  const name = pair.substring(0, eqIndex).trim();
766
826
  const value = pair.substring(eqIndex + 1).trim();
767
- cookies[name] = value;
827
+ setOwn(cookies, name, value);
768
828
  }
769
829
  this.#cookies = cookies;
770
830
  return this.#cookies;
@@ -921,30 +981,113 @@ function normalizeWhitespace(value) {
921
981
  result = result.replace(/ {2,}/g, " ");
922
982
  return result;
923
983
  }
924
- function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isRoot = true) {
925
- if (body === null || body === void 0) {
926
- return isRoot ? "" : "null";
927
- }
928
- if (typeof body === "string") {
929
- return isRoot ? body : JSON.stringify(body);
930
- }
931
- if (typeof body !== "object") {
932
- return String(body);
933
- }
934
- if (visited.has(body)) {
935
- return "[Circular]";
984
+ var MAX_RAW_CHARS = 1024 * 1024;
985
+ var MAX_RAW_NODES = 25e3;
986
+ var MAX_RAW_DEPTH = 200;
987
+ var MAX_RAW_ENTRIES = 2e3;
988
+ function serializeForRawDetection(body) {
989
+ if (body === null || body === void 0) return "";
990
+ if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
991
+ const visited = /* @__PURE__ */ new Set();
992
+ const output = [];
993
+ const stack = [{ kind: "value", value: body, depth: 0, root: true }];
994
+ let chars = 0;
995
+ let nodes = 0;
996
+ const append = (text) => {
997
+ if (chars >= MAX_RAW_CHARS) return;
998
+ const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
999
+ output.push(piece);
1000
+ chars += piece.length;
1001
+ };
1002
+ while (stack.length > 0 && chars < MAX_RAW_CHARS) {
1003
+ const task = stack.pop();
1004
+ if (task.kind === "text") {
1005
+ append(task.text);
1006
+ continue;
1007
+ }
1008
+ if (task.kind === "key") {
1009
+ append(boundedJsonString(task.value));
1010
+ append(":");
1011
+ continue;
1012
+ }
1013
+ nodes++;
1014
+ if (nodes > MAX_RAW_NODES) {
1015
+ append('"[NodeLimit]"');
1016
+ continue;
1017
+ }
1018
+ const value = task.value;
1019
+ if (value === null || value === void 0) {
1020
+ append(task.root ? "" : "null");
1021
+ continue;
1022
+ }
1023
+ if (typeof value === "string") {
1024
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
1025
+ continue;
1026
+ }
1027
+ if (typeof value !== "object") {
1028
+ append(String(value).slice(0, MAX_RAW_CHARS));
1029
+ continue;
1030
+ }
1031
+ if (visited.has(value)) {
1032
+ append("[Circular]");
1033
+ continue;
1034
+ }
1035
+ if (task.depth >= MAX_RAW_DEPTH) {
1036
+ append('"[DepthLimit]"');
1037
+ continue;
1038
+ }
1039
+ visited.add(value);
1040
+ if (Array.isArray(value)) {
1041
+ const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
1042
+ const items = [];
1043
+ for (let i = 0; i < length2; i++) {
1044
+ if (i > 0) items.push({ kind: "text", text: "," });
1045
+ items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
1046
+ }
1047
+ if (value.length > length2) {
1048
+ if (length2 > 0) items.push({ kind: "text", text: "," });
1049
+ items.push({ kind: "text", text: '"[EntryLimit]"' });
1050
+ }
1051
+ stack.push({ kind: "text", text: "]" });
1052
+ for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
1053
+ stack.push({ kind: "text", text: "[" });
1054
+ continue;
1055
+ }
1056
+ let keys;
1057
+ try {
1058
+ keys = Object.getOwnPropertyNames(value);
1059
+ } catch {
1060
+ append('"[Unreadable]"');
1061
+ continue;
1062
+ }
1063
+ const length = Math.min(keys.length, MAX_RAW_ENTRIES);
1064
+ const entries = [];
1065
+ for (let i = 0; i < length; i++) {
1066
+ const key = keys[i];
1067
+ if (i > 0) entries.push({ kind: "text", text: "," });
1068
+ entries.push({ kind: "key", value: key });
1069
+ entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
1070
+ }
1071
+ if (keys.length > length) {
1072
+ if (length > 0) entries.push({ kind: "text", text: "," });
1073
+ entries.push({ kind: "text", text: '"[EntryLimit]":null' });
1074
+ }
1075
+ stack.push({ kind: "text", text: "}" });
1076
+ for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
1077
+ stack.push({ kind: "text", text: "{" });
936
1078
  }
937
- visited.add(body);
938
- if (Array.isArray(body)) {
939
- const items = body.map((item) => serializeForRawDetection(item, visited, false));
940
- return "[" + items.join(",") + "]";
1079
+ return output.join("");
1080
+ }
1081
+ function boundedJsonString(value) {
1082
+ return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
1083
+ }
1084
+ function readOwnValue(value, key) {
1085
+ try {
1086
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
1087
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
1088
+ } catch {
1089
+ return "[Unreadable]";
941
1090
  }
942
- const keys = Object.getOwnPropertyNames(body);
943
- const parts = keys.map((key) => {
944
- const val = serializeForRawDetection(body[key], visited, false);
945
- return JSON.stringify(key) + ":" + val;
946
- });
947
- return "{" + parts.join(",") + "}";
948
1091
  }
949
1092
  var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
950
1093
  function requestField(req, key) {
@@ -986,7 +1129,7 @@ function normalizeObject(value, options = {}, depth = 0) {
986
1129
  if (typeof value === "object" && value !== null) {
987
1130
  const result = {};
988
1131
  for (const [key, val] of Object.entries(value)) {
989
- result[key] = normalizeObject(val, options, depth + 1);
1132
+ setOwn(result, key, normalizeObject(val, options, depth + 1));
990
1133
  }
991
1134
  return result;
992
1135
  }
@@ -1003,6 +1146,111 @@ var REDOS_PATTERNS = [
1003
1146
  new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
1004
1147
  // nested alternation under a quantifier
1005
1148
  ];
1149
+ function hasAdjacentUnbounded(body, flags) {
1150
+ let previousUnboundedAtom = null;
1151
+ for (let i = 0; i < body.length; ) {
1152
+ const start = i;
1153
+ const ch = body[i];
1154
+ let assertion = false;
1155
+ let literal = null;
1156
+ let atom = ch;
1157
+ if (ch === "\\") {
1158
+ const escaped = body[i + 1] ?? "";
1159
+ assertion = /[bBAZz]/.test(escaped);
1160
+ if (!/[bBAZzdswDSWnrtvf0-9pPkKxuc]/.test(escaped)) literal = escaped;
1161
+ i += Math.min(2, body.length - i);
1162
+ atom = body.slice(start, i);
1163
+ } else if (ch === "[") {
1164
+ i++;
1165
+ while (i < body.length) {
1166
+ if (body[i] === "\\") i += 2;
1167
+ else if (body[i++] === "]") break;
1168
+ }
1169
+ atom = body.slice(start, i);
1170
+ } else if (ch === ")") {
1171
+ i++;
1172
+ atom = ".";
1173
+ } else if (ch === "(") {
1174
+ const lookaround = body.startsWith("(?=", i) || body.startsWith("(?!", i) || body.startsWith("(?<=", i) || body.startsWith("(?<!", i);
1175
+ if (lookaround) {
1176
+ i = groupEnd(body, i);
1177
+ continue;
1178
+ }
1179
+ i++;
1180
+ if (body.startsWith("?:", i)) i += 2;
1181
+ else if (body.startsWith("?<", i)) {
1182
+ const nameEnd = body.indexOf(">", i + 2);
1183
+ if (nameEnd !== -1) i = nameEnd + 1;
1184
+ }
1185
+ continue;
1186
+ } else if (ch === "|") {
1187
+ previousUnboundedAtom = null;
1188
+ i++;
1189
+ continue;
1190
+ } else if ("^$".includes(ch)) {
1191
+ i++;
1192
+ continue;
1193
+ } else if ("?*+{}".includes(ch)) {
1194
+ previousUnboundedAtom = null;
1195
+ i++;
1196
+ continue;
1197
+ } else {
1198
+ if (ch !== ".") literal = ch;
1199
+ i++;
1200
+ }
1201
+ if (assertion) continue;
1202
+ let unbounded = false;
1203
+ let mayBeEmpty = false;
1204
+ if (body[i] === "*" || body[i] === "+") {
1205
+ unbounded = true;
1206
+ mayBeEmpty = body[i] === "*";
1207
+ i++;
1208
+ } else if (body[i] === "{") {
1209
+ const quantifier = /^\{(\d+)(?:,(\d*))?\}/.exec(body.slice(i));
1210
+ if (quantifier) {
1211
+ unbounded = quantifier[2] === "";
1212
+ mayBeEmpty = Number(quantifier[1]) === 0;
1213
+ i += quantifier[0].length;
1214
+ }
1215
+ } else if (body[i] === "?") {
1216
+ mayBeEmpty = true;
1217
+ i++;
1218
+ }
1219
+ if (unbounded && (body[i] === "?" || body[i] === "+")) i++;
1220
+ if (unbounded && previousUnboundedAtom !== null) return true;
1221
+ if (unbounded) {
1222
+ previousUnboundedAtom = atom;
1223
+ } else if (previousUnboundedAtom !== null && !mayBeEmpty) {
1224
+ try {
1225
+ const atomFlags = flags.replace(/[^iu]/g, "");
1226
+ 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";
1227
+ const excludedLiteral = literal !== null && !new RegExp(`^(?:${previousUnboundedAtom})$`, atomFlags).test(literal);
1228
+ if (complementaryClass || excludedLiteral) {
1229
+ previousUnboundedAtom = null;
1230
+ }
1231
+ } catch {
1232
+ }
1233
+ }
1234
+ if (i === start) i++;
1235
+ }
1236
+ return false;
1237
+ }
1238
+ function groupEnd(body, start) {
1239
+ let depth = 0;
1240
+ let inClass = false;
1241
+ for (let i = start; i < body.length; i++) {
1242
+ const ch = body[i];
1243
+ if (ch === "\\") {
1244
+ i++;
1245
+ continue;
1246
+ }
1247
+ if (ch === "[") inClass = true;
1248
+ else if (ch === "]") inClass = false;
1249
+ else if (!inClass && ch === "(") depth++;
1250
+ else if (!inClass && ch === ")" && --depth === 0) return i + 1;
1251
+ }
1252
+ return body.length;
1253
+ }
1006
1254
  var warnedRejectedPatterns = /* @__PURE__ */ new Set();
1007
1255
  function warnRejectedPatternOnce(pattern) {
1008
1256
  const key = String(pattern);
@@ -1020,15 +1268,14 @@ function safeRegExp(pattern) {
1020
1268
  if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
1021
1269
  return null;
1022
1270
  }
1023
- for (const dangerous of REDOS_PATTERNS) {
1024
- if (dangerous.test(pattern)) {
1025
- return null;
1026
- }
1027
- }
1028
1271
  const match = pattern.match(/^\/(.+?)\/([gimsuy]*)$/s);
1029
1272
  if (!match) {
1030
1273
  return null;
1031
1274
  }
1275
+ for (const dangerous of REDOS_PATTERNS) {
1276
+ if (dangerous.test(match[1])) return null;
1277
+ }
1278
+ if (hasAdjacentUnbounded(match[1], match[2])) return null;
1032
1279
  try {
1033
1280
  return new RegExp(match[1], match[2]);
1034
1281
  } catch {
@@ -1772,6 +2019,16 @@ function operandShapeProblem(shape, value) {
1772
2019
  const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
1773
2020
  const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
1774
2021
  const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2022
+ const stringLimit = (v) => typeof v === "string" && v.length > LIMITS.maxValueLength ? `must not be longer than ${LIMITS.maxValueLength} chars` : null;
2023
+ const listLimit = (v) => Array.isArray(v) && v.length > LIMITS.maxOperandItems ? `must not have more than ${LIMITS.maxOperandItems} entries` : null;
2024
+ const tooLong = stringLimit(value);
2025
+ if (tooLong) return tooLong;
2026
+ const tooMany = listLimit(value);
2027
+ if (tooMany) return tooMany;
2028
+ if (Array.isArray(value)) {
2029
+ const longMember = value.find((member) => stringLimit(member) !== null);
2030
+ if (longMember !== void 0) return `contains a string longer than ${LIMITS.maxValueLength} chars`;
2031
+ }
1775
2032
  switch (shape) {
1776
2033
  case "positive-number":
1777
2034
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
@@ -1802,8 +2059,11 @@ function operandShapeProblem(shape, value) {
1802
2059
  if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
1803
2060
  const names = Object.keys(value);
1804
2061
  if (names.length === 0) return "must be an object with at least one entry";
2062
+ if (names.length > LIMITS.maxMapEntries) return `must not have more than ${LIMITS.maxMapEntries} entries`;
1805
2063
  const bad = names.find((name) => !isHeaderName(name));
1806
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2064
+ if (bad !== void 0) return `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2065
+ const badValue = Object.values(value).find((entry) => typeof entry !== "string" || entry.length > LIMITS.maxValueLength);
2066
+ return badValue === void 0 ? null : `values must be strings no longer than ${LIMITS.maxValueLength} chars`;
1807
2067
  }
1808
2068
  case "header-name-list": {
1809
2069
  if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
@@ -1949,9 +2209,16 @@ var LIMITS = Object.freeze({
1949
2209
  maxRules: 5e3,
1950
2210
  maxWhitelists: 2e3,
1951
2211
  maxConditionsPerRule: 250,
2212
+ maxConditionNodesPerRule: 1e3,
2213
+ maxConditionNodesPerBundle: 25e3,
1952
2214
  maxNestingDepth: 12,
1953
2215
  maxRegexLength: 1e3,
1954
- maxValueLength: 8192
2216
+ maxValueLength: 8192,
2217
+ maxParameterLength: 512,
2218
+ maxParameterItems: 64,
2219
+ maxOperandItems: 256,
2220
+ maxMapEntries: 128,
2221
+ maxPathSegments: 64
1955
2222
  });
1956
2223
  var GROUP_PARAMETER = "rules";
1957
2224
  function isGroup(condition) {
@@ -1978,6 +2245,9 @@ function parameterProblem(parameter) {
1978
2245
  if (parameter === void 0 || parameter === null) return null;
1979
2246
  if (Array.isArray(parameter)) {
1980
2247
  if (parameter.length === 0) return "parameter list is empty";
2248
+ if (parameter.length > LIMITS.maxParameterItems) {
2249
+ return `parameter list has more than ${LIMITS.maxParameterItems} entries`;
2250
+ }
1981
2251
  for (const member of parameter) {
1982
2252
  if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
1983
2253
  const problem = parameterProblem(member);
@@ -1986,6 +2256,9 @@ function parameterProblem(parameter) {
1986
2256
  return null;
1987
2257
  }
1988
2258
  if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2259
+ if (parameter.length > LIMITS.maxParameterLength) {
2260
+ return `parameter is longer than ${LIMITS.maxParameterLength} chars`;
2261
+ }
1989
2262
  if (parameter === GROUP_PARAMETER) {
1990
2263
  return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
1991
2264
  }
@@ -2021,6 +2294,20 @@ function matchProblem(match, parameter) {
2021
2294
  if (!match || typeof match !== "object") return "condition has no match object";
2022
2295
  if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2023
2296
  if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2297
+ for (const operand of ["value", "key", "claim"]) {
2298
+ const value = match[operand];
2299
+ if (typeof value === "string" && value.length > LIMITS.maxValueLength) {
2300
+ return `match operand "${operand}" is longer than ${LIMITS.maxValueLength} chars`;
2301
+ }
2302
+ if (Array.isArray(value)) {
2303
+ if (value.length > LIMITS.maxOperandItems) {
2304
+ return `match operand "${operand}" has more than ${LIMITS.maxOperandItems} entries`;
2305
+ }
2306
+ if (value.some((entry) => typeof entry === "string" && entry.length > LIMITS.maxValueLength)) {
2307
+ return `match operand "${operand}" contains a string longer than ${LIMITS.maxValueLength} chars`;
2308
+ }
2309
+ }
2310
+ }
2024
2311
  const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2025
2312
  if (!parameterless && (parameter === void 0 || parameter === null)) {
2026
2313
  return `match type "${match.type}" needs a parameter`;
@@ -2043,11 +2330,20 @@ function matchProblem(match, parameter) {
2043
2330
  } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2044
2331
  return `match type "${match.type}" needs a value`;
2045
2332
  }
2333
+ if (match.type === "array_key_value") {
2334
+ const paths = Array.isArray(match.key) ? match.key : [match.key];
2335
+ if (paths.some((path) => typeof path === "string" && path.split(".").length > LIMITS.maxPathSegments)) {
2336
+ return `match type "array_key_value" key has more than ${LIMITS.maxPathSegments} path segments`;
2337
+ }
2338
+ }
2046
2339
  return null;
2047
2340
  }
2048
2341
  function mutationsProblem(mutations) {
2049
2342
  if (mutations === void 0 || mutations === null) return null;
2050
2343
  if (!Array.isArray(mutations)) return "mutations must be an array";
2344
+ if (mutations.length > LIMITS.maxOperandItems) {
2345
+ return `mutations has more than ${LIMITS.maxOperandItems} entries`;
2346
+ }
2051
2347
  for (const mutation of mutations) {
2052
2348
  if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2053
2349
  return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
@@ -2067,10 +2363,19 @@ function whenProblem(when) {
2067
2363
  if ("path" in when && !isNonEmptyString(when.path)) {
2068
2364
  return "when.path must be a non-empty string";
2069
2365
  }
2366
+ if (typeof when.path === "string" && when.path.length > LIMITS.maxValueLength) {
2367
+ return `when.path is longer than ${LIMITS.maxValueLength} chars`;
2368
+ }
2070
2369
  if ("method" in when) {
2071
2370
  const methods = Array.isArray(when.method) ? when.method : [when.method];
2072
2371
  if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2372
+ if (methods.length > LIMITS.maxParameterItems) {
2373
+ return `when.method has more than ${LIMITS.maxParameterItems} entries`;
2374
+ }
2073
2375
  if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
2376
+ if (methods.some((method) => method.length > LIMITS.maxParameterLength)) {
2377
+ return `when.method entries must not be longer than ${LIMITS.maxParameterLength} chars`;
2378
+ }
2074
2379
  }
2075
2380
  return null;
2076
2381
  }
@@ -2094,6 +2399,11 @@ function rulePropertyProblem(rule) {
2094
2399
  const problem = operandShapeProblem(shape, rule[property]);
2095
2400
  if (problem) return `"${property}" ${problem}`;
2096
2401
  }
2402
+ for (const property of ["id", "rule_id", "title", "category", "message", "source_revision"]) {
2403
+ if (typeof rule[property] === "string" && rule[property].length > LIMITS.maxValueLength) {
2404
+ return `"${property}" is longer than ${LIMITS.maxValueLength} chars`;
2405
+ }
2406
+ }
2097
2407
  return null;
2098
2408
  }
2099
2409
  function actionProblem(action, rule) {
@@ -2123,46 +2433,76 @@ var PHASES2 = new Set(PHASES);
2123
2433
  var ACTIONS2 = new Set(ACTIONS);
2124
2434
  function validateBundle(bundle, opts = {}) {
2125
2435
  const rejected = [];
2436
+ let omittedRejections = 0;
2437
+ const reject = (entry) => {
2438
+ if (rejected.length < 100) rejected.push(entry);
2439
+ else omittedRejections++;
2440
+ };
2126
2441
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2127
2442
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2443
+ const bundleBudget = { count: 0 };
2128
2444
  const firewall = [];
2129
- for (const rule of inFirewall) {
2130
- if (firewall.length >= LIMITS2.maxRules) {
2131
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2132
- continue;
2133
- }
2134
- const reason = enforceableRuleProblem(rule);
2135
- if (reason) rejected.push({ id: idOf(rule), reason });
2445
+ for (const rule of inFirewall.slice(0, LIMITS2.maxRules)) {
2446
+ const reason = enforceableRuleProblem(rule, bundleBudget);
2447
+ if (reason) reject({ id: idOf(rule), reason });
2136
2448
  else firewall.push(rule);
2137
2449
  }
2450
+ if (inFirewall.length > LIMITS2.maxRules) {
2451
+ reject({ id: "(bundle)", reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2452
+ }
2138
2453
  const whitelists = [];
2139
- for (const wl of inWhitelists) {
2140
- if (whitelists.length >= LIMITS2.maxWhitelists) {
2141
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2142
- continue;
2143
- }
2454
+ for (const wl of inWhitelists.slice(0, LIMITS2.maxWhitelists)) {
2144
2455
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
2145
- rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2456
+ reject({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2146
2457
  continue;
2147
2458
  }
2148
2459
  if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
2149
- rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
2460
+ reject({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
2150
2461
  continue;
2151
2462
  }
2152
- const reason = conditionsProblem(wl?.rule_v2);
2153
- if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2463
+ const reason = conditionsProblem(wl?.rule_v2, 0, { count: 0 }, bundleBudget);
2464
+ if (reason) reject({ id: idOf(wl), reason: `whitelist: ${reason}` });
2154
2465
  else whitelists.push(wl);
2155
2466
  }
2467
+ if (inWhitelists.length > LIMITS2.maxWhitelists) {
2468
+ reject({ id: "(bundle)", reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2469
+ }
2470
+ let whitelistKeys = {};
2471
+ if (bundle?.whitelist_keys !== void 0) {
2472
+ if (!bundle.whitelist_keys || typeof bundle.whitelist_keys !== "object" || Array.isArray(bundle.whitelist_keys)) {
2473
+ reject({ id: "(bundle)", reason: "whitelist_keys must be an object" });
2474
+ } else {
2475
+ const entries = Object.entries(bundle.whitelist_keys);
2476
+ if (entries.length > LIMITS2.maxMapEntries) {
2477
+ reject({ id: "(bundle)", reason: `whitelist_keys has more than ${LIMITS2.maxMapEntries} entries` });
2478
+ } else {
2479
+ 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));
2480
+ if (invalid) {
2481
+ reject({ id: "(bundle)", reason: `whitelist_keys entry "${invalid[0].slice(0, 64)}" has an unsupported shape` });
2482
+ } else {
2483
+ whitelistKeys = bundle.whitelist_keys;
2484
+ }
2485
+ }
2486
+ }
2487
+ }
2488
+ if (omittedRejections > 0) {
2489
+ rejected.push({ id: "(bundle)", reason: `${omittedRejections} additional rejected entries omitted` });
2490
+ }
2156
2491
  return {
2157
- bundle: { ...bundle, firewall, whitelists },
2492
+ bundle: { ...bundle, firewall, whitelists, whitelist_keys: whitelistKeys },
2158
2493
  rejected
2159
2494
  };
2160
2495
  }
2161
2496
  function idOf(rule) {
2162
2497
  const id = rule?.id ?? rule?.rule_id;
2163
- return id === void 0 || id === null ? "(unidentified)" : String(id);
2498
+ if (id === void 0 || id === null) return "(unidentified)";
2499
+ try {
2500
+ return String(id).slice(0, 128);
2501
+ } catch {
2502
+ return "(unidentified)";
2503
+ }
2164
2504
  }
2165
- function enforceableRuleProblem(rule) {
2505
+ function enforceableRuleProblem(rule, bundleBudget = null) {
2166
2506
  if (!rule || typeof rule !== "object") return "not an object";
2167
2507
  const nullReason = nullPropertyProblem(rule);
2168
2508
  if (nullReason) return nullReason;
@@ -2173,9 +2513,9 @@ function enforceableRuleProblem(rule) {
2173
2513
  if (scopeReason) return scopeReason;
2174
2514
  const propertyReason = rulePropertyProblem(rule);
2175
2515
  if (propertyReason) return propertyReason;
2176
- return conditionsProblem(rule.rule_v2);
2516
+ return conditionsProblem(rule.rule_v2, 0, { count: 0 }, bundleBudget);
2177
2517
  }
2178
- function conditionsProblem(conditions, depth = 0) {
2518
+ function conditionsProblem(conditions, depth = 0, ruleBudget = { count: 0 }, bundleBudget = null) {
2179
2519
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2180
2520
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2181
2521
  if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
@@ -2183,10 +2523,20 @@ function conditionsProblem(conditions, depth = 0) {
2183
2523
  return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
2184
2524
  }
2185
2525
  for (const c of conditions) {
2526
+ ruleBudget.count++;
2527
+ if (ruleBudget.count > LIMITS2.maxConditionNodesPerRule) {
2528
+ return `more than ${LIMITS2.maxConditionNodesPerRule} total condition nodes`;
2529
+ }
2530
+ if (bundleBudget) {
2531
+ bundleBudget.count++;
2532
+ if (bundleBudget.count > LIMITS2.maxConditionNodesPerBundle) {
2533
+ return `bundle exceeds ${LIMITS2.maxConditionNodesPerBundle} total condition nodes`;
2534
+ }
2535
+ }
2186
2536
  const shapeReason = conditionShapeProblem(c);
2187
2537
  if (shapeReason) return shapeReason;
2188
2538
  if (isGroup(c)) {
2189
- const nested = conditionsProblem(c.rules, depth + 1);
2539
+ const nested = conditionsProblem(c.rules, depth + 1, ruleBudget, bundleBudget);
2190
2540
  if (nested) return nested;
2191
2541
  continue;
2192
2542
  }
@@ -2200,11 +2550,17 @@ function conditionsProblem(conditions, depth = 0) {
2200
2550
  if (m.type === "regex") {
2201
2551
  if (typeof m.value !== "string") return "regex match.value must be a string";
2202
2552
  if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2553
+ if (safeRegExp(m.value) === null) return "regex is invalid or has an unsafe repetition shape";
2203
2554
  } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2204
2555
  return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2205
2556
  }
2206
2557
  if (m.match) {
2207
- const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2558
+ const nested = conditionsProblem(
2559
+ [{ parameter: c.parameter, match: m.match }],
2560
+ depth + 1,
2561
+ ruleBudget,
2562
+ bundleBudget
2563
+ );
2208
2564
  if (nested) return nested;
2209
2565
  }
2210
2566
  }
@@ -2417,6 +2773,117 @@ function asText(value) {
2417
2773
  return null;
2418
2774
  }
2419
2775
 
2776
+ // src/types.ts
2777
+ var PatchstackError = class extends Error {
2778
+ constructor(message, code, cause) {
2779
+ super(message);
2780
+ this.code = code;
2781
+ this.cause = cause;
2782
+ this.name = "PatchstackError";
2783
+ }
2784
+ code;
2785
+ cause;
2786
+ /**
2787
+ * For a `VALIDATION_ERROR`, the request fields the server refused, as it named them. Read from the
2788
+ * structured `errors` object a validation response carries, so a caller deciding what to do about a
2789
+ * refusal keys on the field rather than on the wording of a sentence.
2790
+ */
2791
+ fields = [];
2792
+ };
2793
+
2794
+ // src/endpoint-policy.ts
2795
+ function assertConnectableEndpoint(config, target = config.endpoint) {
2796
+ if (config.endpointTrusted === false) {
2797
+ throw new PatchstackError(
2798
+ "The custom endpoint in .patchstackrc.json must be confirmed with --endpoint or PATCHSTACK_ENDPOINT before it can be used.",
2799
+ "CONFIG_INVALID"
2800
+ );
2801
+ }
2802
+ if (safeRemoteUrl(config.endpoint) === null) {
2803
+ throw new PatchstackError(
2804
+ "Patchstack endpoints must use HTTPS, except for localhost development endpoints.",
2805
+ "CONFIG_INVALID"
2806
+ );
2807
+ }
2808
+ let endpointOrigin;
2809
+ let targetOrigin;
2810
+ try {
2811
+ endpointOrigin = new URL(config.endpoint).origin;
2812
+ targetOrigin = new URL(target).origin;
2813
+ } catch (cause) {
2814
+ throw new PatchstackError("The configured Patchstack endpoint is not a valid URL.", "CONFIG_INVALID", cause);
2815
+ }
2816
+ if (safeRemoteUrl(target) === null || targetOrigin !== endpointOrigin) {
2817
+ throw new PatchstackError(
2818
+ "A Patchstack request resolved outside the configured endpoint origin.",
2819
+ "CONFIG_INVALID"
2820
+ );
2821
+ }
2822
+ }
2823
+ function safeRemoteUrl(value) {
2824
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
2825
+ try {
2826
+ const parsed = new URL(value);
2827
+ if (!isSafeOrigin(parsed.toString()) || parsed.username !== "" || parsed.password !== "") return null;
2828
+ return parsed.toString();
2829
+ } catch {
2830
+ return null;
2831
+ }
2832
+ }
2833
+ function isHeaderValue(value, maxLength = 8192) {
2834
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return false;
2835
+ for (let i = 0; i < value.length; i += 1) {
2836
+ const code = value.charCodeAt(i);
2837
+ if (code < 33 || code > 126) return false;
2838
+ }
2839
+ return true;
2840
+ }
2841
+
2842
+ // src/bounded-response.ts
2843
+ var MAX_CONTROL_RESPONSE_BYTES = 256 * 1024;
2844
+ async function readBoundedText(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
2845
+ const stated = Number(response.headers?.get?.("content-length"));
2846
+ if (Number.isFinite(stated) && stated > maxBytes) {
2847
+ void response.body?.cancel().catch(() => {
2848
+ });
2849
+ throw new Error(`response exceeds ${maxBytes} bytes`);
2850
+ }
2851
+ const stream = response.body;
2852
+ if (stream === null || typeof stream.getReader !== "function") {
2853
+ if (stated === 0) return "";
2854
+ throw new Error("response body cannot be read with a size bound");
2855
+ }
2856
+ const reader = stream.getReader();
2857
+ const chunks = [];
2858
+ let total = 0;
2859
+ try {
2860
+ for (; ; ) {
2861
+ const { done, value } = await reader.read();
2862
+ if (done) break;
2863
+ total += value.byteLength;
2864
+ if (total > maxBytes) {
2865
+ void reader.cancel().catch(() => {
2866
+ });
2867
+ throw new Error(`response exceeds ${maxBytes} bytes`);
2868
+ }
2869
+ chunks.push(value);
2870
+ }
2871
+ } finally {
2872
+ reader.releaseLock();
2873
+ }
2874
+ const bytes = new Uint8Array(total);
2875
+ let offset = 0;
2876
+ for (const chunk of chunks) {
2877
+ bytes.set(chunk, offset);
2878
+ offset += chunk.byteLength;
2879
+ }
2880
+ return new TextDecoder().decode(bytes);
2881
+ }
2882
+ async function readBoundedJson2(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
2883
+ const text = await readBoundedText(response, maxBytes);
2884
+ return JSON.parse(text);
2885
+ }
2886
+
2420
2887
  // src/pulse-token.ts
2421
2888
  var TOKEN_SKEW_MS = 6e4;
2422
2889
  function buildTokenUrl(manifestEndpoint) {
@@ -2434,22 +2901,48 @@ function parsePulseAuth(credential) {
2434
2901
  if (!/^\d+$/.test(clientId)) return null;
2435
2902
  return { clientId, clientSecret: credential.slice(0, index) };
2436
2903
  }
2437
- var cached = null;
2438
- var inflight = null;
2439
- function clearPulseToken() {
2440
- cached = null;
2904
+ var cached = /* @__PURE__ */ new Map();
2905
+ var inflight = /* @__PURE__ */ new Map();
2906
+ function cacheKey(tokenUrl, credential) {
2907
+ return `${tokenUrl}
2908
+ ${credential}`;
2909
+ }
2910
+ function clearPulseToken(config) {
2911
+ if (!config || typeof config.pulseAuth !== "string") {
2912
+ cached.clear();
2913
+ inflight.clear();
2914
+ return;
2915
+ }
2916
+ try {
2917
+ const key = cacheKey(buildTokenUrl(config.endpoint), config.pulseAuth);
2918
+ cached.delete(key);
2919
+ inflight.delete(key);
2920
+ } catch {
2921
+ }
2441
2922
  }
2442
2923
  async function getPulseToken(config, fetchImpl = fetch) {
2443
2924
  if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
2444
- if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
2445
- return cached.token;
2925
+ if (config.endpointTrusted === false) return null;
2926
+ let tokenUrl;
2927
+ try {
2928
+ tokenUrl = buildTokenUrl(config.endpoint);
2929
+ } catch {
2930
+ return null;
2931
+ }
2932
+ if (!isSafeOrigin(tokenUrl)) return null;
2933
+ const key = cacheKey(tokenUrl, config.pulseAuth);
2934
+ const existing = cached.get(key);
2935
+ if (existing !== void 0 && Date.now() < existing.expiresAt - TOKEN_SKEW_MS) {
2936
+ return existing.token;
2446
2937
  }
2447
- if (inflight !== null) return inflight;
2938
+ const pending = inflight.get(key);
2939
+ if (pending !== void 0) return pending;
2448
2940
  const credentials = parsePulseAuth(config.pulseAuth);
2449
2941
  if (credentials === null) return null;
2450
- inflight = (async () => {
2942
+ let exchange;
2943
+ exchange = (async () => {
2451
2944
  try {
2452
- const response = await fetchImpl(buildTokenUrl(config.endpoint), {
2945
+ const response = await fetchImpl(tokenUrl, {
2453
2946
  method: "POST",
2454
2947
  headers: {
2455
2948
  "Content-Type": "application/json",
@@ -2464,25 +2957,27 @@ async function getPulseToken(config, fetchImpl = fetch) {
2464
2957
  signal: AbortSignal.timeout(config.timeoutMs)
2465
2958
  });
2466
2959
  if (!response.ok) return null;
2467
- const body = await response.json();
2468
- if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
2960
+ const body = await readBoundedJson2(response);
2961
+ if (!isHeaderValue(body.access_token)) return null;
2469
2962
  const expiresIn = Number(body.expires_in);
2470
2963
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
2471
- cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
2964
+ cached.set(key, { token: body.access_token, expiresAt: Date.now() + ttlMs });
2472
2965
  return body.access_token;
2473
2966
  } catch {
2474
2967
  return null;
2475
2968
  } finally {
2476
- inflight = null;
2969
+ if (inflight.get(key) === exchange) inflight.delete(key);
2477
2970
  }
2478
2971
  })();
2479
- return inflight;
2972
+ inflight.set(key, exchange);
2973
+ return exchange;
2480
2974
  }
2481
2975
  async function pulseAuthHeader(config, fetchImpl = fetch) {
2482
2976
  const token = await getPulseToken(config, fetchImpl);
2483
2977
  return token === null ? {} : { Authorization: `Bearer ${token}` };
2484
2978
  }
2485
2979
  async function pulseFetch(config, url, init, fetchImpl = fetch) {
2980
+ assertConnectableEndpoint(config, url);
2486
2981
  const send = async () => {
2487
2982
  const auth = await pulseAuthHeader(config, fetchImpl);
2488
2983
  const response = await fetchImpl(url, {
@@ -2493,7 +2988,7 @@ async function pulseFetch(config, url, init, fetchImpl = fetch) {
2493
2988
  };
2494
2989
  const first = await send();
2495
2990
  if (first.response.status === 401 && first.authenticated) {
2496
- clearPulseToken();
2991
+ clearPulseToken(config);
2497
2992
  return (await send()).response;
2498
2993
  }
2499
2994
  return first.response;
@@ -2519,6 +3014,7 @@ var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2519
3014
  var DEFAULT_CACHE_TTL2 = 3e5;
2520
3015
  var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
2521
3016
  var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
3017
+ var PER_RULE_DRY_RUN_HEADER = "X-Patchstack-Per-Rule-Dry-Run";
2522
3018
  var JITTER_FRACTION2 = 0.1;
2523
3019
  var PulseRuleClient = class {
2524
3020
  #siteUuid;
@@ -2563,6 +3059,9 @@ var PulseRuleClient = class {
2563
3059
  if (this.#buildId !== null && typeof auth.Authorization === "string") {
2564
3060
  headers["X-Patchstack-Build"] = this.#buildId;
2565
3061
  }
3062
+ if (typeof auth.Authorization === "string") {
3063
+ headers[PER_RULE_DRY_RUN_HEADER] = "1";
3064
+ }
2566
3065
  if (this.#etag) headers["If-None-Match"] = this.#etag;
2567
3066
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2568
3067
  if (response.status === 304) {
@@ -2581,7 +3080,7 @@ var PulseRuleClient = class {
2581
3080
  if (!response.ok) {
2582
3081
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
2583
3082
  }
2584
- const data = await response.json();
3083
+ const data = await readBoundedJson(response);
2585
3084
  if (!data || !Array.isArray(data.firewall)) {
2586
3085
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2587
3086
  }
@@ -2642,19 +3141,18 @@ async function fromFetchRequest(request, options = {}) {
2642
3141
  const method = (request.method || "GET").toUpperCase();
2643
3142
  const headers = {};
2644
3143
  request.headers.forEach((value, key) => {
2645
- headers[key.toLowerCase()] = value;
3144
+ setOwn(headers, key.toLowerCase(), value);
2646
3145
  });
2647
3146
  const query = {};
2648
3147
  for (const [key, value] of url.searchParams) {
2649
- if (key in query) {
2650
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2651
- } else {
2652
- query[key] = value;
2653
- }
3148
+ appendOwn(query, key, value);
2654
3149
  }
2655
3150
  let rawBody = "";
3151
+ let bodyInspectionSkip = null;
2656
3152
  if (method !== "GET" && method !== "HEAD") {
2657
- rawBody = await readCappedText(request, maxBodyBytes);
3153
+ const read = await readCappedText(request, maxBodyBytes);
3154
+ rawBody = read.text;
3155
+ bodyInspectionSkip = read.skip;
2658
3156
  }
2659
3157
  const contentType = headers["content-type"] || "";
2660
3158
  let body = {};
@@ -2681,7 +3179,8 @@ async function fromFetchRequest(request, options = {}) {
2681
3179
  cookies: parseCookies(headers.cookie),
2682
3180
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
2683
3181
  // drops, so prototype-pollution rules on `raw` are robust.
2684
- _rawBody: rawBody
3182
+ _rawBody: rawBody,
3183
+ ...bodyInspectionSkip === null ? {} : { _bodyInspectionSkip: bodyInspectionSkip }
2685
3184
  };
2686
3185
  }
2687
3186
  async function readCappedText(request, max) {
@@ -2689,7 +3188,7 @@ async function readCappedText(request, max) {
2689
3188
  try {
2690
3189
  clone = request.clone();
2691
3190
  } catch {
2692
- return "";
3191
+ return { text: "", skip: "clone-failed" };
2693
3192
  }
2694
3193
  const body = clone.body;
2695
3194
  if (body && typeof body.getReader === "function") {
@@ -2700,23 +3199,30 @@ async function readCappedText(request, max) {
2700
3199
  for (; ; ) {
2701
3200
  const { done, value } = await reader.read();
2702
3201
  if (done) break;
2703
- if (!value || buffered >= max) continue;
3202
+ if (!value) continue;
2704
3203
  const take = Math.min(value.byteLength, max - buffered);
2705
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
3204
+ if (take > 0) chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2706
3205
  buffered += take;
3206
+ if (buffered >= max) {
3207
+ void reader.cancel().catch(() => {
3208
+ });
3209
+ return { text: decodeChunks(chunks, buffered), skip: "body-cap" };
3210
+ }
2707
3211
  }
2708
3212
  } catch {
2709
- return "";
2710
- }
2711
- try {
2712
- return new TextDecoder().decode(concatChunks(chunks, buffered));
2713
- } catch {
2714
- return "";
3213
+ void reader.cancel().catch(() => {
3214
+ });
3215
+ return { text: "", skip: "read-failed" };
3216
+ } finally {
3217
+ reader.releaseLock();
2715
3218
  }
3219
+ return { text: decodeChunks(chunks, buffered), skip: null };
2716
3220
  }
3221
+ return { text: "", skip: "unreadable-body" };
3222
+ }
3223
+ function decodeChunks(chunks, total) {
2717
3224
  try {
2718
- const text = await clone.text();
2719
- return text.length > max ? text.slice(0, max) : text;
3225
+ return new TextDecoder().decode(concatChunks(chunks, total));
2720
3226
  } catch {
2721
3227
  return "";
2722
3228
  }
@@ -2743,7 +3249,7 @@ function parseBody(rawBody, contentType) {
2743
3249
  }
2744
3250
  if (isForm) {
2745
3251
  const body = {};
2746
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
3252
+ for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
2747
3253
  return { body, files: void 0 };
2748
3254
  }
2749
3255
  if (isJson || isAmbiguous) {
@@ -2771,9 +3277,9 @@ function parseMultipart(rawBody, boundary) {
2771
3277
  if (filename !== void 0) {
2772
3278
  const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2773
3279
  const file = { filename, type: partType, content };
2774
- files[name] = name in files ? [].concat(files[name], file) : file;
3280
+ appendOwn(files, name, file);
2775
3281
  } else {
2776
- body[name] = name in body ? [].concat(body[name], content) : content;
3282
+ appendOwn(body, name, content);
2777
3283
  }
2778
3284
  }
2779
3285
  return { body, files };
@@ -2788,7 +3294,7 @@ function parseCookies(header) {
2788
3294
  if (idx === -1) {
2789
3295
  continue;
2790
3296
  }
2791
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3297
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2792
3298
  }
2793
3299
  return cookies;
2794
3300
  }
@@ -2798,7 +3304,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2798
3304
  const method = (req.method || "GET").toUpperCase();
2799
3305
  const headers = {};
2800
3306
  for (const [key, value] of Object.entries(req.headers || {})) {
2801
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
3307
+ setOwn(headers, key.toLowerCase(), Array.isArray(value) ? value.join(", ") : value);
2802
3308
  }
2803
3309
  const host = headers.host || "localhost";
2804
3310
  let url;
@@ -2813,11 +3319,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2813
3319
  }
2814
3320
  const query = {};
2815
3321
  for (const [key, value] of url.searchParams) {
2816
- if (key in query) {
2817
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2818
- } else {
2819
- query[key] = value;
2820
- }
3322
+ appendOwn(query, key, value);
2821
3323
  }
2822
3324
  const contentType = headers["content-type"] || "";
2823
3325
  let body = {};
@@ -2858,7 +3360,7 @@ function parseCookies2(header) {
2858
3360
  if (idx === -1) {
2859
3361
  continue;
2860
3362
  }
2861
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3363
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2862
3364
  }
2863
3365
  return cookies;
2864
3366
  }
@@ -2914,6 +3416,67 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2914
3416
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2915
3417
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
2916
3418
  const MAX_REDIRECTS = 20;
3419
+ const MAX_REPLAY_BODY_BYTES = 1024 * 1024;
3420
+ const captureReplayBody = (request) => {
3421
+ if (request.method === "GET" || request.method === "HEAD" || !request.body) {
3422
+ return { result: Promise.resolve({ body: void 0, tooLarge: false }), cancel() {
3423
+ } };
3424
+ }
3425
+ let reader;
3426
+ try {
3427
+ reader = request.clone().body?.getReader();
3428
+ } catch {
3429
+ reader = null;
3430
+ }
3431
+ if (!reader) {
3432
+ return { result: Promise.resolve({ body: void 0, tooLarge: true }), cancel() {
3433
+ } };
3434
+ }
3435
+ let cancelled = false;
3436
+ const result = (async () => {
3437
+ const chunks = [];
3438
+ let total = 0;
3439
+ try {
3440
+ for (; ; ) {
3441
+ const { done, value } = await reader.read();
3442
+ if (done || cancelled) break;
3443
+ total += value.byteLength;
3444
+ if (total > MAX_REPLAY_BODY_BYTES) {
3445
+ void reader.cancel().catch(() => {
3446
+ });
3447
+ return { body: void 0, tooLarge: true };
3448
+ }
3449
+ chunks.push(value);
3450
+ }
3451
+ if (cancelled) return { body: void 0, tooLarge: false };
3452
+ const body = new Uint8Array(total);
3453
+ let offset = 0;
3454
+ for (const chunk of chunks) {
3455
+ body.set(chunk, offset);
3456
+ offset += chunk.byteLength;
3457
+ }
3458
+ return { body, tooLarge: false };
3459
+ } catch {
3460
+ return { body: void 0, tooLarge: true };
3461
+ } finally {
3462
+ reader.releaseLock();
3463
+ }
3464
+ })();
3465
+ return {
3466
+ result,
3467
+ cancel() {
3468
+ cancelled = true;
3469
+ void reader.cancel().catch(() => {
3470
+ });
3471
+ }
3472
+ };
3473
+ };
3474
+ const discardResponseBody = async (response) => {
3475
+ try {
3476
+ await response?.body?.cancel?.();
3477
+ } catch {
3478
+ }
3479
+ };
2917
3480
  const screenUrl = async (u, method) => {
2918
3481
  let host = null;
2919
3482
  try {
@@ -2939,20 +3502,43 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2939
3502
  if (callerRedirect !== "follow") return originalFetch(input, init);
2940
3503
  const headers = new Headers(cur.headers);
2941
3504
  const signal = cur.signal;
2942
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
3505
+ const replay = captureReplayBody(cur);
3506
+ let body;
2943
3507
  for (let hop = 0; ; hop++) {
2944
- const resp = await originalFetch(
2945
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
2946
- );
3508
+ let resp;
3509
+ try {
3510
+ resp = await originalFetch(
3511
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3512
+ );
3513
+ } catch (error) {
3514
+ replay.cancel();
3515
+ throw error;
3516
+ }
2947
3517
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
2948
- if (!location) return resp;
2949
- if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
3518
+ if (!location) {
3519
+ replay.cancel();
3520
+ return resp;
3521
+ }
3522
+ await discardResponseBody(resp);
3523
+ if (hop >= MAX_REDIRECTS) {
3524
+ replay.cancel();
3525
+ throw new Error("Patchstack blocked an outbound request: too many redirects");
3526
+ }
2950
3527
  const next = new URL(location, url).href;
2951
3528
  if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
2952
3529
  method = "GET";
2953
3530
  body = void 0;
3531
+ replay.cancel();
2954
3532
  headers.delete("content-type");
2955
3533
  headers.delete("content-length");
3534
+ } else if (body === void 0 && method !== "GET" && method !== "HEAD") {
3535
+ const captured = await replay.result;
3536
+ if (captured.tooLarge) {
3537
+ throw new Error(
3538
+ `Patchstack blocked a redirect that required replaying more than ${MAX_REPLAY_BODY_BYTES} bytes`
3539
+ );
3540
+ }
3541
+ body = captured.body;
2956
3542
  }
2957
3543
  if (new URL(next).origin !== new URL(url).origin) {
2958
3544
  headers.delete("authorization");
@@ -3263,7 +3849,7 @@ var DEFAULT_RESPONSE_RULES = [
3263
3849
  //
3264
3850
  // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
3265
3851
  // body, where the newline is the two characters `\` and `n`.
3266
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3852
+ 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+\\)/" } }]
3267
3853
  },
3268
3854
  {
3269
3855
  id: "resp-sql-error",
@@ -3396,9 +3982,18 @@ async function loadFs() {
3396
3982
  try {
3397
3983
  const [fs, path] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3398
3984
  fsMod = {
3985
+ chmodSync: fs.chmodSync,
3986
+ closeSync: fs.closeSync,
3987
+ constants: fs.constants,
3988
+ fstatSync: fs.fstatSync,
3989
+ fsyncSync: fs.fsyncSync,
3990
+ lstatSync: fs.lstatSync,
3991
+ openSync: fs.openSync,
3399
3992
  readFileSync: fs.readFileSync,
3400
- writeFileSync: fs.writeFileSync,
3401
3993
  mkdirSync: fs.mkdirSync,
3994
+ renameSync: fs.renameSync,
3995
+ unlinkSync: fs.unlinkSync,
3996
+ writeFileSync: fs.writeFileSync,
3402
3997
  join: path.join
3403
3998
  };
3404
3999
  } catch {
@@ -3451,21 +4046,83 @@ async function cacheWrite(dir, env) {
3451
4046
  if (!dir) return;
3452
4047
  const fs = await loadFs();
3453
4048
  if (!fs) return;
4049
+ const target = fs.join(dir, "patchstack-rules.json");
4050
+ const temporary = fs.join(dir, `.patchstack-rules-${randomSuffix()}.tmp`);
4051
+ let descriptor = null;
3454
4052
  try {
3455
4053
  fs.mkdirSync(dir, { recursive: true });
3456
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
4054
+ const directory = fs.lstatSync(dir);
4055
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return;
4056
+ let mode;
4057
+ try {
4058
+ const current = fs.lstatSync(target);
4059
+ if (current.isSymbolicLink() || !current.isFile()) return;
4060
+ mode = current.mode & 4095;
4061
+ } catch (error) {
4062
+ if (error?.code !== "ENOENT") return;
4063
+ }
4064
+ descriptor = fs.openSync(temporary, "wx", mode ?? 384);
4065
+ fs.writeFileSync(descriptor, JSON.stringify(env), "utf8");
4066
+ fs.fsyncSync(descriptor);
4067
+ fs.closeSync(descriptor);
4068
+ descriptor = null;
4069
+ if (mode !== void 0) fs.chmodSync(temporary, mode);
4070
+ try {
4071
+ fs.renameSync(temporary, target);
4072
+ } catch (error) {
4073
+ if (typeof process === "undefined" || process.platform !== "win32" || error?.code !== "EEXIST" && error?.code !== "EPERM") throw error;
4074
+ try {
4075
+ fs.unlinkSync(target);
4076
+ } catch (unlinkError) {
4077
+ if (unlinkError?.code !== "ENOENT") throw unlinkError;
4078
+ }
4079
+ fs.renameSync(temporary, target);
4080
+ }
3457
4081
  } catch {
4082
+ } finally {
4083
+ if (descriptor !== null) {
4084
+ try {
4085
+ fs.closeSync(descriptor);
4086
+ } catch {
4087
+ }
4088
+ }
4089
+ try {
4090
+ fs.unlinkSync(temporary);
4091
+ } catch {
4092
+ }
3458
4093
  }
3459
4094
  }
3460
4095
  async function cacheRead(dir) {
3461
4096
  if (!dir) return null;
3462
4097
  const fs = await loadFs();
3463
4098
  if (!fs) return null;
4099
+ const target = fs.join(dir, "patchstack-rules.json");
4100
+ let descriptor = null;
3464
4101
  try {
3465
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
4102
+ const entry = fs.lstatSync(target);
4103
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 16 * 1024 * 1024) return null;
4104
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
4105
+ descriptor = fs.openSync(target, flags);
4106
+ const opened = fs.fstatSync(descriptor);
4107
+ if (!opened.isFile() || opened.size > 16 * 1024 * 1024) return null;
4108
+ return toEnvelope(JSON.parse(fs.readFileSync(descriptor, "utf8")));
3466
4109
  } catch {
3467
4110
  return null;
4111
+ } finally {
4112
+ if (descriptor !== null) {
4113
+ try {
4114
+ fs.closeSync(descriptor);
4115
+ } catch {
4116
+ }
4117
+ }
4118
+ }
4119
+ }
4120
+ function randomSuffix() {
4121
+ try {
4122
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
4123
+ } catch {
3468
4124
  }
4125
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3469
4126
  }
3470
4127
  function toEnvelope(value) {
3471
4128
  if (!value || typeof value !== "object") return null;
@@ -3491,7 +4148,11 @@ function toEnvelope(value) {
3491
4148
  function liveUpdateRejections(res, options) {
3492
4149
  if (options.acceptPartialBundle) return [];
3493
4150
  const { rejected } = validateBundle(
3494
- { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
4151
+ {
4152
+ firewall: Array.isArray(res.firewall) ? res.firewall : [],
4153
+ whitelists: Array.isArray(res.whitelists) ? res.whitelists : [],
4154
+ whitelist_keys: res.whitelist_keys
4155
+ },
3495
4156
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3496
4157
  );
3497
4158
  return rejected;
@@ -3662,7 +4323,8 @@ function normalizeBundle(b, options = {}) {
3662
4323
  const { bundle: checked, rejected } = validateBundle(
3663
4324
  {
3664
4325
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
3665
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
4326
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
4327
+ whitelist_keys: b.whitelist_keys
3666
4328
  },
3667
4329
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3668
4330
  );
@@ -3682,7 +4344,7 @@ function normalizeBundle(b, options = {}) {
3682
4344
  return {
3683
4345
  firewall: checked.firewall,
3684
4346
  whitelists: checked.whitelists,
3685
- whitelist_keys: b.whitelist_keys ?? {},
4347
+ whitelist_keys: checked.whitelist_keys,
3686
4348
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
3687
4349
  };
3688
4350
  }
@@ -3726,18 +4388,23 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3726
4388
  };
3727
4389
  }
3728
4390
  function makeRefreshHandler(tick, secret) {
4391
+ let inflight2 = null;
4392
+ const runOnce = () => {
4393
+ if (inflight2) return inflight2;
4394
+ let current;
4395
+ current = Promise.resolve().then(() => tick()).finally(() => {
4396
+ if (inflight2 === current) inflight2 = null;
4397
+ });
4398
+ inflight2 = current;
4399
+ return current;
4400
+ };
3729
4401
  return async (request) => {
3730
4402
  if (!secret) return new Response("not found", { status: 404 });
3731
- let provided = null;
3732
- try {
3733
- provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
3734
- } catch {
3735
- provided = null;
3736
- }
4403
+ const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
3737
4404
  if (provided !== secret) return new Response("forbidden", { status: 403 });
3738
4405
  let refreshed = true;
3739
4406
  try {
3740
- const status = await tick();
4407
+ const status = await runOnce();
3741
4408
  if (status && status.ok === false) refreshed = false;
3742
4409
  } catch {
3743
4410
  refreshed = false;
@@ -4486,8 +5153,10 @@ function hardensWithoutBody(rule) {
4486
5153
  // src/protect/firewall-log.js
4487
5154
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4488
5155
  var STOP_BUDGET_MS2 = 5e3;
5156
+ var ATTEMPT_TIMEOUT_MS2 = 1e4;
4489
5157
  var DEFAULT_FLUSH_MS2 = 1e3;
4490
5158
  var MAX_BATCH2 = 50;
5159
+ var MAX_QUEUE2 = 500;
4491
5160
  var TOKEN_SKEW_MS2 = 6e4;
4492
5161
  function parseApiKey(apiKey) {
4493
5162
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -4509,14 +5178,16 @@ function resolveApiBase(pulseOrManifestUrl) {
4509
5178
  }
4510
5179
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
4511
5180
  try {
4512
- return new URL(pulseOrManifestUrl).origin;
5181
+ const candidate = new URL(pulseOrManifestUrl).origin;
5182
+ if (isSafeOrigin(candidate)) return candidate;
4513
5183
  } catch {
4514
5184
  }
4515
5185
  }
4516
5186
  const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
4517
5187
  if (typeof endpoint === "string" && endpoint.length > 0) {
4518
5188
  try {
4519
- return new URL(endpoint).origin;
5189
+ const candidate = new URL(endpoint).origin;
5190
+ if (isSafeOrigin(candidate)) return candidate;
4520
5191
  } catch {
4521
5192
  }
4522
5193
  }
@@ -4528,16 +5199,16 @@ function createFirewallLogReporter(opts) {
4528
5199
  return { record() {
4529
5200
  }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
4530
5201
  }
4531
- const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
5202
+ const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
4532
5203
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
4533
5204
  const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
4534
5205
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
4535
5206
  let queue = [];
4536
5207
  let timer = null;
4537
5208
  let stopped = false;
4538
- const outstanding = /* @__PURE__ */ new Set();
5209
+ let inFlight = null;
4539
5210
  let drainPromise = null;
4540
- const lifetime = typeof AbortController === "function" ? new AbortController() : null;
5211
+ let activeController = null;
4541
5212
  let ended = false;
4542
5213
  let cachedToken = null;
4543
5214
  let tokenInflight = null;
@@ -4563,9 +5234,9 @@ function createFirewallLogReporter(opts) {
4563
5234
  })
4564
5235
  });
4565
5236
  if (!res || !res.ok) return null;
4566
- const body = await res.json();
5237
+ const body = await readBoundedJson2(res);
4567
5238
  const token = body?.access_token;
4568
- if (typeof token !== "string" || token.length === 0) return null;
5239
+ if (typeof token !== "string" || token.length === 0 || token.length > 8192 || /[\u0000-\u0020\u007f-\u009f]/.test(token)) return null;
4569
5240
  const expiresIn = Number(body?.expires_in);
4570
5241
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
4571
5242
  cachedToken = { token, expiresAt: Date.now() + ttlMs };
@@ -4583,12 +5254,18 @@ function createFirewallLogReporter(opts) {
4583
5254
  clearTimeout(timer);
4584
5255
  timer = null;
4585
5256
  }
4586
- if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
5257
+ if (ended || typeof fetchImpl !== "function") return Promise.resolve();
5258
+ if (inFlight) return inFlight;
5259
+ if (queue.length === 0) return Promise.resolve();
4587
5260
  const batch = queue.splice(0, MAX_BATCH2);
5261
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
5262
+ activeController = controller;
5263
+ const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
5264
+ attemptTimer?.unref?.();
4588
5265
  let entry;
4589
- const send = (async () => {
5266
+ entry = (async () => {
4590
5267
  try {
4591
- const token = await fetchAccessToken(lifetime?.signal);
5268
+ const token = await fetchAccessToken(controller?.signal);
4592
5269
  if (!token || ended) return;
4593
5270
  const body = new URLSearchParams();
4594
5271
  body.set("type", "firewall");
@@ -4603,19 +5280,24 @@ function createFirewallLogReporter(opts) {
4603
5280
  ...sourceHost ? { "Source-Host": sourceHost } : {}
4604
5281
  },
4605
5282
  body,
4606
- // Both phases carry it, so a shutdown that runs out of time can end either one.
4607
- ...lifetime ? { signal: lifetime.signal } : {}
5283
+ // Both phases carry the attempt controller, so slow transports cannot accumulate work.
5284
+ ...controller ? { signal: controller.signal } : {}
4608
5285
  });
4609
5286
  if (p && typeof p.then === "function") await p.catch(() => {
4610
5287
  });
4611
5288
  } catch {
4612
5289
  } finally {
4613
- outstanding.delete(entry);
5290
+ clearTimeout(attemptTimer);
5291
+ if (activeController === controller) activeController = null;
5292
+ if (inFlight === entry) inFlight = null;
5293
+ if (!stopped && !ended && queue.length > 0 && !timer) {
5294
+ timer = setTimeout(flush, queue.length >= MAX_BATCH2 ? 0 : flushMs);
5295
+ timer?.unref?.();
5296
+ }
4614
5297
  }
4615
5298
  })();
4616
- entry = send;
4617
- outstanding.add(send);
4618
- return send;
5299
+ inFlight = entry;
5300
+ return entry;
4619
5301
  };
4620
5302
  return {
4621
5303
  /**
@@ -4631,6 +5313,7 @@ function createFirewallLogReporter(opts) {
4631
5313
  if (stopped) return;
4632
5314
  const fid = event?.rule?.id;
4633
5315
  if (fid === void 0 || fid === null || fid === "") return;
5316
+ if (queue.length >= MAX_QUEUE2) return;
4634
5317
  queue.push({
4635
5318
  fid,
4636
5319
  method: event.method ?? null,
@@ -4662,9 +5345,10 @@ function createFirewallLogReporter(opts) {
4662
5345
  }
4663
5346
  const terminate = () => {
4664
5347
  ended = true;
4665
- lifetime?.abort();
5348
+ activeController?.abort();
5349
+ activeController = null;
4666
5350
  queue = [];
4667
- outstanding.clear();
5351
+ inFlight = null;
4668
5352
  };
4669
5353
  let budget = null;
4670
5354
  const spent = new Promise((resolve) => {
@@ -4676,8 +5360,8 @@ function createFirewallLogReporter(opts) {
4676
5360
  });
4677
5361
  const work = (async () => {
4678
5362
  while (!ended) {
4679
- if (outstanding.size > 0) {
4680
- await Promise.all([...outstanding]);
5363
+ if (inFlight) {
5364
+ await inFlight;
4681
5365
  continue;
4682
5366
  }
4683
5367
  if (queue.length === 0) break;
@@ -4701,10 +5385,107 @@ function telemetryEnabled() {
4701
5385
 
4702
5386
  // src/protect/supabase-guard.js
4703
5387
  var GUARD_PATH = "/_patchstack/guard";
4704
- var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
4705
- function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
5388
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5389
+ var DEFAULT_TIMEOUT_MS = 3e4;
5390
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5391
+ "connection",
5392
+ "cookie",
5393
+ "cookie2",
5394
+ "forwarded",
5395
+ "host",
5396
+ "keep-alive",
5397
+ "proxy-authenticate",
5398
+ "proxy-authorization",
5399
+ "te",
5400
+ "trailer",
5401
+ "transfer-encoding",
5402
+ "upgrade",
5403
+ "x-forwarded-for",
5404
+ "x-forwarded-host",
5405
+ "x-forwarded-port",
5406
+ "x-forwarded-proto",
5407
+ "x-ps-target",
5408
+ "x-real-ip"
5409
+ ]);
5410
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5411
+ "connection",
5412
+ "content-encoding",
5413
+ "content-length",
5414
+ "keep-alive",
5415
+ "proxy-authenticate",
5416
+ "proxy-authorization",
5417
+ "set-cookie",
5418
+ "set-cookie2",
5419
+ "te",
5420
+ "trailer",
5421
+ "transfer-encoding",
5422
+ "upgrade"
5423
+ ]);
5424
+ var BodyTooLargeError = class extends Error {
5425
+ };
5426
+ async function boundedBody(request, maxBytes) {
5427
+ const stated = Number(request.headers.get("content-length"));
5428
+ if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5429
+ if (!request.body) return void 0;
5430
+ const reader = request.body.getReader();
5431
+ const chunks = [];
5432
+ let total = 0;
5433
+ try {
5434
+ for (; ; ) {
5435
+ const { done, value } = await reader.read();
5436
+ if (done) break;
5437
+ total += value.byteLength;
5438
+ if (total > maxBytes) {
5439
+ void reader.cancel().catch(() => {
5440
+ });
5441
+ throw new BodyTooLargeError();
5442
+ }
5443
+ chunks.push(value);
5444
+ }
5445
+ } finally {
5446
+ reader.releaseLock();
5447
+ }
5448
+ const body = new Uint8Array(total);
5449
+ let offset = 0;
5450
+ for (const chunk of chunks) {
5451
+ body.set(chunk, offset);
5452
+ offset += chunk.byteLength;
5453
+ }
5454
+ return body;
5455
+ }
5456
+ function requestHeaders(input) {
5457
+ const output = new Headers();
5458
+ const connectionTokens = new Set(
5459
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5460
+ );
5461
+ input.forEach((value, key) => {
5462
+ const lower = key.toLowerCase();
5463
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5464
+ });
5465
+ return output;
5466
+ }
5467
+ function responseHeaders(input) {
5468
+ const output = new Headers();
5469
+ const connectionTokens = new Set(
5470
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5471
+ );
5472
+ input.forEach((value, key) => {
5473
+ const lower = key.toLowerCase();
5474
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5475
+ });
5476
+ return output;
5477
+ }
5478
+ function createSupabaseGuard({
5479
+ protection,
5480
+ supabaseUrl,
5481
+ fetchImpl = fetch,
5482
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5483
+ timeoutMs = DEFAULT_TIMEOUT_MS
5484
+ }) {
4706
5485
  const guard = protection.fetchGuard();
4707
5486
  const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5487
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5488
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
4708
5489
  return async function handleGuardRequest(request) {
4709
5490
  const target = request.headers.get("x-ps-target");
4710
5491
  if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
@@ -4718,29 +5499,45 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
4718
5499
  return new Response("patchstack: target not allowed", { status: 403 });
4719
5500
  }
4720
5501
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
4721
- const bodyText = hasBody ? await request.text() : "";
5502
+ let body;
5503
+ try {
5504
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5505
+ } catch (error) {
5506
+ if (error instanceof BodyTooLargeError) {
5507
+ return new Response("patchstack: request body too large", { status: 413 });
5508
+ }
5509
+ throw error;
5510
+ }
5511
+ const forwardHeaders = requestHeaders(request.headers);
4722
5512
  const evalReq = new Request(targetUrl.toString(), {
4723
5513
  method: request.method,
5514
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5515
+ // sanitized at the cross-origin boundary below.
4724
5516
  headers: request.headers,
4725
- body: hasBody ? bodyText : void 0
5517
+ body
4726
5518
  });
4727
5519
  const blocked = await guard(evalReq);
4728
5520
  if (blocked) return blocked;
4729
- const forwardHeaders = new Headers(request.headers);
4730
- forwardHeaders.delete("x-ps-target");
4731
- forwardHeaders.delete("host");
4732
- const upstream = await fetchImpl(targetUrl.toString(), {
4733
- method: request.method,
4734
- headers: forwardHeaders,
4735
- body: hasBody ? bodyText : void 0,
4736
- redirect: "manual"
4737
- });
4738
- const outHeaders = new Headers();
4739
- upstream.headers.forEach((value, key) => {
4740
- if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
5521
+ let upstream;
5522
+ try {
5523
+ upstream = await fetchImpl(targetUrl.toString(), {
5524
+ method: request.method,
5525
+ headers: forwardHeaders,
5526
+ body,
5527
+ redirect: "manual",
5528
+ signal: AbortSignal.timeout(upstreamTimeout)
5529
+ });
5530
+ } catch (error) {
5531
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5532
+ return new Response("patchstack: upstream timed out", { status: 504 });
5533
+ }
5534
+ throw error;
5535
+ }
5536
+ const forwarded = new Response(upstream.body, {
5537
+ status: upstream.status,
5538
+ statusText: upstream.statusText,
5539
+ headers: responseHeaders(upstream.headers)
4741
5540
  });
4742
- const buf = await upstream.arrayBuffer();
4743
- const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
4744
5541
  return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
4745
5542
  };
4746
5543
  }
@@ -5043,9 +5840,13 @@ async function createProtection(options = {}) {
5043
5840
  for (const name of Object.keys(headers)) {
5044
5841
  const value = headers[name];
5045
5842
  if (typeof value === "string") {
5046
- headers[name] = applyRedactors(value, spanRedactors, mask);
5843
+ setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
5047
5844
  } else if (Array.isArray(value)) {
5048
- headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
5845
+ setOwn(
5846
+ headers,
5847
+ name,
5848
+ value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
5849
+ );
5049
5850
  }
5050
5851
  }
5051
5852
  }
@@ -5095,7 +5896,13 @@ async function createProtection(options = {}) {
5095
5896
  let result;
5096
5897
  let shaped;
5097
5898
  try {
5098
- shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
5899
+ shaped = await fromFetchRequest(request, {
5900
+ trustedProxy: options.trustedProxy,
5901
+ maxBodyBytes: options.maxBodyBytes
5902
+ });
5903
+ if (shaped?._bodyInspectionSkip) {
5904
+ recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
5905
+ }
5099
5906
  result = engine.evaluate(shaped);
5100
5907
  } catch (err) {
5101
5908
  notify(onError, err, "onError");
@@ -5185,7 +5992,7 @@ async function createProtection(options = {}) {
5185
5992
  const out = {};
5186
5993
  for (const [name, value] of writeHeadEntries(headers)) {
5187
5994
  const key = name.toLowerCase();
5188
- out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
5995
+ appendOwn(out, key, value);
5189
5996
  }
5190
5997
  return out;
5191
5998
  };
@@ -5721,7 +6528,6 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5721
6528
  const reader = body.getReader();
5722
6529
  const chunks = [];
5723
6530
  let size = 0;
5724
- let over = false;
5725
6531
  let sniffed = !sniff;
5726
6532
  try {
5727
6533
  for (; ; ) {
@@ -5730,20 +6536,27 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5730
6536
  if (!value) continue;
5731
6537
  if (!sniffed) {
5732
6538
  sniffed = true;
5733
- if (looksBinary(value)) return { skip: "binary-body" };
6539
+ if (looksBinary(value)) {
6540
+ void reader.cancel().catch(() => {
6541
+ });
6542
+ return { skip: "binary-body" };
6543
+ }
5734
6544
  }
5735
6545
  size += value.byteLength;
5736
- if (over) continue;
5737
- if (size > cap) {
5738
- over = true;
5739
- continue;
6546
+ if (size >= cap) {
6547
+ void reader.cancel().catch(() => {
6548
+ });
6549
+ return { skip: "body-cap" };
5740
6550
  }
5741
6551
  chunks.push(value);
5742
6552
  }
5743
6553
  } catch {
6554
+ void reader.cancel().catch(() => {
6555
+ });
5744
6556
  return { skip: "read-failed" };
6557
+ } finally {
6558
+ reader.releaseLock();
5745
6559
  }
5746
- if (over) return { skip: "body-cap" };
5747
6560
  try {
5748
6561
  return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
5749
6562
  } catch {
@@ -5771,10 +6584,10 @@ function concatBytes(chunks, total) {
5771
6584
  function headerObject(headers) {
5772
6585
  const out = {};
5773
6586
  headers?.forEach?.((v, k) => {
5774
- out[k.toLowerCase()] = v;
6587
+ setOwn(out, k.toLowerCase(), v);
5775
6588
  });
5776
6589
  const setCookies = headers?.getSetCookie?.();
5777
- if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
6590
+ if (setCookies && setCookies.length) setOwn(out, "set-cookie", setCookies);
5778
6591
  return out;
5779
6592
  }
5780
6593
  function hasSpanMutations(rule) {
@@ -5943,7 +6756,7 @@ function isHeaderMutation(action) {
5943
6756
  }
5944
6757
  function applyHeaderMutation(headers, rule) {
5945
6758
  if (rule.action === "remove-header") {
5946
- for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
6759
+ for (const name of rule.remove_headers ?? []) setOwn(headers, String(name).toLowerCase(), null);
5947
6760
  return;
5948
6761
  }
5949
6762
  if (rule.action === "set-header") {
@@ -5952,7 +6765,7 @@ function applyHeaderMutation(headers, rule) {
5952
6765
  const key = String(name).toLowerCase();
5953
6766
  const present = headers[key] != null && headers[key] !== "";
5954
6767
  if (ensure && present) continue;
5955
- headers[key] = String(value);
6768
+ setOwn(headers, key, String(value));
5956
6769
  }
5957
6770
  return;
5958
6771
  }