@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.
@@ -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;
2446
2931
  }
2447
- if (inflight !== null) return inflight;
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;
2937
+ }
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;
@@ -2581,7 +3076,7 @@ var PulseRuleClient = class {
2581
3076
  if (!response.ok) {
2582
3077
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
2583
3078
  }
2584
- const data = await response.json();
3079
+ const data = await readBoundedJson(response);
2585
3080
  if (!data || !Array.isArray(data.firewall)) {
2586
3081
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2587
3082
  }
@@ -2642,19 +3137,18 @@ async function fromFetchRequest(request, options = {}) {
2642
3137
  const method = (request.method || "GET").toUpperCase();
2643
3138
  const headers = {};
2644
3139
  request.headers.forEach((value, key) => {
2645
- headers[key.toLowerCase()] = value;
3140
+ setOwn(headers, key.toLowerCase(), value);
2646
3141
  });
2647
3142
  const query = {};
2648
3143
  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
- }
3144
+ appendOwn(query, key, value);
2654
3145
  }
2655
3146
  let rawBody = "";
3147
+ let bodyInspectionSkip = null;
2656
3148
  if (method !== "GET" && method !== "HEAD") {
2657
- rawBody = await readCappedText(request, maxBodyBytes);
3149
+ const read = await readCappedText(request, maxBodyBytes);
3150
+ rawBody = read.text;
3151
+ bodyInspectionSkip = read.skip;
2658
3152
  }
2659
3153
  const contentType = headers["content-type"] || "";
2660
3154
  let body = {};
@@ -2681,7 +3175,8 @@ async function fromFetchRequest(request, options = {}) {
2681
3175
  cookies: parseCookies(headers.cookie),
2682
3176
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
2683
3177
  // drops, so prototype-pollution rules on `raw` are robust.
2684
- _rawBody: rawBody
3178
+ _rawBody: rawBody,
3179
+ ...bodyInspectionSkip === null ? {} : { _bodyInspectionSkip: bodyInspectionSkip }
2685
3180
  };
2686
3181
  }
2687
3182
  async function readCappedText(request, max) {
@@ -2689,7 +3184,7 @@ async function readCappedText(request, max) {
2689
3184
  try {
2690
3185
  clone = request.clone();
2691
3186
  } catch {
2692
- return "";
3187
+ return { text: "", skip: "clone-failed" };
2693
3188
  }
2694
3189
  const body = clone.body;
2695
3190
  if (body && typeof body.getReader === "function") {
@@ -2700,23 +3195,30 @@ async function readCappedText(request, max) {
2700
3195
  for (; ; ) {
2701
3196
  const { done, value } = await reader.read();
2702
3197
  if (done) break;
2703
- if (!value || buffered >= max) continue;
3198
+ if (!value) continue;
2704
3199
  const take = Math.min(value.byteLength, max - buffered);
2705
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
3200
+ if (take > 0) chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2706
3201
  buffered += take;
3202
+ if (buffered >= max) {
3203
+ void reader.cancel().catch(() => {
3204
+ });
3205
+ return { text: decodeChunks(chunks, buffered), skip: "body-cap" };
3206
+ }
2707
3207
  }
2708
3208
  } catch {
2709
- return "";
2710
- }
2711
- try {
2712
- return new TextDecoder().decode(concatChunks(chunks, buffered));
2713
- } catch {
2714
- return "";
3209
+ void reader.cancel().catch(() => {
3210
+ });
3211
+ return { text: "", skip: "read-failed" };
3212
+ } finally {
3213
+ reader.releaseLock();
2715
3214
  }
3215
+ return { text: decodeChunks(chunks, buffered), skip: null };
2716
3216
  }
3217
+ return { text: "", skip: "unreadable-body" };
3218
+ }
3219
+ function decodeChunks(chunks, total) {
2717
3220
  try {
2718
- const text = await clone.text();
2719
- return text.length > max ? text.slice(0, max) : text;
3221
+ return new TextDecoder().decode(concatChunks(chunks, total));
2720
3222
  } catch {
2721
3223
  return "";
2722
3224
  }
@@ -2743,7 +3245,7 @@ function parseBody(rawBody, contentType) {
2743
3245
  }
2744
3246
  if (isForm) {
2745
3247
  const body = {};
2746
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
3248
+ for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
2747
3249
  return { body, files: void 0 };
2748
3250
  }
2749
3251
  if (isJson || isAmbiguous) {
@@ -2771,9 +3273,9 @@ function parseMultipart(rawBody, boundary) {
2771
3273
  if (filename !== void 0) {
2772
3274
  const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2773
3275
  const file = { filename, type: partType, content };
2774
- files[name] = name in files ? [].concat(files[name], file) : file;
3276
+ appendOwn(files, name, file);
2775
3277
  } else {
2776
- body[name] = name in body ? [].concat(body[name], content) : content;
3278
+ appendOwn(body, name, content);
2777
3279
  }
2778
3280
  }
2779
3281
  return { body, files };
@@ -2788,7 +3290,7 @@ function parseCookies(header) {
2788
3290
  if (idx === -1) {
2789
3291
  continue;
2790
3292
  }
2791
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3293
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2792
3294
  }
2793
3295
  return cookies;
2794
3296
  }
@@ -2798,7 +3300,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2798
3300
  const method = (req.method || "GET").toUpperCase();
2799
3301
  const headers = {};
2800
3302
  for (const [key, value] of Object.entries(req.headers || {})) {
2801
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
3303
+ setOwn(headers, key.toLowerCase(), Array.isArray(value) ? value.join(", ") : value);
2802
3304
  }
2803
3305
  const host = headers.host || "localhost";
2804
3306
  let url;
@@ -2813,11 +3315,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
2813
3315
  }
2814
3316
  const query = {};
2815
3317
  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
- }
3318
+ appendOwn(query, key, value);
2821
3319
  }
