@patchstack/connect 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protect.cjs CHANGED
@@ -35,6 +35,183 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
35
35
  ));
36
36
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
37
37
 
38
+ // src/protect/safe-origin.js
39
+ function isSafeOrigin(value) {
40
+ try {
41
+ const u = new URL(value);
42
+ if (u.protocol === "https:") return true;
43
+ return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+ function safeBaseUrl(candidate, fallback, label) {
49
+ if (typeof candidate !== "string" || candidate === "") return fallback;
50
+ if (isSafeOrigin(candidate)) return candidate;
51
+ warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
52
+ return fallback;
53
+ }
54
+ function warnOnce(key, message) {
55
+ if (warned.has(key)) return;
56
+ warned.add(key);
57
+ console.warn(message);
58
+ }
59
+ var warned;
60
+ var init_safe_origin = __esm({
61
+ "src/protect/safe-origin.js"() {
62
+ "use strict";
63
+ warned = /* @__PURE__ */ new Set();
64
+ }
65
+ });
66
+
67
+ // src/types.ts
68
+ var PatchstackError;
69
+ var init_types = __esm({
70
+ "src/types.ts"() {
71
+ "use strict";
72
+ PatchstackError = class extends Error {
73
+ constructor(message, code, cause) {
74
+ super(message);
75
+ this.code = code;
76
+ this.cause = cause;
77
+ this.name = "PatchstackError";
78
+ }
79
+ code;
80
+ cause;
81
+ /**
82
+ * For a `VALIDATION_ERROR`, the request fields the server refused, as it named them. Read from the
83
+ * structured `errors` object a validation response carries, so a caller deciding what to do about a
84
+ * refusal keys on the field rather than on the wording of a sentence.
85
+ */
86
+ fields = [];
87
+ };
88
+ }
89
+ });
90
+
91
+ // src/endpoint-policy.ts
92
+ function assertConnectableEndpoint(config, target = config.endpoint) {
93
+ if (config.endpointTrusted === false) {
94
+ throw new PatchstackError(
95
+ "The custom endpoint in .patchstackrc.json must be confirmed with --endpoint or PATCHSTACK_ENDPOINT before it can be used.",
96
+ "CONFIG_INVALID"
97
+ );
98
+ }
99
+ if (safeRemoteUrl(config.endpoint) === null) {
100
+ throw new PatchstackError(
101
+ "Patchstack endpoints must use HTTPS, except for localhost development endpoints.",
102
+ "CONFIG_INVALID"
103
+ );
104
+ }
105
+ let endpointOrigin;
106
+ let targetOrigin;
107
+ try {
108
+ endpointOrigin = new URL(config.endpoint).origin;
109
+ targetOrigin = new URL(target).origin;
110
+ } catch (cause) {
111
+ throw new PatchstackError("The configured Patchstack endpoint is not a valid URL.", "CONFIG_INVALID", cause);
112
+ }
113
+ if (safeRemoteUrl(target) === null || targetOrigin !== endpointOrigin) {
114
+ throw new PatchstackError(
115
+ "A Patchstack request resolved outside the configured endpoint origin.",
116
+ "CONFIG_INVALID"
117
+ );
118
+ }
119
+ }
120
+ function safeRemoteUrl(value) {
121
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
122
+ try {
123
+ const parsed = new URL(value);
124
+ if (!isSafeOrigin(parsed.toString()) || parsed.username !== "" || parsed.password !== "") return null;
125
+ return parsed.toString();
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+ function safeDisplayString(value, maxLength = 512) {
131
+ if (typeof value !== "string") return null;
132
+ const clean = value.replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\))/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
133
+ if (clean.length === 0) return null;
134
+ return clean.slice(0, maxLength);
135
+ }
136
+ function isCanonicalUuid(value) {
137
+ return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
138
+ }
139
+ function isCredential(value) {
140
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) return false;
141
+ for (let i = 0; i < value.length; i += 1) {
142
+ const code = value.charCodeAt(i);
143
+ if (code < 33 || code > 126) return false;
144
+ }
145
+ const separator = value.lastIndexOf("-");
146
+ return separator > 0 && separator < value.length - 1 && /^\d+$/.test(value.slice(separator + 1));
147
+ }
148
+ function isHeaderValue(value, maxLength = 8192) {
149
+ if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return false;
150
+ for (let i = 0; i < value.length; i += 1) {
151
+ const code = value.charCodeAt(i);
152
+ if (code < 33 || code > 126) return false;
153
+ }
154
+ return true;
155
+ }
156
+ var init_endpoint_policy = __esm({
157
+ "src/endpoint-policy.ts"() {
158
+ "use strict";
159
+ init_types();
160
+ init_safe_origin();
161
+ }
162
+ });
163
+
164
+ // src/bounded-response.ts
165
+ async function readBoundedText(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
166
+ const stated2 = Number(response.headers?.get?.("content-length"));
167
+ if (Number.isFinite(stated2) && stated2 > maxBytes) {
168
+ void response.body?.cancel().catch(() => {
169
+ });
170
+ throw new Error(`response exceeds ${maxBytes} bytes`);
171
+ }
172
+ const stream = response.body;
173
+ if (stream === null || typeof stream.getReader !== "function") {
174
+ if (stated2 === 0) return "";
175
+ throw new Error("response body cannot be read with a size bound");
176
+ }
177
+ const reader = stream.getReader();
178
+ const chunks = [];
179
+ let total = 0;
180
+ try {
181
+ for (; ; ) {
182
+ const { done, value } = await reader.read();
183
+ if (done) break;
184
+ total += value.byteLength;
185
+ if (total > maxBytes) {
186
+ void reader.cancel().catch(() => {
187
+ });
188
+ throw new Error(`response exceeds ${maxBytes} bytes`);
189
+ }
190
+ chunks.push(value);
191
+ }
192
+ } finally {
193
+ reader.releaseLock();
194
+ }
195
+ const bytes = new Uint8Array(total);
196
+ let offset = 0;
197
+ for (const chunk of chunks) {
198
+ bytes.set(chunk, offset);
199
+ offset += chunk.byteLength;
200
+ }
201
+ return new TextDecoder().decode(bytes);
202
+ }
203
+ async function readBoundedJson2(response, maxBytes = MAX_CONTROL_RESPONSE_BYTES) {
204
+ const text = await readBoundedText(response, maxBytes);
205
+ return JSON.parse(text);
206
+ }
207
+ var MAX_CONTROL_RESPONSE_BYTES;
208
+ var init_bounded_response = __esm({
209
+ "src/bounded-response.ts"() {
210
+ "use strict";
211
+ MAX_CONTROL_RESPONSE_BYTES = 256 * 1024;
212
+ }
213
+ });
214
+
38
215
  // src/pulse-token.ts
39
216
  function buildTokenUrl(manifestEndpoint) {
40
217
  const url = new URL(manifestEndpoint);
@@ -51,20 +228,46 @@ function parsePulseAuth(credential) {
51
228
  if (!/^\d+$/.test(clientId)) return null;
52
229
  return { clientId, clientSecret: credential.slice(0, index) };
53
230
  }
54
- function clearPulseToken() {
55
- cached = null;
231
+ function cacheKey(tokenUrl, credential) {
232
+ return `${tokenUrl}
233
+ ${credential}`;
234
+ }
235
+ function clearPulseToken(config) {
236
+ if (!config || typeof config.pulseAuth !== "string") {
237
+ cached.clear();
238
+ inflight.clear();
239
+ return;
240
+ }
241
+ try {
242
+ const key = cacheKey(buildTokenUrl(config.endpoint), config.pulseAuth);
243
+ cached.delete(key);
244
+ inflight.delete(key);
245
+ } catch {
246
+ }
56
247
  }
57
248
  async function getPulseToken(config, fetchImpl = fetch) {
58
249
  if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
59
- if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
60
- return cached.token;
250
+ if (config.endpointTrusted === false) return null;
251
+ let tokenUrl;
252
+ try {
253
+ tokenUrl = buildTokenUrl(config.endpoint);
254
+ } catch {
255
+ return null;
256
+ }
257
+ if (!isSafeOrigin(tokenUrl)) return null;
258
+ const key = cacheKey(tokenUrl, config.pulseAuth);
259
+ const existing = cached.get(key);
260
+ if (existing !== void 0 && Date.now() < existing.expiresAt - TOKEN_SKEW_MS) {
261
+ return existing.token;
61
262
  }
62
- if (inflight !== null) return inflight;
263
+ const pending = inflight.get(key);
264
+ if (pending !== void 0) return pending;
63
265
  const credentials = parsePulseAuth(config.pulseAuth);
64
266
  if (credentials === null) return null;
65
- inflight = (async () => {
267
+ let exchange;
268
+ exchange = (async () => {
66
269
  try {
67
- const response = await fetchImpl(buildTokenUrl(config.endpoint), {
270
+ const response = await fetchImpl(tokenUrl, {
68
271
  method: "POST",
69
272
  headers: {
70
273
  "Content-Type": "application/json",
@@ -79,25 +282,27 @@ async function getPulseToken(config, fetchImpl = fetch) {
79
282
  signal: AbortSignal.timeout(config.timeoutMs)
80
283
  });
81
284
  if (!response.ok) return null;
82
- const body = await response.json();
83
- if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
285
+ const body = await readBoundedJson2(response);
286
+ if (!isHeaderValue(body.access_token)) return null;
84
287
  const expiresIn = Number(body.expires_in);
85
288
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
86
- cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
289
+ cached.set(key, { token: body.access_token, expiresAt: Date.now() + ttlMs });
87
290
  return body.access_token;
88
291
  } catch {
89
292
  return null;
90
293
  } finally {
91
- inflight = null;
294
+ if (inflight.get(key) === exchange) inflight.delete(key);
92
295
  }
93
296
  })();
94
- return inflight;
297
+ inflight.set(key, exchange);
298
+ return exchange;
95
299
  }
96
300
  async function pulseAuthHeader(config, fetchImpl = fetch) {
97
301
  const token = await getPulseToken(config, fetchImpl);
98
302
  return token === null ? {} : { Authorization: `Bearer ${token}` };
99
303
  }
100
304
  async function pulseFetch(config, url, init, fetchImpl = fetch) {
305
+ assertConnectableEndpoint(config, url);
101
306
  const send = async () => {
102
307
  const auth = await pulseAuthHeader(config, fetchImpl);
103
308
  const response = await fetchImpl(url, {
@@ -108,7 +313,7 @@ async function pulseFetch(config, url, init, fetchImpl = fetch) {
108
313
  };
109
314
  const first = await send();
110
315
  if (first.response.status === 401 && first.authenticated) {
111
- clearPulseToken();
316
+ clearPulseToken(config);
112
317
  return (await send()).response;
113
318
  }
114
319
  return first.response;
@@ -117,9 +322,12 @@ var TOKEN_SKEW_MS, cached, inflight;
117
322
  var init_pulse_token = __esm({
118
323
  "src/pulse-token.ts"() {
119
324
  "use strict";
325
+ init_safe_origin();
326
+ init_endpoint_policy();
327
+ init_bounded_response();
120
328
  TOKEN_SKEW_MS = 6e4;
121
- cached = null;
122
- inflight = null;
329
+ cached = /* @__PURE__ */ new Map();
330
+ inflight = /* @__PURE__ */ new Map();
123
331
  }
124
332
  });
125
333
 
@@ -144,30 +352,6 @@ var init_build_id = __esm({
144
352
  }
145
353
  });
146
354
 
147
- // src/types.ts
148
- var PatchstackError;
149
- var init_types = __esm({
150
- "src/types.ts"() {
151
- "use strict";
152
- PatchstackError = class extends Error {
153
- constructor(message, code, cause) {
154
- super(message);
155
- this.code = code;
156
- this.cause = cause;
157
- this.name = "PatchstackError";
158
- }
159
- code;
160
- cause;
161
- /**
162
- * For a `VALIDATION_ERROR`, the request fields the server refused, as it named them. Read from the
163
- * structured `errors` object a validation response carries, so a caller deciding what to do about a
164
- * refusal keys on the field rather than on the wording of a sentence.
165
- */
166
- fields = [];
167
- };
168
- }
169
- });
170
-
171
355
  // src/hosting.ts
172
356
  function detectHostingPlatform(env = process.env) {
173
357
  for (const rule of RULES) {
@@ -204,7 +388,7 @@ var init_hosting = __esm({
204
388
 
205
389
  // src/client.ts
206
390
  function claimTokenHeader(config) {
207
- return typeof config.claimToken === "string" && config.claimToken !== "" ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
391
+ return config.endpointTrusted !== false && isHeaderValue(config.claimToken) ? { [CLAIM_TOKEN_HEADER]: config.claimToken } : {};
208
392
  }
209
393
  function buildEndpointUrl(base, siteUuid) {
210
394
  const trimmed = base.replace(/\/$/, "");
@@ -239,9 +423,46 @@ function validationFields(body) {
239
423
  if (typeof errors !== "object" || errors === null || Array.isArray(errors)) return [];
240
424
  return Object.keys(errors);
241
425
  }
426
+ function manifestResponse(body, provisioning) {
427
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
428
+ const value = body;
429
+ if (typeof value.stored !== "boolean") return null;
430
+ const uuid = value.uuid === void 0 ? void 0 : isCanonicalUuid(value.uuid) ? value.uuid : null;
431
+ if (uuid === null || provisioning && uuid === void 0) return null;
432
+ if (value.api_key !== void 0 && !isCredential(value.api_key)) return null;
433
+ if (value.manifest_id !== void 0 && (!Number.isInteger(value.manifest_id) || Number(value.manifest_id) <= 0)) return null;
434
+ const claimValue = value.claim;
435
+ let claim2;
436
+ if (claimValue !== void 0) {
437
+ if (typeof claimValue !== "object" || claimValue === null || Array.isArray(claimValue)) return null;
438
+ const raw = claimValue;
439
+ if (!["claimed", "owned-by-you", "owned-by-other", "rejected"].includes(String(raw.state))) return null;
440
+ const dashboardUrl = raw.dashboard_url === void 0 || raw.dashboard_url === null ? null : safeRemoteUrl(raw.dashboard_url);
441
+ if (raw.dashboard_url !== void 0 && raw.dashboard_url !== null && dashboardUrl === null) return null;
442
+ claim2 = {
443
+ state: raw.state,
444
+ ...Number.isInteger(raw.site_id) ? { site_id: Number(raw.site_id) } : {},
445
+ ...dashboardUrl !== null ? { dashboard_url: dashboardUrl } : {},
446
+ ...raw.reason === "expired" || raw.reason === "invalid" ? { reason: raw.reason } : {}
447
+ };
448
+ }
449
+ const result = {
450
+ stored: value.stored,
451
+ ...uuid !== void 0 ? { uuid } : {},
452
+ ...typeof value.api_key === "string" ? { api_key: value.api_key } : {},
453
+ ...Number.isInteger(value.manifest_id) ? { manifest_id: Number(value.manifest_id) } : {},
454
+ ...safeDisplayString(value.checksum, 256) !== null ? { checksum: safeDisplayString(value.checksum, 256) } : {},
455
+ ...safeDisplayString(value.reason) !== null ? { reason: safeDisplayString(value.reason) } : {},
456
+ ...safeDisplayString(value.message) !== null ? { message: safeDisplayString(value.message) } : {},
457
+ ...safeDisplayString(value.error) !== null ? { error: safeDisplayString(value.error) } : {},
458
+ ...claim2 !== void 0 ? { claim: claim2 } : {}
459
+ };
460
+ return result;
461
+ }
242
462
  async function postManifest(config, payload) {
243
463
  const url = buildEndpointUrl(config.endpoint, config.siteUuid);
244
464
  const timeoutMs = config.timeoutMs;
465
+ assertConnectableEndpoint(config, url);
245
466
  let response;
246
467
  try {
247
468
  response = await pulseFetch(config, url, {
@@ -269,25 +490,27 @@ async function postManifest(config, payload) {
269
490
  cause
270
491
  );
271
492
  }
272
- const text = await response.text();
273
- let body = null;
493
+ let text = "";
494
+ let parsed = null;
274
495
  try {
275
- body = text.length > 0 ? JSON.parse(text) : null;
496
+ text = await readBoundedText(response);
497
+ parsed = text.length > 0 ? JSON.parse(text) : null;
276
498
  } catch {
277
- body = null;
499
+ parsed = null;
278
500
  }
501
+ const body = manifestResponse(parsed, config.siteUuid === null);
279
502
  if (response.status === 404) {
280
503
  throw new PatchstackError(
281
- body?.error ?? "Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.",
504
+ safeDisplayString(parsed?.error) ?? "Site not found. Check that your site UUID is correct and that the app is registered as a Pulse app in your Patchstack dashboard.",
282
505
  "SITE_NOT_FOUND"
283
506
  );
284
507
  }
285
508
  if (response.status === 422) {
286
509
  const refused2 = new PatchstackError(
287
- body?.message ?? "Patchstack rejected the manifest payload (validation failed).",
510
+ safeDisplayString(parsed?.message) ?? "Patchstack rejected the manifest payload (validation failed).",
288
511
  "VALIDATION_ERROR"
289
512
  );
290
- refused2.fields = validationFields(body);
513
+ refused2.fields = validationFields(parsed);
291
514
  throw refused2;
292
515
  }
293
516
  const refused = authFailureMessage(response.status, config);
@@ -296,12 +519,12 @@ async function postManifest(config, payload) {
296
519
  }
297
520
  if (response.status < 200 || response.status >= 300) {
298
521
  throw new PatchstackError(
299
- `Patchstack returned ${response.status}: ${text.slice(0, 200)}`,
522
+ `Patchstack returned ${response.status}: ${safeDisplayString(text, 200) ?? "no response details"}`,
300
523
  "SERVER_ERROR"
301
524
  );
302
525
  }
303
526
  if (body === null) {
304
- throw new PatchstackError("Patchstack returned an empty response.", "SERVER_ERROR");
527
+ throw new PatchstackError("Patchstack returned an unexpected response.", "SERVER_ERROR");
305
528
  }
306
529
  return body;
307
530
  }
@@ -311,7 +534,7 @@ function isTimeoutError(cause) {
311
534
  }
312
535
  return false;
313
536
  }
314
- var DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS, CLAIM_TOKEN_HEADER;
537
+ var DEFAULT_ENDPOINT, DEFAULT_TIMEOUT_MS2, CLAIM_TOKEN_HEADER;
315
538
  var init_client = __esm({
316
539
  "src/client.ts"() {
317
540
  "use strict";
@@ -319,8 +542,10 @@ var init_client = __esm({
319
542
  init_hosting();
320
543
  init_pulse_token();
321
544
  init_build_id();
545
+ init_bounded_response();
546
+ init_endpoint_policy();
322
547
  DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
323
- DEFAULT_TIMEOUT_MS = 3e4;
548
+ DEFAULT_TIMEOUT_MS2 = 3e4;
324
549
  CLAIM_TOKEN_HEADER = "X-Patchstack-Claim-Token";
325
550
  }
326
551
  });
@@ -610,8 +835,16 @@ async function resolveConfig(options) {
610
835
  const fromSecretFile = await readSecretFile(options.cwd);
611
836
  const fromEnv = readEnv();
612
837
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
613
- const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
614
- const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS;
838
+ const endpointRaw = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
839
+ const endpointTrusted = endpointRaw === DEFAULT_ENDPOINT || options.cliEndpoint !== void 0 || fromEnv.endpoint !== void 0 || fromFile.endpoint === void 0;
840
+ const endpoint = safeRemoteUrl(endpointRaw);
841
+ if (endpoint === null) {
842
+ throw new PatchstackError(
843
+ "Patchstack endpoints must be valid HTTPS URLs, except for localhost development endpoints.",
844
+ "CONFIG_INVALID"
845
+ );
846
+ }
847
+ const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
615
848
  const environmentRaw = fromEnv.environment ?? fromFile.environment;
616
849
  if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
617
850
  throw new PatchstackError(
@@ -622,7 +855,7 @@ async function resolveConfig(options) {
622
855
  const inferred = environmentRaw === void 0 ? inferEnvironment(process.env, detectHostedBuilder(options.cwd)) : null;
623
856
  const environment = environmentRaw ?? inferred.environment;
624
857
  const environmentEvidence = inferred?.evidence ?? [];
625
- if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
858
+ if (siteUuid !== null && siteUuid.length > 0 && !isCanonicalUuid(siteUuid)) {
626
859
  throw new PatchstackError(
627
860
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
628
861
  "CONFIG_INVALID"
@@ -645,6 +878,7 @@ async function resolveConfig(options) {
645
878
  siteUrl: identity.url,
646
879
  siteName: identity.name,
647
880
  endpoint,
881
+ endpointTrusted,
648
882
  timeoutMs,
649
883
  environment,
650
884
  environmentEvidence,
@@ -663,8 +897,15 @@ async function readJsonFile(cwd, filename) {
663
897
  const target = import_node_path3.default.join(cwd, filename);
664
898
  let raw;
665
899
  try {
900
+ if ((await (0, import_promises2.lstat)(target)).isSymbolicLink()) {
901
+ throw new PatchstackError(
902
+ `Could not read ${target}: configuration files must not be symbolic links.`,
903
+ "CONFIG_INVALID"
904
+ );
905
+ }
666
906
  raw = await (0, import_promises2.readFile)(target, "utf8");
667
907
  } catch (err) {
908
+ if (err instanceof PatchstackError) throw err;
668
909
  if (err.code === "ENOENT") {
669
910
  return {};
670
911
  }
@@ -709,23 +950,22 @@ function readEnv() {
709
950
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
710
951
  };
711
952
  }
712
- function isUuid(value) {
713
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
714
- }
715
953
  function isEnvironment(value) {
716
954
  return value === "production" || value === "sandbox" || value === "local";
717
955
  }
718
- var import_promises2, import_node_path3, CONFIG_FILENAME, SECRET_FILENAME;
956
+ var import_promises2, import_node_path3, import_node_crypto, CONFIG_FILENAME, SECRET_FILENAME;
719
957
  var init_config = __esm({
720
958
  "src/config.ts"() {
721
959
  "use strict";
722
960
  import_promises2 = require("fs/promises");
723
961
  import_node_path3 = __toESM(require("path"), 1);
962
+ import_node_crypto = require("crypto");
724
963
  init_types();
725
964
  init_client();
726
965
  init_environment();
727
966
  init_site_url();
728
967
  init_site_name();
968
+ init_endpoint_policy();
729
969
  CONFIG_FILENAME = ".patchstackrc.json";
730
970
  SECRET_FILENAME = ".patchstackrc.local.json";
731
971
  }
@@ -2222,27 +2462,52 @@ function clientIpFields(resolved) {
2222
2462
  return established && address !== null ? { client_ip: address, client_ip_source: resolved.source } : { client_ip_source: "unavailable" };
2223
2463
  }
2224
2464
 
2225
- // src/protect/safe-origin.js
2226
- function isSafeOrigin(value) {
2465
+ // src/protect/engine/client.js
2466
+ init_safe_origin();
2467
+
2468
+ // src/protect/engine/response-json.js
2469
+ var MAX_RULE_RESPONSE_BYTES = 5 * 1024 * 1024;
2470
+ async function readBoundedJson(response, maxBytes = MAX_RULE_RESPONSE_BYTES) {
2471
+ const stated2 = Number(response.headers?.get?.("content-length"));
2472
+ if (Number.isFinite(stated2) && stated2 > maxBytes) {
2473
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
2474
+ }
2475
+ const stream = response.body;
2476
+ if (!stream || typeof stream.getReader !== "function") {
2477
+ if (typeof response.text === "function") {
2478
+ const text = await response.text();
2479
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
2480
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
2481
+ }
2482
+ return JSON.parse(text);
2483
+ }
2484
+ throw new Error("rule response body cannot be read with a size bound");
2485
+ }
2486
+ const reader = stream.getReader();
2487
+ const chunks = [];
2488
+ let total = 0;
2227
2489
  try {
2228
- const u = new URL(value);
2229
- if (u.protocol === "https:") return true;
2230
- return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
2231
- } catch {
2232
- return false;
2490
+ for (; ; ) {
2491
+ const { done, value } = await reader.read();
2492
+ if (done) break;
2493
+ total += value.byteLength;
2494
+ if (total > maxBytes) {
2495
+ void reader.cancel().catch(() => {
2496
+ });
2497
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
2498
+ }
2499
+ chunks.push(value);
2500
+ }
2501
+ } finally {
2502
+ reader.releaseLock();
2233
2503
  }
2234
- }
2235
- function safeBaseUrl(candidate, fallback, label) {
2236
- if (typeof candidate !== "string" || candidate === "") return fallback;
2237
- if (isSafeOrigin(candidate)) return candidate;
2238
- warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
2239
- return fallback;
2240
- }
2241
- var warned = /* @__PURE__ */ new Set();
2242
- function warnOnce(key, message) {
2243
- if (warned.has(key)) return;
2244
- warned.add(key);
2245
- console.warn(message);
2504
+ const bytes = new Uint8Array(total);
2505
+ let offset = 0;
2506
+ for (const chunk of chunks) {
2507
+ bytes.set(chunk, offset);
2508
+ offset += chunk.byteLength;
2509
+ }
2510
+ return JSON.parse(new TextDecoder().decode(bytes));
2246
2511
  }
2247
2512
 
2248
2513
  // src/protect/engine/client.js
@@ -2301,7 +2566,7 @@ var PatchstackRuleClient = class {
2301
2566
  whitelist_keys: {}
2302
2567
  };
2303
2568
  }
2304
- const data = await response.json();
2569
+ const data = await readBoundedJson(response);
2305
2570
  if (!data || !Array.isArray(data.firewall)) {
2306
2571
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2307
2572
  }
@@ -2338,6 +2603,22 @@ var PatchstackRuleClient = class {
2338
2603
  }
2339
2604
  };
2340
2605
 
2606
+ // src/protect/engine/own.js
2607
+ function setOwn(target, key, value) {
2608
+ Object.defineProperty(target, key, {
2609
+ configurable: true,
2610
+ enumerable: true,
2611
+ writable: true,
2612
+ value
2613
+ });
2614
+ return value;
2615
+ }
2616
+ function appendOwn(target, key, value) {
2617
+ if (!Object.hasOwn(target, key)) return setOwn(target, key, value);
2618
+ const previous = target[key];
2619
+ return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
2620
+ }
2621
+
2341
2622
  // src/protect/engine/request.js
2342
2623
  var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
2343
2624
  var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
@@ -2456,8 +2737,8 @@ var RequestResolver = class {
2456
2737
  }
2457
2738
  if (key.startsWith("header.")) {
2458
2739
  const name = key.slice("header.".length).toLowerCase();
2459
- const value = (resp.headers ?? {})[name];
2460
- return value !== void 0 ? [value] : [];
2740
+ const headers = resp.headers ?? {};
2741
+ return Object.hasOwn(headers, name) ? [headers[name]] : [];
2461
2742
  }
2462
2743
  return [];
2463
2744
  }
@@ -2552,7 +2833,7 @@ var RequestResolver = class {
2552
2833
  ...this.#resolveWildcard(cookies, key)
2553
2834
  ];
2554
2835
  }
2555
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? cookies[key];
2836
+ const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
2556
2837
  return value !== void 0 ? [value] : [];
2557
2838
  }
2558
2839
  #resolveCookie(key) {
@@ -2560,8 +2841,7 @@ var RequestResolver = class {
2560
2841
  if (key.endsWith("*")) {
2561
2842
  return this.#resolveWildcard(cookies, key);
2562
2843
  }
2563
- const value = cookies[key];
2564
- return value !== void 0 ? [value] : [];
2844
+ return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
2565
2845
  }
2566
2846
  #resolveServer(key) {
2567
2847
  const req = this.#req;
@@ -2737,7 +3017,7 @@ var RequestResolver = class {
2737
3017
  }
2738
3018
  const name = pair.substring(0, eqIndex).trim();
2739
3019
  const value = pair.substring(eqIndex + 1).trim();
2740
- cookies[name] = value;
3020
+ setOwn(cookies, name, value);
2741
3021
  }
2742
3022
  this.#cookies = cookies;
2743
3023
  return this.#cookies;
@@ -2894,30 +3174,113 @@ function normalizeWhitespace(value) {
2894
3174
  result = result.replace(/ {2,}/g, " ");
2895
3175
  return result;
2896
3176
  }
2897
- function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isRoot = true) {
2898
- if (body === null || body === void 0) {
2899
- return isRoot ? "" : "null";
2900
- }
2901
- if (typeof body === "string") {
2902
- return isRoot ? body : JSON.stringify(body);
2903
- }
2904
- if (typeof body !== "object") {
2905
- return String(body);
2906
- }
2907
- if (visited.has(body)) {
2908
- return "[Circular]";
3177
+ var MAX_RAW_CHARS = 1024 * 1024;
3178
+ var MAX_RAW_NODES = 25e3;
3179
+ var MAX_RAW_DEPTH = 200;
3180
+ var MAX_RAW_ENTRIES = 2e3;
3181
+ function serializeForRawDetection(body) {
3182
+ if (body === null || body === void 0) return "";
3183
+ if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
3184
+ const visited = /* @__PURE__ */ new Set();
3185
+ const output = [];
3186
+ const stack = [{ kind: "value", value: body, depth: 0, root: true }];
3187
+ let chars = 0;
3188
+ let nodes = 0;
3189
+ const append = (text) => {
3190
+ if (chars >= MAX_RAW_CHARS) return;
3191
+ const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
3192
+ output.push(piece);
3193
+ chars += piece.length;
3194
+ };
3195
+ while (stack.length > 0 && chars < MAX_RAW_CHARS) {
3196
+ const task = stack.pop();
3197
+ if (task.kind === "text") {
3198
+ append(task.text);
3199
+ continue;
3200
+ }
3201
+ if (task.kind === "key") {
3202
+ append(boundedJsonString(task.value));
3203
+ append(":");
3204
+ continue;
3205
+ }
3206
+ nodes++;
3207
+ if (nodes > MAX_RAW_NODES) {
3208
+ append('"[NodeLimit]"');
3209
+ continue;
3210
+ }
3211
+ const value = task.value;
3212
+ if (value === null || value === void 0) {
3213
+ append(task.root ? "" : "null");
3214
+ continue;
3215
+ }
3216
+ if (typeof value === "string") {
3217
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
3218
+ continue;
3219
+ }
3220
+ if (typeof value !== "object") {
3221
+ append(String(value).slice(0, MAX_RAW_CHARS));
3222
+ continue;
3223
+ }
3224
+ if (visited.has(value)) {
3225
+ append("[Circular]");
3226
+ continue;
3227
+ }
3228
+ if (task.depth >= MAX_RAW_DEPTH) {
3229
+ append('"[DepthLimit]"');
3230
+ continue;
3231
+ }
3232
+ visited.add(value);
3233
+ if (Array.isArray(value)) {
3234
+ const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
3235
+ const items = [];
3236
+ for (let i = 0; i < length2; i++) {
3237
+ if (i > 0) items.push({ kind: "text", text: "," });
3238
+ items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
3239
+ }
3240
+ if (value.length > length2) {
3241
+ if (length2 > 0) items.push({ kind: "text", text: "," });
3242
+ items.push({ kind: "text", text: '"[EntryLimit]"' });
3243
+ }
3244
+ stack.push({ kind: "text", text: "]" });
3245
+ for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
3246
+ stack.push({ kind: "text", text: "[" });
3247
+ continue;
3248
+ }
3249
+ let keys;
3250
+ try {
3251
+ keys = Object.getOwnPropertyNames(value);
3252
+ } catch {
3253
+ append('"[Unreadable]"');
3254
+ continue;
3255
+ }
3256
+ const length = Math.min(keys.length, MAX_RAW_ENTRIES);
3257
+ const entries = [];
3258
+ for (let i = 0; i < length; i++) {
3259
+ const key = keys[i];
3260
+ if (i > 0) entries.push({ kind: "text", text: "," });
3261
+ entries.push({ kind: "key", value: key });
3262
+ entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
3263
+ }
3264
+ if (keys.length > length) {
3265
+ if (length > 0) entries.push({ kind: "text", text: "," });
3266
+ entries.push({ kind: "text", text: '"[EntryLimit]":null' });
3267
+ }
3268
+ stack.push({ kind: "text", text: "}" });
3269
+ for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
3270
+ stack.push({ kind: "text", text: "{" });
2909
3271
  }
2910
- visited.add(body);
2911
- if (Array.isArray(body)) {
2912
- const items = body.map((item) => serializeForRawDetection(item, visited, false));
2913
- return "[" + items.join(",") + "]";
3272
+ return output.join("");
3273
+ }
3274
+ function boundedJsonString(value) {
3275
+ return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
3276
+ }
3277
+ function readOwnValue(value, key) {
3278
+ try {
3279
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
3280
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
3281
+ } catch {
3282
+ return "[Unreadable]";
2914
3283
  }
2915
- const keys = Object.getOwnPropertyNames(body);
2916
- const parts = keys.map((key) => {
2917
- const val = serializeForRawDetection(body[key], visited, false);
2918
- return JSON.stringify(key) + ":" + val;
2919
- });
2920
- return "{" + parts.join(",") + "}";
2921
3284
  }
2922
3285
  var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
2923
3286
  function requestField(req, key) {
@@ -2959,7 +3322,7 @@ function normalizeObject(value, options = {}, depth = 0) {
2959
3322
  if (typeof value === "object" && value !== null) {
2960
3323
  const result = {};
2961
3324
  for (const [key, val] of Object.entries(value)) {
2962
- result[key] = normalizeObject(val, options, depth + 1);
3325
+ setOwn(result, key, normalizeObject(val, options, depth + 1));
2963
3326
  }
2964
3327
  return result;
2965
3328
  }
@@ -2976,6 +3339,111 @@ var REDOS_PATTERNS = [
2976
3339
  new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
2977
3340
  // nested alternation under a quantifier
2978
3341
  ];
3342
+ function hasAdjacentUnbounded(body, flags) {
3343
+ let previousUnboundedAtom = null;
3344
+ for (let i = 0; i < body.length; ) {
3345
+ const start = i;
3346
+ const ch = body[i];
3347
+ let assertion = false;
3348
+ let literal = null;
3349
+ let atom = ch;
3350
+ if (ch === "\\") {
3351
+ const escaped = body[i + 1] ?? "";
3352
+ assertion = /[bBAZz]/.test(escaped);
3353
+ if (!/[bBAZzdswDSWnrtvf0-9pPkKxuc]/.test(escaped)) literal = escaped;
3354
+ i += Math.min(2, body.length - i);
3355
+ atom = body.slice(start, i);
3356
+ } else if (ch === "[") {
3357
+ i++;
3358
+ while (i < body.length) {
3359
+ if (body[i] === "\\") i += 2;
3360
+ else if (body[i++] === "]") break;
3361
+ }
3362
+ atom = body.slice(start, i);
3363
+ } else if (ch === ")") {
3364
+ i++;
3365
+ atom = ".";
3366
+ } else if (ch === "(") {
3367
+ const lookaround = body.startsWith("(?=", i) || body.startsWith("(?!", i) || body.startsWith("(?<=", i) || body.startsWith("(?<!", i);
3368
+ if (lookaround) {
3369
+ i = groupEnd(body, i);
3370
+ continue;
3371
+ }
3372
+ i++;
3373
+ if (body.startsWith("?:", i)) i += 2;
3374
+ else if (body.startsWith("?<", i)) {
3375
+ const nameEnd = body.indexOf(">", i + 2);
3376
+ if (nameEnd !== -1) i = nameEnd + 1;
3377
+ }
3378
+ continue;
3379
+ } else if (ch === "|") {
3380
+ previousUnboundedAtom = null;
3381
+ i++;
3382
+ continue;
3383
+ } else if ("^$".includes(ch)) {
3384
+ i++;
3385
+ continue;
3386
+ } else if ("?*+{}".includes(ch)) {
3387
+ previousUnboundedAtom = null;
3388
+ i++;
3389
+ continue;
3390
+ } else {
3391
+ if (ch !== ".") literal = ch;
3392
+ i++;
3393
+ }
3394
+ if (assertion) continue;
3395
+ let unbounded = false;
3396
+ let mayBeEmpty = false;
3397
+ if (body[i] === "*" || body[i] === "+") {
3398
+ unbounded = true;
3399
+ mayBeEmpty = body[i] === "*";
3400
+ i++;
3401
+ } else if (body[i] === "{") {
3402
+ const quantifier = /^\{(\d+)(?:,(\d*))?\}/.exec(body.slice(i));
3403
+ if (quantifier) {
3404
+ unbounded = quantifier[2] === "";
3405
+ mayBeEmpty = Number(quantifier[1]) === 0;
3406
+ i += quantifier[0].length;
3407
+ }
3408
+ } else if (body[i] === "?") {
3409
+ mayBeEmpty = true;
3410
+ i++;
3411
+ }
3412
+ if (unbounded && (body[i] === "?" || body[i] === "+")) i++;
3413
+ if (unbounded && previousUnboundedAtom !== null) return true;
3414
+ if (unbounded) {
3415
+ previousUnboundedAtom = atom;
3416
+ } else if (previousUnboundedAtom !== null && !mayBeEmpty) {
3417
+ try {
3418
+ const atomFlags = flags.replace(/[^iu]/g, "");
3419
+ 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";
3420
+ const excludedLiteral = literal !== null && !new RegExp(`^(?:${previousUnboundedAtom})$`, atomFlags).test(literal);
3421
+ if (complementaryClass || excludedLiteral) {
3422
+ previousUnboundedAtom = null;
3423
+ }
3424
+ } catch {
3425
+ }
3426
+ }
3427
+ if (i === start) i++;
3428
+ }
3429
+ return false;
3430
+ }
3431
+ function groupEnd(body, start) {
3432
+ let depth = 0;
3433
+ let inClass = false;
3434
+ for (let i = start; i < body.length; i++) {
3435
+ const ch = body[i];
3436
+ if (ch === "\\") {
3437
+ i++;
3438
+ continue;
3439
+ }
3440
+ if (ch === "[") inClass = true;
3441
+ else if (ch === "]") inClass = false;
3442
+ else if (!inClass && ch === "(") depth++;
3443
+ else if (!inClass && ch === ")" && --depth === 0) return i + 1;
3444
+ }
3445
+ return body.length;
3446
+ }
2979
3447
  var warnedRejectedPatterns = /* @__PURE__ */ new Set();
2980
3448
  function warnRejectedPatternOnce(pattern) {
2981
3449
  const key = String(pattern);
@@ -2993,15 +3461,14 @@ function safeRegExp(pattern) {
2993
3461
  if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
2994
3462
  return null;
2995
3463
  }
2996
- for (const dangerous of REDOS_PATTERNS) {
2997
- if (dangerous.test(pattern)) {
2998
- return null;
2999
- }
3000
- }
3001
3464
  const match = pattern.match(/^\/(.+?)\/([gimsuy]*)$/s);
3002
3465
  if (!match) {
3003
3466
  return null;
3004
3467
  }
3468
+ for (const dangerous of REDOS_PATTERNS) {
3469
+ if (dangerous.test(match[1])) return null;
3470
+ }
3471
+ if (hasAdjacentUnbounded(match[1], match[2])) return null;
3005
3472
  try {
3006
3473
  return new RegExp(match[1], match[2]);
3007
3474
  } catch {
@@ -3745,6 +4212,16 @@ function operandShapeProblem(shape, value) {
3745
4212
  const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
3746
4213
  const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
3747
4214
  const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
4215
+ const stringLimit = (v) => typeof v === "string" && v.length > LIMITS.maxValueLength ? `must not be longer than ${LIMITS.maxValueLength} chars` : null;
4216
+ const listLimit = (v) => Array.isArray(v) && v.length > LIMITS.maxOperandItems ? `must not have more than ${LIMITS.maxOperandItems} entries` : null;
4217
+ const tooLong = stringLimit(value);
4218
+ if (tooLong) return tooLong;
4219
+ const tooMany = listLimit(value);
4220
+ if (tooMany) return tooMany;
4221
+ if (Array.isArray(value)) {
4222
+ const longMember = value.find((member) => stringLimit(member) !== null);
4223
+ if (longMember !== void 0) return `contains a string longer than ${LIMITS.maxValueLength} chars`;
4224
+ }
3748
4225
  switch (shape) {
3749
4226
  case "positive-number":
3750
4227
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
@@ -3775,8 +4252,11 @@ function operandShapeProblem(shape, value) {
3775
4252
  if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
3776
4253
  const names = Object.keys(value);
3777
4254
  if (names.length === 0) return "must be an object with at least one entry";
4255
+ if (names.length > LIMITS.maxMapEntries) return `must not have more than ${LIMITS.maxMapEntries} entries`;
3778
4256
  const bad = names.find((name) => !isHeaderName(name));
3779
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4257
+ if (bad !== void 0) return `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4258
+ const badValue = Object.values(value).find((entry) => typeof entry !== "string" || entry.length > LIMITS.maxValueLength);
4259
+ return badValue === void 0 ? null : `values must be strings no longer than ${LIMITS.maxValueLength} chars`;
3780
4260
  }
3781
4261
  case "header-name-list": {
3782
4262
  if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
@@ -3922,9 +4402,16 @@ var LIMITS = Object.freeze({
3922
4402
  maxRules: 5e3,
3923
4403
  maxWhitelists: 2e3,
3924
4404
  maxConditionsPerRule: 250,
4405
+ maxConditionNodesPerRule: 1e3,
4406
+ maxConditionNodesPerBundle: 25e3,
3925
4407
  maxNestingDepth: 12,
3926
4408
  maxRegexLength: 1e3,
3927
- maxValueLength: 8192
4409
+ maxValueLength: 8192,
4410
+ maxParameterLength: 512,
4411
+ maxParameterItems: 64,
4412
+ maxOperandItems: 256,
4413
+ maxMapEntries: 128,
4414
+ maxPathSegments: 64
3928
4415
  });
3929
4416
  var GROUP_PARAMETER = "rules";
3930
4417
  function isGroup(condition) {
@@ -3951,6 +4438,9 @@ function parameterProblem(parameter) {
3951
4438
  if (parameter === void 0 || parameter === null) return null;
3952
4439
  if (Array.isArray(parameter)) {
3953
4440
  if (parameter.length === 0) return "parameter list is empty";
4441
+ if (parameter.length > LIMITS.maxParameterItems) {
4442
+ return `parameter list has more than ${LIMITS.maxParameterItems} entries`;
4443
+ }
3954
4444
  for (const member of parameter) {
3955
4445
  if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
3956
4446
  const problem = parameterProblem(member);
@@ -3959,6 +4449,9 @@ function parameterProblem(parameter) {
3959
4449
  return null;
3960
4450
  }
3961
4451
  if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
4452
+ if (parameter.length > LIMITS.maxParameterLength) {
4453
+ return `parameter is longer than ${LIMITS.maxParameterLength} chars`;
4454
+ }
3962
4455
  if (parameter === GROUP_PARAMETER) {
3963
4456
  return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
3964
4457
  }
@@ -3994,6 +4487,20 @@ function matchProblem(match, parameter) {
3994
4487
  if (!match || typeof match !== "object") return "condition has no match object";
3995
4488
  if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
3996
4489
  if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
4490
+ for (const operand of ["value", "key", "claim"]) {
4491
+ const value = match[operand];
4492
+ if (typeof value === "string" && value.length > LIMITS.maxValueLength) {
4493
+ return `match operand "${operand}" is longer than ${LIMITS.maxValueLength} chars`;
4494
+ }
4495
+ if (Array.isArray(value)) {
4496
+ if (value.length > LIMITS.maxOperandItems) {
4497
+ return `match operand "${operand}" has more than ${LIMITS.maxOperandItems} entries`;
4498
+ }
4499
+ if (value.some((entry) => typeof entry === "string" && entry.length > LIMITS.maxValueLength)) {
4500
+ return `match operand "${operand}" contains a string longer than ${LIMITS.maxValueLength} chars`;
4501
+ }
4502
+ }
4503
+ }
3997
4504
  const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
3998
4505
  if (!parameterless && (parameter === void 0 || parameter === null)) {
3999
4506
  return `match type "${match.type}" needs a parameter`;
@@ -4016,11 +4523,20 @@ function matchProblem(match, parameter) {
4016
4523
  } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
4017
4524
  return `match type "${match.type}" needs a value`;
4018
4525
  }
4526
+ if (match.type === "array_key_value") {
4527
+ const paths = Array.isArray(match.key) ? match.key : [match.key];
4528
+ if (paths.some((path9) => typeof path9 === "string" && path9.split(".").length > LIMITS.maxPathSegments)) {
4529
+ return `match type "array_key_value" key has more than ${LIMITS.maxPathSegments} path segments`;
4530
+ }
4531
+ }
4019
4532
  return null;
4020
4533
  }
4021
4534
  function mutationsProblem(mutations) {
4022
4535
  if (mutations === void 0 || mutations === null) return null;
4023
4536
  if (!Array.isArray(mutations)) return "mutations must be an array";
4537
+ if (mutations.length > LIMITS.maxOperandItems) {
4538
+ return `mutations has more than ${LIMITS.maxOperandItems} entries`;
4539
+ }
4024
4540
  for (const mutation of mutations) {
4025
4541
  if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
4026
4542
  return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
@@ -4040,10 +4556,19 @@ function whenProblem(when) {
4040
4556
  if ("path" in when && !isNonEmptyString(when.path)) {
4041
4557
  return "when.path must be a non-empty string";
4042
4558
  }
4559
+ if (typeof when.path === "string" && when.path.length > LIMITS.maxValueLength) {
4560
+ return `when.path is longer than ${LIMITS.maxValueLength} chars`;
4561
+ }
4043
4562
  if ("method" in when) {
4044
4563
  const methods = Array.isArray(when.method) ? when.method : [when.method];
4045
4564
  if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
4565
+ if (methods.length > LIMITS.maxParameterItems) {
4566
+ return `when.method has more than ${LIMITS.maxParameterItems} entries`;
4567
+ }
4046
4568
  if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
4569
+ if (methods.some((method) => method.length > LIMITS.maxParameterLength)) {
4570
+ return `when.method entries must not be longer than ${LIMITS.maxParameterLength} chars`;
4571
+ }
4047
4572
  }
4048
4573
  return null;
4049
4574
  }
@@ -4067,6 +4592,11 @@ function rulePropertyProblem(rule) {
4067
4592
  const problem = operandShapeProblem(shape, rule[property]);
4068
4593
  if (problem) return `"${property}" ${problem}`;
4069
4594
  }
4595
+ for (const property of ["id", "rule_id", "title", "category", "message", "source_revision"]) {
4596
+ if (typeof rule[property] === "string" && rule[property].length > LIMITS.maxValueLength) {
4597
+ return `"${property}" is longer than ${LIMITS.maxValueLength} chars`;
4598
+ }
4599
+ }
4070
4600
  return null;
4071
4601
  }
4072
4602
  function actionProblem(action, rule) {
@@ -4096,46 +4626,76 @@ var PHASES2 = new Set(PHASES);
4096
4626
  var ACTIONS2 = new Set(ACTIONS);
4097
4627
  function validateBundle(bundle, opts = {}) {
4098
4628
  const rejected = [];
4629
+ let omittedRejections = 0;
4630
+ const reject = (entry) => {
4631
+ if (rejected.length < 100) rejected.push(entry);
4632
+ else omittedRejections++;
4633
+ };
4099
4634
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
4100
4635
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
4636
+ const bundleBudget = { count: 0 };
4101
4637
  const firewall = [];
4102
- for (const rule of inFirewall) {
4103
- if (firewall.length >= LIMITS2.maxRules) {
4104
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
4105
- continue;
4106
- }
4107
- const reason = enforceableRuleProblem(rule);
4108
- if (reason) rejected.push({ id: idOf(rule), reason });
4638
+ for (const rule of inFirewall.slice(0, LIMITS2.maxRules)) {
4639
+ const reason = enforceableRuleProblem(rule, bundleBudget);
4640
+ if (reason) reject({ id: idOf(rule), reason });
4109
4641
  else firewall.push(rule);
4110
4642
  }
4643
+ if (inFirewall.length > LIMITS2.maxRules) {
4644
+ reject({ id: "(bundle)", reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
4645
+ }
4111
4646
  const whitelists = [];
4112
- for (const wl of inWhitelists) {
4113
- if (whitelists.length >= LIMITS2.maxWhitelists) {
4114
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
4115
- continue;
4116
- }
4647
+ for (const wl of inWhitelists.slice(0, LIMITS2.maxWhitelists)) {
4117
4648
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
4118
- rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
4649
+ reject({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
4119
4650
  continue;
4120
4651
  }
4121
4652
  if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
4122
- rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
4653
+ reject({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
4123
4654
  continue;
4124
4655
  }
4125
- const reason = conditionsProblem(wl?.rule_v2);
4126
- if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
4656
+ const reason = conditionsProblem(wl?.rule_v2, 0, { count: 0 }, bundleBudget);
4657
+ if (reason) reject({ id: idOf(wl), reason: `whitelist: ${reason}` });
4127
4658
  else whitelists.push(wl);
4128
4659
  }
4660
+ if (inWhitelists.length > LIMITS2.maxWhitelists) {
4661
+ reject({ id: "(bundle)", reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
4662
+ }
4663
+ let whitelistKeys = {};
4664
+ if (bundle?.whitelist_keys !== void 0) {
4665
+ if (!bundle.whitelist_keys || typeof bundle.whitelist_keys !== "object" || Array.isArray(bundle.whitelist_keys)) {
4666
+ reject({ id: "(bundle)", reason: "whitelist_keys must be an object" });
4667
+ } else {
4668
+ const entries = Object.entries(bundle.whitelist_keys);
4669
+ if (entries.length > LIMITS2.maxMapEntries) {
4670
+ reject({ id: "(bundle)", reason: `whitelist_keys has more than ${LIMITS2.maxMapEntries} entries` });
4671
+ } else {
4672
+ 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));
4673
+ if (invalid) {
4674
+ reject({ id: "(bundle)", reason: `whitelist_keys entry "${invalid[0].slice(0, 64)}" has an unsupported shape` });
4675
+ } else {
4676
+ whitelistKeys = bundle.whitelist_keys;
4677
+ }
4678
+ }
4679
+ }
4680
+ }
4681
+ if (omittedRejections > 0) {
4682
+ rejected.push({ id: "(bundle)", reason: `${omittedRejections} additional rejected entries omitted` });
4683
+ }
4129
4684
  return {
4130
- bundle: { ...bundle, firewall, whitelists },
4685
+ bundle: { ...bundle, firewall, whitelists, whitelist_keys: whitelistKeys },
4131
4686
  rejected
4132
4687
  };
4133
4688
  }
4134
4689
  function idOf(rule) {
4135
4690
  const id = rule?.id ?? rule?.rule_id;
4136
- return id === void 0 || id === null ? "(unidentified)" : String(id);
4691
+ if (id === void 0 || id === null) return "(unidentified)";
4692
+ try {
4693
+ return String(id).slice(0, 128);
4694
+ } catch {
4695
+ return "(unidentified)";
4696
+ }
4137
4697
  }
4138
- function enforceableRuleProblem(rule) {
4698
+ function enforceableRuleProblem(rule, bundleBudget = null) {
4139
4699
  if (!rule || typeof rule !== "object") return "not an object";
4140
4700
  const nullReason = nullPropertyProblem(rule);
4141
4701
  if (nullReason) return nullReason;
@@ -4146,9 +4706,9 @@ function enforceableRuleProblem(rule) {
4146
4706
  if (scopeReason) return scopeReason;
4147
4707
  const propertyReason = rulePropertyProblem(rule);
4148
4708
  if (propertyReason) return propertyReason;
4149
- return conditionsProblem(rule.rule_v2);
4709
+ return conditionsProblem(rule.rule_v2, 0, { count: 0 }, bundleBudget);
4150
4710
  }
4151
- function conditionsProblem(conditions, depth = 0) {
4711
+ function conditionsProblem(conditions, depth = 0, ruleBudget = { count: 0 }, bundleBudget = null) {
4152
4712
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
4153
4713
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
4154
4714
  if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
@@ -4156,10 +4716,20 @@ function conditionsProblem(conditions, depth = 0) {
4156
4716
  return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
4157
4717
  }
4158
4718
  for (const c of conditions) {
4719
+ ruleBudget.count++;
4720
+ if (ruleBudget.count > LIMITS2.maxConditionNodesPerRule) {
4721
+ return `more than ${LIMITS2.maxConditionNodesPerRule} total condition nodes`;
4722
+ }
4723
+ if (bundleBudget) {
4724
+ bundleBudget.count++;
4725
+ if (bundleBudget.count > LIMITS2.maxConditionNodesPerBundle) {
4726
+ return `bundle exceeds ${LIMITS2.maxConditionNodesPerBundle} total condition nodes`;
4727
+ }
4728
+ }
4159
4729
  const shapeReason = conditionShapeProblem(c);
4160
4730
  if (shapeReason) return shapeReason;
4161
4731
  if (isGroup(c)) {
4162
- const nested = conditionsProblem(c.rules, depth + 1);
4732
+ const nested = conditionsProblem(c.rules, depth + 1, ruleBudget, bundleBudget);
4163
4733
  if (nested) return nested;
4164
4734
  continue;
4165
4735
  }
@@ -4173,11 +4743,17 @@ function conditionsProblem(conditions, depth = 0) {
4173
4743
  if (m.type === "regex") {
4174
4744
  if (typeof m.value !== "string") return "regex match.value must be a string";
4175
4745
  if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
4746
+ if (safeRegExp(m.value) === null) return "regex is invalid or has an unsafe repetition shape";
4176
4747
  } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
4177
4748
  return `match.value longer than ${LIMITS2.maxValueLength} chars`;
4178
4749
  }
4179
4750
  if (m.match) {
4180
- const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
4751
+ const nested = conditionsProblem(
4752
+ [{ parameter: c.parameter, match: m.match }],
4753
+ depth + 1,
4754
+ ruleBudget,
4755
+ bundleBudget
4756
+ );
4181
4757
  if (nested) return nested;
4182
4758
  }
4183
4759
  }
@@ -4391,6 +4967,7 @@ function asText(value) {
4391
4967
  }
4392
4968
 
4393
4969
  // src/protect/engine/pulse-client.js
4970
+ init_safe_origin();
4394
4971
  init_pulse_token();
4395
4972
  init_build_id();
4396
4973
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
@@ -4459,7 +5036,7 @@ var PulseRuleClient = class {
4459
5036
  if (!response.ok) {
4460
5037
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
4461
5038
  }
4462
- const data = await response.json();
5039
+ const data = await readBoundedJson(response);
4463
5040
  if (!data || !Array.isArray(data.firewall)) {
4464
5041
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
4465
5042
  }
@@ -4520,19 +5097,18 @@ async function fromFetchRequest(request, options = {}) {
4520
5097
  const method = (request.method || "GET").toUpperCase();
4521
5098
  const headers = {};
4522
5099
  request.headers.forEach((value, key) => {
4523
- headers[key.toLowerCase()] = value;
5100
+ setOwn(headers, key.toLowerCase(), value);
4524
5101
  });
4525
5102
  const query = {};
4526
5103
  for (const [key, value] of url.searchParams) {
4527
- if (key in query) {
4528
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
4529
- } else {
4530
- query[key] = value;
4531
- }
5104
+ appendOwn(query, key, value);
4532
5105
  }
4533
5106
  let rawBody = "";
5107
+ let bodyInspectionSkip = null;
4534
5108
  if (method !== "GET" && method !== "HEAD") {
4535
- rawBody = await readCappedText(request, maxBodyBytes);
5109
+ const read = await readCappedText(request, maxBodyBytes);
5110
+ rawBody = read.text;
5111
+ bodyInspectionSkip = read.skip;
4536
5112
  }
4537
5113
  const contentType = headers["content-type"] || "";
4538
5114
  let body = {};
@@ -4559,7 +5135,8 @@ async function fromFetchRequest(request, options = {}) {
4559
5135
  cookies: parseCookies(headers.cookie),
4560
5136
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
4561
5137
  // drops, so prototype-pollution rules on `raw` are robust.
4562
- _rawBody: rawBody
5138
+ _rawBody: rawBody,
5139
+ ...bodyInspectionSkip === null ? {} : { _bodyInspectionSkip: bodyInspectionSkip }
4563
5140
  };
4564
5141
  }
4565
5142
  async function readCappedText(request, max) {
@@ -4567,7 +5144,7 @@ async function readCappedText(request, max) {
4567
5144
  try {
4568
5145
  clone = request.clone();
4569
5146
  } catch {
4570
- return "";
5147
+ return { text: "", skip: "clone-failed" };
4571
5148
  }
4572
5149
  const body = clone.body;
4573
5150
  if (body && typeof body.getReader === "function") {
@@ -4578,23 +5155,30 @@ async function readCappedText(request, max) {
4578
5155
  for (; ; ) {
4579
5156
  const { done, value } = await reader.read();
4580
5157
  if (done) break;
4581
- if (!value || buffered >= max) continue;
5158
+ if (!value) continue;
4582
5159
  const take = Math.min(value.byteLength, max - buffered);
4583
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
5160
+ if (take > 0) chunks.push(take === value.byteLength ? value : value.subarray(0, take));
4584
5161
  buffered += take;
5162
+ if (buffered >= max) {
5163
+ void reader.cancel().catch(() => {
5164
+ });
5165
+ return { text: decodeChunks(chunks, buffered), skip: "body-cap" };
5166
+ }
4585
5167
  }
4586
5168
  } catch {
4587
- return "";
4588
- }
4589
- try {
4590
- return new TextDecoder().decode(concatChunks(chunks, buffered));
4591
- } catch {
4592
- return "";
5169
+ void reader.cancel().catch(() => {
5170
+ });
5171
+ return { text: "", skip: "read-failed" };
5172
+ } finally {
5173
+ reader.releaseLock();
4593
5174
  }
5175
+ return { text: decodeChunks(chunks, buffered), skip: null };
4594
5176
  }
5177
+ return { text: "", skip: "unreadable-body" };
5178
+ }
5179
+ function decodeChunks(chunks, total) {
4595
5180
  try {
4596
- const text = await clone.text();
4597
- return text.length > max ? text.slice(0, max) : text;
5181
+ return new TextDecoder().decode(concatChunks(chunks, total));
4598
5182
  } catch {
4599
5183
  return "";
4600
5184
  }
@@ -4621,7 +5205,7 @@ function parseBody(rawBody, contentType) {
4621
5205
  }
4622
5206
  if (isForm) {
4623
5207
  const body = {};
4624
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
5208
+ for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
4625
5209
  return { body, files: void 0 };
4626
5210
  }
4627
5211
  if (isJson || isAmbiguous) {
@@ -4649,9 +5233,9 @@ function parseMultipart(rawBody, boundary) {
4649
5233
  if (filename !== void 0) {
4650
5234
  const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
4651
5235
  const file = { filename, type: partType, content };
4652
- files[name] = name in files ? [].concat(files[name], file) : file;
5236
+ appendOwn(files, name, file);
4653
5237
  } else {
4654
- body[name] = name in body ? [].concat(body[name], content) : content;
5238
+ appendOwn(body, name, content);
4655
5239
  }
4656
5240
  }
4657
5241
  return { body, files };
@@ -4666,7 +5250,7 @@ function parseCookies(header) {
4666
5250
  if (idx === -1) {
4667
5251
  continue;
4668
5252
  }
4669
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
5253
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
4670
5254
  }
4671
5255
  return cookies;
4672
5256
  }
@@ -4676,7 +5260,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
4676
5260
  const method = (req.method || "GET").toUpperCase();
4677
5261
  const headers = {};
4678
5262
  for (const [key, value] of Object.entries(req.headers || {})) {
4679
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
5263
+ setOwn(headers, key.toLowerCase(), Array.isArray(value) ? value.join(", ") : value);
4680
5264
  }
4681
5265
  const host = headers.host || "localhost";
4682
5266
  let url;
@@ -4691,11 +5275,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
4691
5275
  }
4692
5276
  const query = {};
4693
5277
  for (const [key, value] of url.searchParams) {
4694
- if (key in query) {
4695
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
4696
- } else {
4697
- query[key] = value;
4698
- }
5278
+ appendOwn(query, key, value);
4699
5279
  }
4700
5280
  const contentType = headers["content-type"] || "";
4701
5281
  let body = {};
@@ -4736,7 +5316,7 @@ function parseCookies2(header) {
4736
5316
  if (idx === -1) {
4737
5317
  continue;
4738
5318
  }
4739
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
5319
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
4740
5320
  }
4741
5321
  return cookies;
4742
5322
  }
@@ -4792,6 +5372,67 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
4792
5372
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
4793
5373
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
4794
5374
  const MAX_REDIRECTS = 20;
5375
+ const MAX_REPLAY_BODY_BYTES = 1024 * 1024;
5376
+ const captureReplayBody = (request) => {
5377
+ if (request.method === "GET" || request.method === "HEAD" || !request.body) {
5378
+ return { result: Promise.resolve({ body: void 0, tooLarge: false }), cancel() {
5379
+ } };
5380
+ }
5381
+ let reader;
5382
+ try {
5383
+ reader = request.clone().body?.getReader();
5384
+ } catch {
5385
+ reader = null;
5386
+ }
5387
+ if (!reader) {
5388
+ return { result: Promise.resolve({ body: void 0, tooLarge: true }), cancel() {
5389
+ } };
5390
+ }
5391
+ let cancelled = false;
5392
+ const result = (async () => {
5393
+ const chunks = [];
5394
+ let total = 0;
5395
+ try {
5396
+ for (; ; ) {
5397
+ const { done, value } = await reader.read();
5398
+ if (done || cancelled) break;
5399
+ total += value.byteLength;
5400
+ if (total > MAX_REPLAY_BODY_BYTES) {
5401
+ void reader.cancel().catch(() => {
5402
+ });
5403
+ return { body: void 0, tooLarge: true };
5404
+ }
5405
+ chunks.push(value);
5406
+ }
5407
+ if (cancelled) return { body: void 0, tooLarge: false };
5408
+ const body = new Uint8Array(total);
5409
+ let offset = 0;
5410
+ for (const chunk of chunks) {
5411
+ body.set(chunk, offset);
5412
+ offset += chunk.byteLength;
5413
+ }
5414
+ return { body, tooLarge: false };
5415
+ } catch {
5416
+ return { body: void 0, tooLarge: true };
5417
+ } finally {
5418
+ reader.releaseLock();
5419
+ }
5420
+ })();
5421
+ return {
5422
+ result,
5423
+ cancel() {
5424
+ cancelled = true;
5425
+ void reader.cancel().catch(() => {
5426
+ });
5427
+ }
5428
+ };
5429
+ };
5430
+ const discardResponseBody = async (response) => {
5431
+ try {
5432
+ await response?.body?.cancel?.();
5433
+ } catch {
5434
+ }
5435
+ };
4795
5436
  const screenUrl = async (u, method) => {
4796
5437
  let host = null;
4797
5438
  try {
@@ -4817,20 +5458,43 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
4817
5458
  if (callerRedirect !== "follow") return originalFetch(input, init);
4818
5459
  const headers = new Headers(cur.headers);
4819
5460
  const signal = cur.signal;
4820
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
5461
+ const replay = captureReplayBody(cur);
5462
+ let body;
4821
5463
  for (let hop = 0; ; hop++) {
4822
- const resp = await originalFetch(
4823
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
4824
- );
5464
+ let resp;
5465
+ try {
5466
+ resp = await originalFetch(
5467
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
5468
+ );
5469
+ } catch (error) {
5470
+ replay.cancel();
5471
+ throw error;
5472
+ }
4825
5473
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
4826
- if (!location) return resp;
4827
- if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
5474
+ if (!location) {
5475
+ replay.cancel();
5476
+ return resp;
5477
+ }
5478
+ await discardResponseBody(resp);
5479
+ if (hop >= MAX_REDIRECTS) {
5480
+ replay.cancel();
5481
+ throw new Error("Patchstack blocked an outbound request: too many redirects");
5482
+ }
4828
5483
  const next = new URL(location, url).href;
4829
5484
  if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
4830
5485
  method = "GET";
4831
5486
  body = void 0;
5487
+ replay.cancel();
4832
5488
  headers.delete("content-type");
4833
5489
  headers.delete("content-length");
5490
+ } else if (body === void 0 && method !== "GET" && method !== "HEAD") {
5491
+ const captured = await replay.result;
5492
+ if (captured.tooLarge) {
5493
+ throw new Error(
5494
+ `Patchstack blocked a redirect that required replaying more than ${MAX_REPLAY_BODY_BYTES} bytes`
5495
+ );
5496
+ }
5497
+ body = captured.body;
4834
5498
  }
4835
5499
  if (new URL(next).origin !== new URL(url).origin) {
4836
5500
  headers.delete("authorization");
@@ -5141,7 +5805,7 @@ var DEFAULT_RESPONSE_RULES = [
5141
5805
  //
5142
5806
  // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
5143
5807
  // body, where the newline is the two characters `\` and `n`.
5144
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
5808
+ 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+\\)/" } }]
5145
5809
  },
5146
5810
  {
5147
5811
  id: "resp-sql-error",
@@ -5275,9 +5939,18 @@ async function loadFs() {
5275
5939
  try {
5276
5940
  const [fs, path9] = await Promise.all([import("fs"), import("path")]);
5277
5941
  fsMod = {
5942
+ chmodSync: fs.chmodSync,
5943
+ closeSync: fs.closeSync,
5944
+ constants: fs.constants,
5945
+ fstatSync: fs.fstatSync,
5946
+ fsyncSync: fs.fsyncSync,
5947
+ lstatSync: fs.lstatSync,
5948
+ openSync: fs.openSync,
5278
5949
  readFileSync: fs.readFileSync,
5279
- writeFileSync: fs.writeFileSync,
5280
5950
  mkdirSync: fs.mkdirSync,
5951
+ renameSync: fs.renameSync,
5952
+ unlinkSync: fs.unlinkSync,
5953
+ writeFileSync: fs.writeFileSync,
5281
5954
  join: path9.join
5282
5955
  };
5283
5956
  } catch {
@@ -5330,22 +6003,84 @@ async function cacheWrite(dir, env) {
5330
6003
  if (!dir) return;
5331
6004
  const fs = await loadFs();
5332
6005
  if (!fs) return;
6006
+ const target = fs.join(dir, "patchstack-rules.json");
6007
+ const temporary = fs.join(dir, `.patchstack-rules-${randomSuffix()}.tmp`);
6008
+ let descriptor = null;
5333
6009
  try {
5334
6010
  fs.mkdirSync(dir, { recursive: true });
5335
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
6011
+ const directory = fs.lstatSync(dir);
6012
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return;
6013
+ let mode;
6014
+ try {
6015
+ const current = fs.lstatSync(target);
6016
+ if (current.isSymbolicLink() || !current.isFile()) return;
6017
+ mode = current.mode & 4095;
6018
+ } catch (error) {
6019
+ if (error?.code !== "ENOENT") return;
6020
+ }
6021
+ descriptor = fs.openSync(temporary, "wx", mode ?? 384);
6022
+ fs.writeFileSync(descriptor, JSON.stringify(env), "utf8");
6023
+ fs.fsyncSync(descriptor);
6024
+ fs.closeSync(descriptor);
6025
+ descriptor = null;
6026
+ if (mode !== void 0) fs.chmodSync(temporary, mode);
6027
+ try {
6028
+ fs.renameSync(temporary, target);
6029
+ } catch (error) {
6030
+ if (typeof process === "undefined" || process.platform !== "win32" || error?.code !== "EEXIST" && error?.code !== "EPERM") throw error;
6031
+ try {
6032
+ fs.unlinkSync(target);
6033
+ } catch (unlinkError) {
6034
+ if (unlinkError?.code !== "ENOENT") throw unlinkError;
6035
+ }
6036
+ fs.renameSync(temporary, target);
6037
+ }
5336
6038
  } catch {
6039
+ } finally {
6040
+ if (descriptor !== null) {
6041
+ try {
6042
+ fs.closeSync(descriptor);
6043
+ } catch {
6044
+ }
6045
+ }
6046
+ try {
6047
+ fs.unlinkSync(temporary);
6048
+ } catch {
6049
+ }
5337
6050
  }
5338
6051
  }
5339
6052
  async function cacheRead(dir) {
5340
6053
  if (!dir) return null;
5341
6054
  const fs = await loadFs();
5342
6055
  if (!fs) return null;
6056
+ const target = fs.join(dir, "patchstack-rules.json");
6057
+ let descriptor = null;
5343
6058
  try {
5344
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
6059
+ const entry = fs.lstatSync(target);
6060
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 16 * 1024 * 1024) return null;
6061
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
6062
+ descriptor = fs.openSync(target, flags);
6063
+ const opened = fs.fstatSync(descriptor);
6064
+ if (!opened.isFile() || opened.size > 16 * 1024 * 1024) return null;
6065
+ return toEnvelope(JSON.parse(fs.readFileSync(descriptor, "utf8")));
5345
6066
  } catch {
5346
6067
  return null;
6068
+ } finally {
6069
+ if (descriptor !== null) {
6070
+ try {
6071
+ fs.closeSync(descriptor);
6072
+ } catch {
6073
+ }
6074
+ }
5347
6075
  }
5348
6076
  }
6077
+ function randomSuffix() {
6078
+ try {
6079
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
6080
+ } catch {
6081
+ }
6082
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
6083
+ }
5349
6084
  function toEnvelope(value) {
5350
6085
  if (!value || typeof value !== "object") return null;
5351
6086
  if (value.bundle && typeof value.bundle === "object") {
@@ -5371,7 +6106,11 @@ init_build_id();
5371
6106
  function liveUpdateRejections(res, options) {
5372
6107
  if (options.acceptPartialBundle) return [];
5373
6108
  const { rejected } = validateBundle(
5374
- { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
6109
+ {
6110
+ firewall: Array.isArray(res.firewall) ? res.firewall : [],
6111
+ whitelists: Array.isArray(res.whitelists) ? res.whitelists : [],
6112
+ whitelist_keys: res.whitelist_keys
6113
+ },
5375
6114
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
5376
6115
  );
5377
6116
  return rejected;
@@ -5542,7 +6281,8 @@ function normalizeBundle(b, options = {}) {
5542
6281
  const { bundle: checked, rejected } = validateBundle(
5543
6282
  {
5544
6283
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
5545
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
6284
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
6285
+ whitelist_keys: b.whitelist_keys
5546
6286
  },
5547
6287
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
5548
6288
  );
@@ -5562,7 +6302,7 @@ function normalizeBundle(b, options = {}) {
5562
6302
  return {
5563
6303
  firewall: checked.firewall,
5564
6304
  whitelists: checked.whitelists,
5565
- whitelist_keys: b.whitelist_keys ?? {},
6305
+ whitelist_keys: checked.whitelist_keys,
5566
6306
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
5567
6307
  };
5568
6308
  }
@@ -5606,18 +6346,23 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
5606
6346
  };
5607
6347
  }
5608
6348
  function makeRefreshHandler(tick, secret) {
6349
+ let inflight2 = null;
6350
+ const runOnce = () => {
6351
+ if (inflight2) return inflight2;
6352
+ let current;
6353
+ current = Promise.resolve().then(() => tick()).finally(() => {
6354
+ if (inflight2 === current) inflight2 = null;
6355
+ });
6356
+ inflight2 = current;
6357
+ return current;
6358
+ };
5609
6359
  return async (request) => {
5610
6360
  if (!secret) return new Response("not found", { status: 404 });
5611
- let provided = null;
5612
- try {
5613
- provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
5614
- } catch {
5615
- provided = null;
5616
- }
6361
+ const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
5617
6362
  if (provided !== secret) return new Response("forbidden", { status: 403 });
5618
6363
  let refreshed = true;
5619
6364
  try {
5620
- const status = await tick();
6365
+ const status = await runOnce();
5621
6366
  if (status && status.ok === false) refreshed = false;
5622
6367
  } catch {
5623
6368
  refreshed = false;
@@ -5628,6 +6373,7 @@ function makeRefreshHandler(tick, secret) {
5628
6373
 
5629
6374
  // src/protect/detections.js
5630
6375
  init_pulse_token();
6376
+ init_safe_origin();
5631
6377
  var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
5632
6378
  var DEFAULT_FLUSH_MS = 5e3;
5633
6379
  var MAX_BATCH = 50;
@@ -6365,10 +7111,14 @@ function hardensWithoutBody(rule) {
6365
7111
  }
6366
7112
 
6367
7113
  // src/protect/firewall-log.js
7114
+ init_safe_origin();
7115
+ init_bounded_response();
6368
7116
  var DEFAULT_API_BASE = "https://api.patchstack.com";
6369
7117
  var STOP_BUDGET_MS2 = 5e3;
7118
+ var ATTEMPT_TIMEOUT_MS2 = 1e4;
6370
7119
  var DEFAULT_FLUSH_MS2 = 1e3;
6371
7120
  var MAX_BATCH2 = 50;
7121
+ var MAX_QUEUE2 = 500;
6372
7122
  var TOKEN_SKEW_MS2 = 6e4;
6373
7123
  function parseApiKey(apiKey) {
6374
7124
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -6390,14 +7140,16 @@ function resolveApiBase(pulseOrManifestUrl) {
6390
7140
  }
6391
7141
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
6392
7142
  try {
6393
- return new URL(pulseOrManifestUrl).origin;
7143
+ const candidate = new URL(pulseOrManifestUrl).origin;
7144
+ if (isSafeOrigin(candidate)) return candidate;
6394
7145
  } catch {
6395
7146
  }
6396
7147
  }
6397
7148
  const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
6398
7149
  if (typeof endpoint === "string" && endpoint.length > 0) {
6399
7150
  try {
6400
- return new URL(endpoint).origin;
7151
+ const candidate = new URL(endpoint).origin;
7152
+ if (isSafeOrigin(candidate)) return candidate;
6401
7153
  } catch {
6402
7154
  }
6403
7155
  }
@@ -6409,16 +7161,16 @@ function createFirewallLogReporter(opts) {
6409
7161
  return { record() {
6410
7162
  }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
6411
7163
  }
6412
- const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
7164
+ const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
6413
7165
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
6414
7166
  const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
6415
7167
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
6416
7168
  let queue = [];
6417
7169
  let timer = null;
6418
7170
  let stopped = false;
6419
- const outstanding = /* @__PURE__ */ new Set();
7171
+ let inFlight = null;
6420
7172
  let drainPromise = null;
6421
- const lifetime = typeof AbortController === "function" ? new AbortController() : null;
7173
+ let activeController = null;
6422
7174
  let ended = false;
6423
7175
  let cachedToken = null;
6424
7176
  let tokenInflight = null;
@@ -6444,9 +7196,9 @@ function createFirewallLogReporter(opts) {
6444
7196
  })
6445
7197
  });
6446
7198
  if (!res || !res.ok) return null;
6447
- const body = await res.json();
7199
+ const body = await readBoundedJson2(res);
6448
7200
  const token = body?.access_token;
6449
- if (typeof token !== "string" || token.length === 0) return null;
7201
+ if (typeof token !== "string" || token.length === 0 || token.length > 8192 || /[\u0000-\u0020\u007f-\u009f]/.test(token)) return null;
6450
7202
  const expiresIn = Number(body?.expires_in);
6451
7203
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
6452
7204
  cachedToken = { token, expiresAt: Date.now() + ttlMs };
@@ -6464,12 +7216,18 @@ function createFirewallLogReporter(opts) {
6464
7216
  clearTimeout(timer);
6465
7217
  timer = null;
6466
7218
  }
6467
- if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
7219
+ if (ended || typeof fetchImpl !== "function") return Promise.resolve();
7220
+ if (inFlight) return inFlight;
7221
+ if (queue.length === 0) return Promise.resolve();
6468
7222
  const batch = queue.splice(0, MAX_BATCH2);
7223
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
7224
+ activeController = controller;
7225
+ const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
7226
+ attemptTimer?.unref?.();
6469
7227
  let entry;
6470
- const send = (async () => {
7228
+ entry = (async () => {
6471
7229
  try {
6472
- const token = await fetchAccessToken(lifetime?.signal);
7230
+ const token = await fetchAccessToken(controller?.signal);
6473
7231
  if (!token || ended) return;
6474
7232
  const body = new URLSearchParams();
6475
7233
  body.set("type", "firewall");
@@ -6484,19 +7242,24 @@ function createFirewallLogReporter(opts) {
6484
7242
  ...sourceHost ? { "Source-Host": sourceHost } : {}
6485
7243
  },
6486
7244
  body,
6487
- // Both phases carry it, so a shutdown that runs out of time can end either one.
6488
- ...lifetime ? { signal: lifetime.signal } : {}
7245
+ // Both phases carry the attempt controller, so slow transports cannot accumulate work.
7246
+ ...controller ? { signal: controller.signal } : {}
6489
7247
  });
6490
7248
  if (p && typeof p.then === "function") await p.catch(() => {
6491
7249
  });
6492
7250
  } catch {
6493
7251
  } finally {
6494
- outstanding.delete(entry);
7252
+ clearTimeout(attemptTimer);
7253
+ if (activeController === controller) activeController = null;
7254
+ if (inFlight === entry) inFlight = null;
7255
+ if (!stopped && !ended && queue.length > 0 && !timer) {
7256
+ timer = setTimeout(flush, queue.length >= MAX_BATCH2 ? 0 : flushMs);
7257
+ timer?.unref?.();
7258
+ }
6495
7259
  }
6496
7260
  })();
6497
- entry = send;
6498
- outstanding.add(send);
6499
- return send;
7261
+ inFlight = entry;
7262
+ return entry;
6500
7263
  };
6501
7264
  return {
6502
7265
  /**
@@ -6512,6 +7275,7 @@ function createFirewallLogReporter(opts) {
6512
7275
  if (stopped) return;
6513
7276
  const fid = event?.rule?.id;
6514
7277
  if (fid === void 0 || fid === null || fid === "") return;
7278
+ if (queue.length >= MAX_QUEUE2) return;
6515
7279
  queue.push({
6516
7280
  fid,
6517
7281
  method: event.method ?? null,
@@ -6543,9 +7307,10 @@ function createFirewallLogReporter(opts) {
6543
7307
  }
6544
7308
  const terminate = () => {
6545
7309
  ended = true;
6546
- lifetime?.abort();
7310
+ activeController?.abort();
7311
+ activeController = null;
6547
7312
  queue = [];
6548
- outstanding.clear();
7313
+ inFlight = null;
6549
7314
  };
6550
7315
  let budget = null;
6551
7316
  const spent = new Promise((resolve) => {
@@ -6557,8 +7322,8 @@ function createFirewallLogReporter(opts) {
6557
7322
  });
6558
7323
  const work = (async () => {
6559
7324
  while (!ended) {
6560
- if (outstanding.size > 0) {
6561
- await Promise.all([...outstanding]);
7325
+ if (inFlight) {
7326
+ await inFlight;
6562
7327
  continue;
6563
7328
  }
6564
7329
  if (queue.length === 0) break;
@@ -6582,10 +7347,107 @@ function telemetryEnabled() {
6582
7347
 
6583
7348
  // src/protect/supabase-guard.js
6584
7349
  var GUARD_PATH = "/_patchstack/guard";
6585
- var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
6586
- function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
7350
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7351
+ var DEFAULT_TIMEOUT_MS = 3e4;
7352
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7353
+ "connection",
7354
+ "cookie",
7355
+ "cookie2",
7356
+ "forwarded",
7357
+ "host",
7358
+ "keep-alive",
7359
+ "proxy-authenticate",
7360
+ "proxy-authorization",
7361
+ "te",
7362
+ "trailer",
7363
+ "transfer-encoding",
7364
+ "upgrade",
7365
+ "x-forwarded-for",
7366
+ "x-forwarded-host",
7367
+ "x-forwarded-port",
7368
+ "x-forwarded-proto",
7369
+ "x-ps-target",
7370
+ "x-real-ip"
7371
+ ]);
7372
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7373
+ "connection",
7374
+ "content-encoding",
7375
+ "content-length",
7376
+ "keep-alive",
7377
+ "proxy-authenticate",
7378
+ "proxy-authorization",
7379
+ "set-cookie",
7380
+ "set-cookie2",
7381
+ "te",
7382
+ "trailer",
7383
+ "transfer-encoding",
7384
+ "upgrade"
7385
+ ]);
7386
+ var BodyTooLargeError = class extends Error {
7387
+ };
7388
+ async function boundedBody(request, maxBytes) {
7389
+ const stated2 = Number(request.headers.get("content-length"));
7390
+ if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7391
+ if (!request.body) return void 0;
7392
+ const reader = request.body.getReader();
7393
+ const chunks = [];
7394
+ let total = 0;
7395
+ try {
7396
+ for (; ; ) {
7397
+ const { done, value } = await reader.read();
7398
+ if (done) break;
7399
+ total += value.byteLength;
7400
+ if (total > maxBytes) {
7401
+ void reader.cancel().catch(() => {
7402
+ });
7403
+ throw new BodyTooLargeError();
7404
+ }
7405
+ chunks.push(value);
7406
+ }
7407
+ } finally {
7408
+ reader.releaseLock();
7409
+ }
7410
+ const body = new Uint8Array(total);
7411
+ let offset = 0;
7412
+ for (const chunk of chunks) {
7413
+ body.set(chunk, offset);
7414
+ offset += chunk.byteLength;
7415
+ }
7416
+ return body;
7417
+ }
7418
+ function requestHeaders(input) {
7419
+ const output = new Headers();
7420
+ const connectionTokens = new Set(
7421
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7422
+ );
7423
+ input.forEach((value, key) => {
7424
+ const lower = key.toLowerCase();
7425
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7426
+ });
7427
+ return output;
7428
+ }
7429
+ function responseHeaders(input) {
7430
+ const output = new Headers();
7431
+ const connectionTokens = new Set(
7432
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7433
+ );
7434
+ input.forEach((value, key) => {
7435
+ const lower = key.toLowerCase();
7436
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7437
+ });
7438
+ return output;
7439
+ }
7440
+ function createSupabaseGuard({
7441
+ protection,
7442
+ supabaseUrl,
7443
+ fetchImpl = fetch,
7444
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7445
+ timeoutMs = DEFAULT_TIMEOUT_MS
7446
+ }) {
6587
7447
  const guard = protection.fetchGuard();
6588
7448
  const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7449
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7450
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
6589
7451
  return async function handleGuardRequest(request) {
6590
7452
  const target = request.headers.get("x-ps-target");
6591
7453
  if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
@@ -6599,29 +7461,45 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
6599
7461
  return new Response("patchstack: target not allowed", { status: 403 });
6600
7462
  }
6601
7463
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
6602
- const bodyText = hasBody ? await request.text() : "";
7464
+ let body;
7465
+ try {
7466
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
7467
+ } catch (error) {
7468
+ if (error instanceof BodyTooLargeError) {
7469
+ return new Response("patchstack: request body too large", { status: 413 });
7470
+ }
7471
+ throw error;
7472
+ }
7473
+ const forwardHeaders = requestHeaders(request.headers);
6603
7474
  const evalReq = new Request(targetUrl.toString(), {
6604
7475
  method: request.method,
7476
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
7477
+ // sanitized at the cross-origin boundary below.
6605
7478
  headers: request.headers,
6606
- body: hasBody ? bodyText : void 0
7479
+ body
6607
7480
  });
6608
7481
  const blocked = await guard(evalReq);
6609
7482
  if (blocked) return blocked;
6610
- const forwardHeaders = new Headers(request.headers);
6611
- forwardHeaders.delete("x-ps-target");
6612
- forwardHeaders.delete("host");
6613
- const upstream = await fetchImpl(targetUrl.toString(), {
6614
- method: request.method,
6615
- headers: forwardHeaders,
6616
- body: hasBody ? bodyText : void 0,
6617
- redirect: "manual"
6618
- });
6619
- const outHeaders = new Headers();
6620
- upstream.headers.forEach((value, key) => {
6621
- if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
7483
+ let upstream;
7484
+ try {
7485
+ upstream = await fetchImpl(targetUrl.toString(), {
7486
+ method: request.method,
7487
+ headers: forwardHeaders,
7488
+ body,
7489
+ redirect: "manual",
7490
+ signal: AbortSignal.timeout(upstreamTimeout)
7491
+ });
7492
+ } catch (error) {
7493
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
7494
+ return new Response("patchstack: upstream timed out", { status: 504 });
7495
+ }
7496
+ throw error;
7497
+ }
7498
+ const forwarded = new Response(upstream.body, {
7499
+ status: upstream.status,
7500
+ statusText: upstream.statusText,
7501
+ headers: responseHeaders(upstream.headers)
6622
7502
  });
6623
- const buf = await upstream.arrayBuffer();
6624
- const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
6625
7503
  return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
6626
7504
  };
6627
7505
  }
@@ -6924,9 +7802,13 @@ async function createProtection(options = {}) {
6924
7802
  for (const name of Object.keys(headers)) {
6925
7803
  const value = headers[name];
6926
7804
  if (typeof value === "string") {
6927
- headers[name] = applyRedactors(value, spanRedactors, mask);
7805
+ setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
6928
7806
  } else if (Array.isArray(value)) {
6929
- headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
7807
+ setOwn(
7808
+ headers,
7809
+ name,
7810
+ value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
7811
+ );
6930
7812
  }
6931
7813
  }
6932
7814
  }
@@ -6976,7 +7858,13 @@ async function createProtection(options = {}) {
6976
7858
  let result;
6977
7859
  let shaped;
6978
7860
  try {
6979
- shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
7861
+ shaped = await fromFetchRequest(request, {
7862
+ trustedProxy: options.trustedProxy,
7863
+ maxBodyBytes: options.maxBodyBytes
7864
+ });
7865
+ if (shaped?._bodyInspectionSkip) {
7866
+ recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
7867
+ }
6980
7868
  result = engine.evaluate(shaped);
6981
7869
  } catch (err) {
6982
7870
  notify(onError, err, "onError");
@@ -7066,7 +7954,7 @@ async function createProtection(options = {}) {
7066
7954
  const out = {};
7067
7955
  for (const [name, value] of writeHeadEntries(headers)) {
7068
7956
  const key = name.toLowerCase();
7069
- out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
7957
+ appendOwn(out, key, value);
7070
7958
  }
7071
7959
  return out;
7072
7960
  };
@@ -7602,7 +8490,6 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
7602
8490
  const reader = body.getReader();
7603
8491
  const chunks = [];
7604
8492
  let size = 0;
7605
- let over = false;
7606
8493
  let sniffed = !sniff;
7607
8494
  try {
7608
8495
  for (; ; ) {
@@ -7611,20 +8498,27 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
7611
8498
  if (!value) continue;
7612
8499
  if (!sniffed) {
7613
8500
  sniffed = true;
7614
- if (looksBinary(value)) return { skip: "binary-body" };
8501
+ if (looksBinary(value)) {
8502
+ void reader.cancel().catch(() => {
8503
+ });
8504
+ return { skip: "binary-body" };
8505
+ }
7615
8506
  }
7616
8507
  size += value.byteLength;
7617
- if (over) continue;
7618
- if (size > cap) {
7619
- over = true;
7620
- continue;
8508
+ if (size >= cap) {
8509
+ void reader.cancel().catch(() => {
8510
+ });
8511
+ return { skip: "body-cap" };
7621
8512
  }
7622
8513
  chunks.push(value);
7623
8514
  }
7624
8515
  } catch {
8516
+ void reader.cancel().catch(() => {
8517
+ });
7625
8518
  return { skip: "read-failed" };
8519
+ } finally {
8520
+ reader.releaseLock();
7626
8521
  }
7627
- if (over) return { skip: "body-cap" };
7628
8522
  try {
7629
8523
  return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
7630
8524
  } catch {
@@ -7652,10 +8546,10 @@ function concatBytes(chunks, total) {
7652
8546
  function headerObject(headers) {
7653
8547
  const out = {};
7654
8548
  headers?.forEach?.((v, k) => {
7655
- out[k.toLowerCase()] = v;
8549
+ setOwn(out, k.toLowerCase(), v);
7656
8550
  });
7657
8551
  const setCookies = headers?.getSetCookie?.();
7658
- if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
8552
+ if (setCookies && setCookies.length) setOwn(out, "set-cookie", setCookies);
7659
8553
  return out;
7660
8554
  }
7661
8555
  function hasSpanMutations(rule) {
@@ -7824,7 +8718,7 @@ function isHeaderMutation(action) {
7824
8718
  }
7825
8719
  function applyHeaderMutation(headers, rule) {
7826
8720
  if (rule.action === "remove-header") {
7827
- for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
8721
+ for (const name of rule.remove_headers ?? []) setOwn(headers, String(name).toLowerCase(), null);
7828
8722
  return;
7829
8723
  }
7830
8724
  if (rule.action === "set-header") {
@@ -7833,7 +8727,7 @@ function applyHeaderMutation(headers, rule) {
7833
8727
  const key = String(name).toLowerCase();
7834
8728
  const present2 = headers[key] != null && headers[key] !== "";
7835
8729
  if (ensure && present2) continue;
7836
- headers[key] = String(value);
8730
+ setOwn(headers, key, String(value));
7837
8731
  }
7838
8732
  return;
7839
8733
  }