2822
3320
  const contentType = headers["content-type"] || "";
2823
3321
  let body = {};
@@ -2858,7 +3356,7 @@ function parseCookies2(header) {
2858
3356
  if (idx === -1) {
2859
3357
  continue;
2860
3358
  }
2861
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3359
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
2862
3360
  }
2863
3361
  return cookies;
2864
3362
  }
@@ -2914,6 +3412,67 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2914
3412
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2915
3413
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
2916
3414
  const MAX_REDIRECTS = 20;
3415
+ const MAX_REPLAY_BODY_BYTES = 1024 * 1024;
3416
+ const captureReplayBody = (request) => {
3417
+ if (request.method === "GET" || request.method === "HEAD" || !request.body) {
3418
+ return { result: Promise.resolve({ body: void 0, tooLarge: false }), cancel() {
3419
+ } };
3420
+ }
3421
+ let reader;
3422
+ try {
3423
+ reader = request.clone().body?.getReader();
3424
+ } catch {
3425
+ reader = null;
3426
+ }
3427
+ if (!reader) {
3428
+ return { result: Promise.resolve({ body: void 0, tooLarge: true }), cancel() {
3429
+ } };
3430
+ }
3431
+ let cancelled = false;
3432
+ const result = (async () => {
3433
+ const chunks = [];
3434
+ let total = 0;
3435
+ try {
3436
+ for (; ; ) {
3437
+ const { done, value } = await reader.read();
3438
+ if (done || cancelled) break;
3439
+ total += value.byteLength;
3440
+ if (total > MAX_REPLAY_BODY_BYTES) {
3441
+ void reader.cancel().catch(() => {
3442
+ });
3443
+ return { body: void 0, tooLarge: true };
3444
+ }
3445
+ chunks.push(value);
3446
+ }
3447
+ if (cancelled) return { body: void 0, tooLarge: false };
3448
+ const body = new Uint8Array(total);
3449
+ let offset = 0;
3450
+ for (const chunk of chunks) {
3451
+ body.set(chunk, offset);
3452
+ offset += chunk.byteLength;
3453
+ }
3454
+ return { body, tooLarge: false };
3455
+ } catch {
3456
+ return { body: void 0, tooLarge: true };
3457
+ } finally {
3458
+ reader.releaseLock();
3459
+ }
3460
+ })();
3461
+ return {
3462
+ result,
3463
+ cancel() {
3464
+ cancelled = true;
3465
+ void reader.cancel().catch(() => {
3466
+ });
3467
+ }
3468
+ };
3469
+ };
3470
+ const discardResponseBody = async (response) => {
3471
+ try {
3472
+ await response?.body?.cancel?.();
3473
+ } catch {
3474
+ }
3475
+ };
2917
3476
  const screenUrl = async (u, method) => {
2918
3477
  let host = null;
2919
3478
  try {
@@ -2939,20 +3498,43 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
2939
3498
  if (callerRedirect !== "follow") return originalFetch(input, init);
2940
3499
  const headers = new Headers(cur.headers);
2941
3500
  const signal = cur.signal;
2942
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
3501
+ const replay = captureReplayBody(cur);
3502
+ let body;
2943
3503
  for (let hop = 0; ; hop++) {
2944
- const resp = await originalFetch(
2945
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
2946
- );
3504
+ let resp;
3505
+ try {
3506
+ resp = await originalFetch(
3507
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3508
+ );
3509
+ } catch (error) {
3510
+ replay.cancel();
3511
+ throw error;
3512
+ }
2947
3513
  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");
3514
+ if (!location) {
3515
+ replay.cancel();
3516
+ return resp;
3517
+ }
3518
+ await discardResponseBody(resp);
3519
+ if (hop >= MAX_REDIRECTS) {
3520
+ replay.cancel();
3521
+ throw new Error("Patchstack blocked an outbound request: too many redirects");
3522
+ }
2950
3523
  const next = new URL(location, url).href;
2951
3524
  if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
2952
3525
  method = "GET";
2953
3526
  body = void 0;
3527
+ replay.cancel();
2954
3528
  headers.delete("content-type");
2955
3529
  headers.delete("content-length");
3530
+ } else if (body === void 0 && method !== "GET" && method !== "HEAD") {
3531
+ const captured = await replay.result;
3532
+ if (captured.tooLarge) {
3533
+ throw new Error(
3534
+ `Patchstack blocked a redirect that required replaying more than ${MAX_REPLAY_BODY_BYTES} bytes`
3535
+ );
3536
+ }
3537
+ body = captured.body;
2956
3538
  }
2957
3539
  if (new URL(next).origin !== new URL(url).origin) {
2958
3540
  headers.delete("authorization");
@@ -3263,7 +3845,7 @@ var DEFAULT_RESPONSE_RULES = [
3263
3845
  //
3264
3846
  // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
3265
3847
  // 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+\\)/" } }]
3848
+ 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
3849
  },
3268
3850
  {
3269
3851
  id: "resp-sql-error",
@@ -3396,9 +3978,18 @@ async function loadFs() {
3396
3978
  try {
3397
3979
  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
3980
  fsMod = {
3981
+ chmodSync: fs.chmodSync,
3982
+ closeSync: fs.closeSync,
3983
+ constants: fs.constants,
3984
+ fstatSync: fs.fstatSync,
3985
+ fsyncSync: fs.fsyncSync,
3986
+ lstatSync: fs.lstatSync,
3987
+ openSync: fs.openSync,
3399
3988
  readFileSync: fs.readFileSync,
3400
- writeFileSync: fs.writeFileSync,
3401
3989
  mkdirSync: fs.mkdirSync,
3990
+ renameSync: fs.renameSync,
3991
+ unlinkSync: fs.unlinkSync,
3992
+ writeFileSync: fs.writeFileSync,
3402
3993
  join: path.join
3403
3994
  };
3404
3995
  } catch {
@@ -3451,21 +4042,83 @@ async function cacheWrite(dir, env) {
3451
4042
  if (!dir) return;
3452
4043
  const fs = await loadFs();
3453
4044
  if (!fs) return;
4045
+ const target = fs.join(dir, "patchstack-rules.json");
4046
+ const temporary = fs.join(dir, `.patchstack-rules-${randomSuffix()}.tmp`);
4047
+ let descriptor = null;
3454
4048
  try {
3455
4049
  fs.mkdirSync(dir, { recursive: true });
3456
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
4050
+ const directory = fs.lstatSync(dir);
4051
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return;
4052
+ let mode;
4053
+ try {
4054
+ const current = fs.lstatSync(target);
4055
+ if (current.isSymbolicLink() || !current.isFile()) return;
4056
+ mode = current.mode & 4095;
4057
+ } catch (error) {
4058
+ if (error?.code !== "ENOENT") return;
4059
+ }
4060
+ descriptor = fs.openSync(temporary, "wx", mode ?? 384);
4061
+ fs.writeFileSync(descriptor, JSON.stringify(env), "utf8");
4062
+ fs.fsyncSync(descriptor);
4063
+ fs.closeSync(descriptor);
4064
+ descriptor = null;
4065
+ if (mode !== void 0) fs.chmodSync(temporary, mode);
4066
+ try {
4067
+ fs.renameSync(temporary, target);
4068
+ } catch (error) {
4069
+ if (typeof process === "undefined" || process.platform !== "win32" || error?.code !== "EEXIST" && error?.code !== "EPERM") throw error;
4070
+ try {
4071
+ fs.unlinkSync(target);
4072
+ } catch (unlinkError) {
4073
+ if (unlinkError?.code !== "ENOENT") throw unlinkError;
4074
+ }
4075
+ fs.renameSync(temporary, target);
4076
+ }
3457
4077
  } catch {
4078
+ } finally {
4079
+ if (descriptor !== null) {
4080
+ try {
4081
+ fs.closeSync(descriptor);
4082
+ } catch {
4083
+ }
4084
+ }
4085
+ try {
4086
+ fs.unlinkSync(temporary);
4087
+ } catch {
4088
+ }
3458
4089
  }
3459
4090
  }
3460
4091
  async function cacheRead(dir) {
3461
4092
  if (!dir) return null;
3462
4093
  const fs = await loadFs();
3463
4094
  if (!fs) return null;
4095
+ const target = fs.join(dir, "patchstack-rules.json");
4096
+ let descriptor = null;
3464
4097
  try {
3465
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
4098
+ const entry = fs.lstatSync(target);
4099
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 16 * 1024 * 1024) return null;
4100
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
4101
+ descriptor = fs.openSync(target, flags);
4102
+ const opened = fs.fstatSync(descriptor);
4103
+ if (!opened.isFile() || opened.size > 16 * 1024 * 1024) return null;
4104
+ return toEnvelope(JSON.parse(fs.readFileSync(descriptor, "utf8")));
3466
4105
  } catch {
3467
4106
  return null;
4107
+ } finally {
4108
+ if (descriptor !== null) {
4109
+ try {
4110
+ fs.closeSync(descriptor);
4111
+ } catch {
4112
+ }
4113
+ }
4114
+ }
4115
+ }
4116
+ function randomSuffix() {
4117
+ try {
4118
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
4119
+ } catch {
3468
4120
  }
4121
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3469
4122
  }
3470
4123
  function toEnvelope(value) {
3471
4124
  if (!value || typeof value !== "object") return null;
@@ -3491,7 +4144,11 @@ function toEnvelope(value) {
3491
4144
  function liveUpdateRejections(res, options) {
3492
4145
  if (options.acceptPartialBundle) return [];
3493
4146
  const { rejected } = validateBundle(
3494
- { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
4147
+ {
4148
+ firewall: Array.isArray(res.firewall) ? res.firewall : [],
4149
+ whitelists: Array.isArray(res.whitelists) ? res.whitelists : [],
4150
+ whitelist_keys: res.whitelist_keys
4151
+ },
3495
4152
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3496
4153
  );
3497
4154
  return rejected;
@@ -3662,7 +4319,8 @@ function normalizeBundle(b, options = {}) {
3662
4319
  const { bundle: checked, rejected } = validateBundle(
3663
4320
  {
3664
4321
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
3665
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
4322
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
4323
+ whitelist_keys: b.whitelist_keys
3666
4324
  },
3667
4325
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
3668
4326
  );
@@ -3682,7 +4340,7 @@ function normalizeBundle(b, options = {}) {
3682
4340
  return {
3683
4341
  firewall: checked.firewall,
3684
4342
  whitelists: checked.whitelists,
3685
- whitelist_keys: b.whitelist_keys ?? {},
4343
+ whitelist_keys: checked.whitelist_keys,
3686
4344
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
3687
4345
  };
3688
4346
  }
@@ -3726,18 +4384,23 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
3726
4384
  };
3727
4385
  }
3728
4386
  function makeRefreshHandler(tick, secret) {
4387
+ let inflight2 = null;
4388
+ const runOnce = () => {
4389
+ if (inflight2) return inflight2;
4390
+ let current;
4391
+ current = Promise.resolve().then(() => tick()).finally(() => {
4392
+ if (inflight2 === current) inflight2 = null;
4393
+ });
4394
+ inflight2 = current;
4395
+ return current;
4396
+ };
3729
4397
  return async (request) => {
3730
4398
  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
- }
4399
+ const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
3737
4400
  if (provided !== secret) return new Response("forbidden", { status: 403 });
3738
4401
  let refreshed = true;
3739
4402
  try {
3740
- const status = await tick();
4403
+ const status = await runOnce();
3741
4404
  if (status && status.ok === false) refreshed = false;
3742
4405
  } catch {
3743
4406
  refreshed = false;
@@ -4486,8 +5149,10 @@ function hardensWithoutBody(rule) {
4486
5149
  // src/protect/firewall-log.js
4487
5150
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4488
5151
  var STOP_BUDGET_MS2 = 5e3;
5152
+ var ATTEMPT_TIMEOUT_MS2 = 1e4;
4489
5153
  var DEFAULT_FLUSH_MS2 = 1e3;
4490
5154
  var MAX_BATCH2 = 50;
5155
+ var MAX_QUEUE2 = 500;
4491
5156
  var TOKEN_SKEW_MS2 = 6e4;
4492
5157
  function parseApiKey(apiKey) {
4493
5158
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -4509,14 +5174,16 @@ function resolveApiBase(pulseOrManifestUrl) {
4509
5174
  }
4510
5175
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
4511
5176
  try {
4512
- return new URL(pulseOrManifestUrl).origin;
5177
+ const candidate = new URL(pulseOrManifestUrl).origin;
5178
+ if (isSafeOrigin(candidate)) return candidate;
4513
5179
  } catch {
4514
5180
  }
4515
5181
  }
4516
5182
  const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
4517
5183
  if (typeof endpoint === "string" && endpoint.length > 0) {
4518
5184
  try {
4519
- return new URL(endpoint).origin;
5185
+ const candidate = new URL(endpoint).origin;
5186
+ if (isSafeOrigin(candidate)) return candidate;
4520
5187
  } catch {
4521
5188
  }
4522
5189
  }
@@ -4528,16 +5195,16 @@ function createFirewallLogReporter(opts) {
4528
5195
  return { record() {
4529
5196
  }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
4530
5197
  }
4531
- const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
5198
+ const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
4532
5199
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
4533
5200
  const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
4534
5201
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
4535
5202
  let queue = [];
4536
5203
  let timer = null;
4537
5204
  let stopped = false;
4538
- const outstanding = /* @__PURE__ */ new Set();
5205
+ let inFlight = null;
4539
5206
  let drainPromise = null;
4540
- const lifetime = typeof AbortController === "function" ? new AbortController() : null;
5207
+ let activeController = null;
4541
5208
  let ended = false;
4542
5209
  let cachedToken = null;
4543
5210
  let tokenInflight = null;
@@ -4563,9 +5230,9 @@ function createFirewallLogReporter(opts) {
4563
5230
  })
4564
5231
  });
4565
5232
  if (!res || !res.ok) return null;
4566
- const body = await res.json();
5233
+ const body = await readBoundedJson2(res);
4567
5234
  const token = body?.access_token;
4568
- if (typeof token !== "string" || token.length === 0) return null;
5235
+ if (typeof token !== "string" || token.length === 0 || token.length > 8192 || /[\u0000-\u0020\u007f-\u009f]/.test(token)) return null;
4569
5236
  const expiresIn = Number(body?.expires_in);
4570
5237
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
4571
5238
  cachedToken = { token, expiresAt: Date.now() + ttlMs };
@@ -4583,12 +5250,18 @@ function createFirewallLogReporter(opts) {
4583
5250
  clearTimeout(timer);
4584
5251
  timer = null;
4585
5252
  }
4586
- if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
5253
+ if (ended || typeof fetchImpl !== "function") return Promise.resolve();
5254
+ if (inFlight) return inFlight;
5255
+ if (queue.length === 0) return Promise.resolve();
4587
5256
  const batch = queue.splice(0, MAX_BATCH2);
5257
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
5258
+ activeController = controller;
5259
+ const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
5260
+ attemptTimer?.unref?.();
4588
5261
  let entry;
4589
- const send = (async () => {
5262
+ entry = (async () => {
4590
5263
  try {
4591
- const token = await fetchAccessToken(lifetime?.signal);
5264
+ const token = await fetchAccessToken(controller?.signal);
4592
5265
  if (!token || ended) return;
4593
5266
  const body = new URLSearchParams();
4594
5267
  body.set("type", "firewall");
@@ -4603,19 +5276,24 @@ function createFirewallLogReporter(opts) {
4603
5276
  ...sourceHost ? { "Source-Host": sourceHost } : {}
4604
5277
  },
4605
5278
  body,
4606
- // Both phases carry it, so a shutdown that runs out of time can end either one.
4607
- ...lifetime ? { signal: lifetime.signal } : {}
5279
+ // Both phases carry the attempt controller, so slow transports cannot accumulate work.
5280
+ ...controller ? { signal: controller.signal } : {}
4608
5281
  });
4609
5282
  if (p && typeof p.then === "function") await p.catch(() => {
4610
5283
  });
4611
5284
  } catch {
4612
5285
  } finally {
4613
- outstanding.delete(entry);
5286
+ clearTimeout(attemptTimer);
5287
+ if (activeController === controller) activeController = null;
5288
+ if (inFlight === entry) inFlight = null;
5289
+ if (!stopped && !ended && queue.length > 0 && !timer) {
5290
+ timer = setTimeout(flush, queue.length >= MAX_BATCH2 ? 0 : flushMs);
5291
+ timer?.unref?.();
5292
+ }
4614
5293
  }
4615
5294
  })();
4616
- entry = send;
4617
- outstanding.add(send);
4618
- return send;
5295
+ inFlight = entry;
5296
+ return entry;
4619
5297
  };
4620
5298
  return {
4621
5299
  /**
@@ -4631,6 +5309,7 @@ function createFirewallLogReporter(opts) {
4631
5309
  if (stopped) return;
4632
5310
  const fid = event?.rule?.id;
4633
5311
  if (fid === void 0 || fid === null || fid === "") return;
5312
+ if (queue.length >= MAX_QUEUE2) return;
4634
5313
  queue.push({
4635
5314
  fid,
4636
5315
  method: event.method ?? null,
@@ -4662,9 +5341,10 @@ function createFirewallLogReporter(opts) {
4662
5341
  }
4663
5342
  const terminate = () => {
4664
5343
  ended = true;
4665
- lifetime?.abort();
5344
+ activeController?.abort();
5345
+ activeController = null;
4666
5346
  queue = [];
4667
- outstanding.clear();
5347
+ inFlight = null;
4668
5348
  };
4669
5349
  let budget = null;
4670
5350
  const spent = new Promise((resolve) => {
@@ -4676,8 +5356,8 @@ function createFirewallLogReporter(opts) {
4676
5356
  });
4677
5357
  const work = (async () => {
4678
5358
  while (!ended) {
4679
- if (outstanding.size > 0) {
4680
- await Promise.all([...outstanding]);
5359
+ if (inFlight) {
5360
+ await inFlight;
4681
5361
  continue;
4682
5362
  }
4683
5363
  if (queue.length === 0) break;
@@ -4701,10 +5381,107 @@ function telemetryEnabled() {
4701
5381
 
4702
5382
  // src/protect/supabase-guard.js
4703
5383
  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 }) {
5384
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
5385
+ var DEFAULT_TIMEOUT_MS = 3e4;
5386
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5387
+ "connection",
5388
+ "cookie",
5389
+ "cookie2",
5390
+ "forwarded",
5391
+ "host",
5392
+ "keep-alive",
5393
+ "proxy-authenticate",
5394
+ "proxy-authorization",
5395
+ "te",
5396
+ "trailer",
5397
+ "transfer-encoding",
5398
+ "upgrade",
5399
+ "x-forwarded-for",
5400
+ "x-forwarded-host",
5401
+ "x-forwarded-port",
5402
+ "x-forwarded-proto",
5403
+ "x-ps-target",
5404
+ "x-real-ip"
5405
+ ]);
5406
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
5407
+ "connection",
5408
+ "content-encoding",
5409
+ "content-length",
5410
+ "keep-alive",
5411
+ "proxy-authenticate",
5412
+ "proxy-authorization",
5413
+ "set-cookie",
5414
+ "set-cookie2",
5415
+ "te",
5416
+ "trailer",
5417
+ "transfer-encoding",
5418
+ "upgrade"
5419
+ ]);
5420
+ var BodyTooLargeError = class extends Error {
5421
+ };
5422
+ async function boundedBody(request, maxBytes) {
5423
+ const stated = Number(request.headers.get("content-length"));
5424
+ if (Number.isFinite(stated) && stated > maxBytes) throw new BodyTooLargeError();
5425
+ if (!request.body) return void 0;
5426
+ const reader = request.body.getReader();
5427
+ const chunks = [];
5428
+ let total = 0;
5429
+ try {
5430
+ for (; ; ) {
5431
+ const { done, value } = await reader.read();
5432
+ if (done) break;
5433
+ total += value.byteLength;
5434
+ if (total > maxBytes) {
5435
+ void reader.cancel().catch(() => {
5436
+ });
5437
+ throw new BodyTooLargeError();
5438
+ }
5439
+ chunks.push(value);
5440
+ }
5441
+ } finally {
5442
+ reader.releaseLock();
5443
+ }
5444
+ const body = new Uint8Array(total);
5445
+ let offset = 0;
5446
+ for (const chunk of chunks) {
5447
+ body.set(chunk, offset);
5448
+ offset += chunk.byteLength;
5449
+ }
5450
+ return body;
5451
+ }
5452
+ function requestHeaders(input) {
5453
+ const output = new Headers();
5454
+ const connectionTokens = new Set(
5455
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5456
+ );
5457
+ input.forEach((value, key) => {
5458
+ const lower = key.toLowerCase();
5459
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5460
+ });
5461
+ return output;
5462
+ }
5463
+ function responseHeaders(input) {
5464
+ const output = new Headers();
5465
+ const connectionTokens = new Set(
5466
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
5467
+ );
5468
+ input.forEach((value, key) => {
5469
+ const lower = key.toLowerCase();
5470
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
5471
+ });
5472
+ return output;
5473
+ }
5474
+ function createSupabaseGuard({
5475
+ protection,
5476
+ supabaseUrl,
5477
+ fetchImpl = fetch,
5478
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
5479
+ timeoutMs = DEFAULT_TIMEOUT_MS
5480
+ }) {
4706
5481
  const guard = protection.fetchGuard();
4707
5482
  const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
5483
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
5484
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
4708
5485
  return async function handleGuardRequest(request) {
4709
5486
  const target = request.headers.get("x-ps-target");
4710
5487
  if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
@@ -4718,29 +5495,45 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
4718
5495
  return new Response("patchstack: target not allowed", { status: 403 });
4719
5496
  }
4720
5497
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
4721
- const bodyText = hasBody ? await request.text() : "";
5498
+ let body;
5499
+ try {
5500
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
5501
+ } catch (error) {
5502
+ if (error instanceof BodyTooLargeError) {
5503
+ return new Response("patchstack: request body too large", { status: 413 });
5504
+ }
5505
+ throw error;
5506
+ }
5507
+ const forwardHeaders = requestHeaders(request.headers);
4722
5508
  const evalReq = new Request(targetUrl.toString(), {
4723
5509
  method: request.method,
5510
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
5511
+ // sanitized at the cross-origin boundary below.
4724
5512
  headers: request.headers,
4725
- body: hasBody ? bodyText : void 0
5513
+ body
4726
5514
  });
4727
5515
  const blocked = await guard(evalReq);
4728
5516
  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);
5517
+ let upstream;
5518
+ try {
5519
+ upstream = await fetchImpl(targetUrl.toString(), {
5520
+ method: request.method,
5521
+ headers: forwardHeaders,
5522
+ body,
5523
+ redirect: "manual",
5524
+ signal: AbortSignal.timeout(upstreamTimeout)
5525
+ });
5526
+ } catch (error) {
5527
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
5528
+ return new Response("patchstack: upstream timed out", { status: 504 });
5529
+ }
5530
+ throw error;
5531
+ }
5532
+ const forwarded = new Response(upstream.body, {
5533
+ status: upstream.status,
5534
+ statusText: upstream.statusText,
5535
+ headers: responseHeaders(upstream.headers)
4741
5536
  });
4742
- const buf = await upstream.arrayBuffer();
4743
- const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
4744
5537
  return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
4745
5538
  };
4746
5539
  }
@@ -5043,9 +5836,13 @@ async function createProtection(options = {}) {
5043
5836
  for (const name of Object.keys(headers)) {
5044
5837
  const value = headers[name];
5045
5838
  if (typeof value === "string") {
5046
- headers[name] = applyRedactors(value, spanRedactors, mask);
5839
+ setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
5047
5840
  } else if (Array.isArray(value)) {
5048
- headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
5841
+ setOwn(
5842
+ headers,
5843
+ name,
5844
+ value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
5845
+ );
5049
5846
  }
5050
5847
  }
5051
5848
  }
@@ -5095,7 +5892,13 @@ async function createProtection(options = {}) {
5095
5892
  let result;
5096
5893
  let shaped;
5097
5894
  try {
5098
- shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
5895
+ shaped = await fromFetchRequest(request, {
5896
+ trustedProxy: options.trustedProxy,
5897
+ maxBodyBytes: options.maxBodyBytes
5898
+ });
5899
+ if (shaped?._bodyInspectionSkip) {
5900
+ recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
5901
+ }
5099
5902
  result = engine.evaluate(shaped);
5100
5903
  } catch (err) {
5101
5904
  notify(onError, err, "onError");
@@ -5185,7 +5988,7 @@ async function createProtection(options = {}) {
5185
5988
  const out = {};
5186
5989
  for (const [name, value] of writeHeadEntries(headers)) {
5187
5990
  const key = name.toLowerCase();
5188
- out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
5991
+ appendOwn(out, key, value);
5189
5992
  }
5190
5993
  return out;
5191
5994
  };
@@ -5721,7 +6524,6 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5721
6524
  const reader = body.getReader();
5722
6525
  const chunks = [];
5723
6526
  let size = 0;
5724
- let over = false;
5725
6527
  let sniffed = !sniff;
5726
6528
  try {
5727
6529
  for (; ; ) {
@@ -5730,20 +6532,27 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
5730
6532
  if (!value) continue;
5731
6533
  if (!sniffed) {
5732
6534
  sniffed = true;
5733
- if (looksBinary(value)) return { skip: "binary-body" };
6535
+ if (looksBinary(value)) {
6536
+ void reader.cancel().catch(() => {
6537
+ });
6538
+ return { skip: "binary-body" };
6539
+ }
5734
6540
  }
5735
6541
  size += value.byteLength;
5736
- if (over) continue;
5737
- if (size > cap) {
5738
- over = true;
5739
- continue;
6542
+ if (size >= cap) {
6543
+ void reader.cancel().catch(() => {
6544
+ });
6545
+ return { skip: "body-cap" };
5740
6546
  }
5741
6547
  chunks.push(value);
5742
6548
  }
5743
6549
  } catch {
6550
+ void reader.cancel().catch(() => {
6551
+ });
5744
6552
  return { skip: "read-failed" };
6553
+ } finally {
6554
+ reader.releaseLock();
5745
6555
  }
5746
- if (over) return { skip: "body-cap" };
5747
6556
  try {
5748
6557
  return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
5749
6558
  } catch {
@@ -5771,10 +6580,10 @@ function concatBytes(chunks, total) {
5771
6580
  function headerObject(headers) {
5772
6581
  const out = {};
5773
6582
  headers?.forEach?.((v, k) => {
5774
- out[k.toLowerCase()] = v;
6583
+ setOwn(out, k.toLowerCase(), v);
5775
6584
  });
5776
6585
  const setCookies = headers?.getSetCookie?.();
5777
- if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
6586
+ if (setCookies && setCookies.length) setOwn(out, "set-cookie", setCookies);
5778
6587
  return out;
5779
6588
  }
5780
6589
  function hasSpanMutations(rule) {
@@ -5943,7 +6752,7 @@ function isHeaderMutation(action) {
5943
6752
  }
5944
6753
  function applyHeaderMutation(headers, rule) {
5945
6754
  if (rule.action === "remove-header") {
5946
- for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
6755
+ for (const name of rule.remove_headers ?? []) setOwn(headers, String(name).toLowerCase(), null);
5947
6756
  return;
5948
6757
  }
5949
6758
  if (rule.action === "set-header") {
@@ -5952,7 +6761,7 @@ function applyHeaderMutation(headers, rule) {
5952
6761
  const key = String(name).toLowerCase();
5953
6762
  const present = headers[key] != null && headers[key] !== "";
5954
6763
  if (ensure && present) continue;
5955
- headers[key] = String(value);
6764
+ setOwn(headers, key, String(value));
5956
6765
  }
5957
6766
  return;
5958
6767
  }