@patchstack/connect 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protect.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;
61
256
  }
62
- if (inflight !== null) return inflight;
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;
262
+ }
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) {
@@ -185,8 +369,8 @@ var init_hosting = __esm({
185
369
  RULES = [
186
370
  { platform: "netlify", any: ["NETLIFY", "NETLIFY_BUILD_BASE", "DEPLOY_PRIME_URL"] },
187
371
  { platform: "vercel", any: ["VERCEL", "VERCEL_ENV", "VERCEL_URL"] },
188
- { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "CLOUDFLARE_ACCOUNT_ID"] },
189
- { platform: "aws", any: ["AWS_APP_ID", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
372
+ { platform: "cloudflare", any: ["CF_PAGES", "CF_PAGES_URL", "WORKERS_CI", "WORKERS_CI_BRANCH", "CLOUDFLARE_ACCOUNT_ID"] },
373
+ { platform: "aws", any: ["AWS_APP_ID", "AWS_BRANCH", "AWS_LAMBDA_FUNCTION_NAME", "AWS_EXECUTION_ENV"] },
190
374
  { platform: "render", any: ["RENDER", "RENDER_SERVICE_ID", "RENDER_EXTERNAL_URL"] },
191
375
  { platform: "railway", any: ["RAILWAY_ENVIRONMENT", "RAILWAY_ENVIRONMENT_NAME", "RAILWAY_PROJECT_ID"] },
192
376
  { platform: "fly", any: ["FLY_APP_NAME", "FLY_REGION", "FLY_ALLOC_ID"] },
@@ -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,13 +542,18 @@ 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
  });
327
552
 
328
553
  // src/environment.ts
554
+ function environmentFromBranch(variable, branch) {
555
+ return PRODUCTION_BRANCHES.has(branch.toLowerCase()) ? { environment: "production", evidence: `${variable}=${branch} (a production branch by name)` } : { environment: "sandbox", evidence: `${variable}=${branch} (not a production branch by name)` };
556
+ }
329
557
  function detectHostedBuilder(cwd) {
330
558
  let declared;
331
559
  try {
@@ -358,13 +586,21 @@ function inferEnvironment(env = process.env, builder = null) {
358
586
  }
359
587
  return { environment: "local", evidence: [] };
360
588
  }
361
- var import_node_fs, import_node_path, set, DISCRIMINATORS, HOSTED_BUILDER_PACKAGES;
589
+ var import_node_fs, import_node_path, set, PRODUCTION_BRANCHES, DISCRIMINATORS, HOSTED_BUILDER_PACKAGES;
362
590
  var init_environment = __esm({
363
591
  "src/environment.ts"() {
364
592
  "use strict";
365
593
  import_node_fs = require("fs");
366
594
  import_node_path = __toESM(require("path"), 1);
367
595
  set = (value) => value !== void 0 && value !== "";
596
+ PRODUCTION_BRANCHES = /* @__PURE__ */ new Set([
597
+ "main",
598
+ "master",
599
+ "production",
600
+ "prod",
601
+ "release",
602
+ "live"
603
+ ]);
368
604
  DISCRIMINATORS = [
369
605
  {
370
606
  platform: "vercel",
@@ -393,6 +629,79 @@ var init_environment = __esm({
393
629
  if (!set(env.RAILWAY_ENVIRONMENT_NAME)) return null;
394
630
  return env.RAILWAY_ENVIRONMENT_NAME === "production" ? { environment: "production", evidence: "RAILWAY_ENVIRONMENT_NAME=production" } : { environment: "sandbox", evidence: `RAILWAY_ENVIRONMENT_NAME=${env.RAILWAY_ENVIRONMENT_NAME}` };
395
631
  }
632
+ },
633
+ {
634
+ // Pages and Workers Builds both name the branch and neither names the tier.
635
+ platform: "cloudflare",
636
+ read: (env) => {
637
+ if (set(env.CF_PAGES) && set(env.CF_PAGES_BRANCH)) {
638
+ return environmentFromBranch("CF_PAGES_BRANCH", env.CF_PAGES_BRANCH);
639
+ }
640
+ if (set(env.WORKERS_CI) && set(env.WORKERS_CI_BRANCH)) {
641
+ return environmentFromBranch("WORKERS_CI_BRANCH", env.WORKERS_CI_BRANCH);
642
+ }
643
+ return null;
644
+ }
645
+ },
646
+ {
647
+ // Amplify names the branch, and separately whether the build is a pull-request preview.
648
+ platform: "aws",
649
+ read: (env) => {
650
+ if (!set(env.AWS_APP_ID) || !set(env.AWS_BRANCH)) return null;
651
+ return set(env.AWS_PULL_REQUEST_ID) ? { environment: "sandbox", evidence: "AWS_PULL_REQUEST_ID set (a pull-request preview)" } : environmentFromBranch("AWS_BRANCH", env.AWS_BRANCH);
652
+ }
653
+ },
654
+ {
655
+ // A Deployment build carries `REPLIT_DEPLOYMENT`; the workspace carries `REPL_ID` alone. Read here,
656
+ // before the hosted-builder rule, so a workspace build in a Replit project is not its publish step.
657
+ platform: "replit",
658
+ read: (env) => {
659
+ if (set(env.REPLIT_DEPLOYMENT)) {
660
+ return { environment: "production", evidence: "REPLIT_DEPLOYMENT set (a Replit Deployment build)" };
661
+ }
662
+ if (set(env.REPL_ID)) {
663
+ return { environment: "sandbox", evidence: "REPL_ID set without REPLIT_DEPLOYMENT (the workspace)" };
664
+ }
665
+ return null;
666
+ }
667
+ },
668
+ {
669
+ // GitHub Pages deploys build here. A pull request checks out a merge ref whose name says nothing, so
670
+ // the event is read first; a tag is a release; otherwise the branch name decides.
671
+ platform: "github-actions",
672
+ read: (env) => {
673
+ if (env.GITHUB_ACTIONS !== "true") return null;
674
+ if (set(env.GITHUB_EVENT_NAME) && env.GITHUB_EVENT_NAME.startsWith("pull_request")) {
675
+ return { environment: "sandbox", evidence: `GITHUB_EVENT_NAME=${env.GITHUB_EVENT_NAME} (a pull-request build)` };
676
+ }
677
+ if (env.GITHUB_REF_TYPE === "tag") {
678
+ return { environment: "production", evidence: "GITHUB_REF_TYPE=tag (a tag build)" };
679
+ }
680
+ if (!set(env.GITHUB_REF_NAME)) return null;
681
+ return environmentFromBranch("GITHUB_REF_NAME", env.GITHUB_REF_NAME);
682
+ }
683
+ },
684
+ {
685
+ // The tier, when a job declares one, is GitLab's own word for it. Without one: a merge request is a
686
+ // check, a tag is a release, the default branch goes live, and any other branch name decides.
687
+ platform: "gitlab-ci",
688
+ read: (env) => {
689
+ if (env.GITLAB_CI !== "true") return null;
690
+ if (set(env.CI_ENVIRONMENT_TIER)) {
691
+ return env.CI_ENVIRONMENT_TIER === "production" ? { environment: "production", evidence: "CI_ENVIRONMENT_TIER=production" } : { environment: "sandbox", evidence: `CI_ENVIRONMENT_TIER=${env.CI_ENVIRONMENT_TIER}` };
692
+ }
693
+ if (set(env.CI_MERGE_REQUEST_IID)) {
694
+ return { environment: "sandbox", evidence: "CI_MERGE_REQUEST_IID set (a merge-request build)" };
695
+ }
696
+ if (set(env.CI_COMMIT_TAG)) {
697
+ return { environment: "production", evidence: "CI_COMMIT_TAG set (a tag build)" };
698
+ }
699
+ if (!set(env.CI_COMMIT_BRANCH)) return null;
700
+ if (set(env.CI_DEFAULT_BRANCH) && env.CI_COMMIT_BRANCH === env.CI_DEFAULT_BRANCH) {
701
+ return { environment: "production", evidence: `CI_COMMIT_BRANCH=${env.CI_COMMIT_BRANCH} (the default branch)` };
702
+ }
703
+ return environmentFromBranch("CI_COMMIT_BRANCH", env.CI_COMMIT_BRANCH);
704
+ }
396
705
  }
397
706
  ];
398
707
  HOSTED_BUILDER_PACKAGES = [
@@ -610,8 +919,16 @@ async function resolveConfig(options) {
610
919
  const fromSecretFile = await readSecretFile(options.cwd);
611
920
  const fromEnv = readEnv();
612
921
  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;
922
+ const endpointRaw = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
923
+ const endpointTrusted = endpointRaw === DEFAULT_ENDPOINT || options.cliEndpoint !== void 0 || fromEnv.endpoint !== void 0 || fromFile.endpoint === void 0;
924
+ const endpoint = safeRemoteUrl(endpointRaw);
925
+ if (endpoint === null) {
926
+ throw new PatchstackError(
927
+ "Patchstack endpoints must be valid HTTPS URLs, except for localhost development endpoints.",
928
+ "CONFIG_INVALID"
929
+ );
930
+ }
931
+ const timeoutMs = fromEnv.timeoutMs ?? fromFile.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
615
932
  const environmentRaw = fromEnv.environment ?? fromFile.environment;
616
933
  if (environmentRaw !== void 0 && !isEnvironment(environmentRaw)) {
617
934
  throw new PatchstackError(
@@ -622,7 +939,7 @@ async function resolveConfig(options) {
622
939
  const inferred = environmentRaw === void 0 ? inferEnvironment(process.env, detectHostedBuilder(options.cwd)) : null;
623
940
  const environment = environmentRaw ?? inferred.environment;
624
941
  const environmentEvidence = inferred?.evidence ?? [];
625
- if (siteUuid !== null && siteUuid.length > 0 && !isUuid(siteUuid)) {
942
+ if (siteUuid !== null && siteUuid.length > 0 && !isCanonicalUuid(siteUuid)) {
626
943
  throw new PatchstackError(
627
944
  `Site UUID "${siteUuid}" does not look like a valid UUID.`,
628
945
  "CONFIG_INVALID"
@@ -645,6 +962,7 @@ async function resolveConfig(options) {
645
962
  siteUrl: identity.url,
646
963
  siteName: identity.name,
647
964
  endpoint,
965
+ endpointTrusted,
648
966
  timeoutMs,
649
967
  environment,
650
968
  environmentEvidence,
@@ -663,8 +981,15 @@ async function readJsonFile(cwd, filename) {
663
981
  const target = import_node_path3.default.join(cwd, filename);
664
982
  let raw;
665
983
  try {
984
+ if ((await (0, import_promises2.lstat)(target)).isSymbolicLink()) {
985
+ throw new PatchstackError(
986
+ `Could not read ${target}: configuration files must not be symbolic links.`,
987
+ "CONFIG_INVALID"
988
+ );
989
+ }
666
990
  raw = await (0, import_promises2.readFile)(target, "utf8");
667
991
  } catch (err) {
992
+ if (err instanceof PatchstackError) throw err;
668
993
  if (err.code === "ENOENT") {
669
994
  return {};
670
995
  }
@@ -709,23 +1034,22 @@ function readEnv() {
709
1034
  environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
710
1035
  };
711
1036
  }
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
1037
  function isEnvironment(value) {
716
1038
  return value === "production" || value === "sandbox" || value === "local";
717
1039
  }
718
- var import_promises2, import_node_path3, CONFIG_FILENAME, SECRET_FILENAME;
1040
+ var import_promises2, import_node_path3, import_node_crypto, CONFIG_FILENAME, SECRET_FILENAME;
719
1041
  var init_config = __esm({
720
1042
  "src/config.ts"() {
721
1043
  "use strict";
722
1044
  import_promises2 = require("fs/promises");
723
1045
  import_node_path3 = __toESM(require("path"), 1);
1046
+ import_node_crypto = require("crypto");
724
1047
  init_types();
725
1048
  init_client();
726
1049
  init_environment();
727
1050
  init_site_url();
728
1051
  init_site_name();
1052
+ init_endpoint_policy();
729
1053
  CONFIG_FILENAME = ".patchstackrc.json";
730
1054
  SECRET_FILENAME = ".patchstackrc.local.json";
731
1055
  }
@@ -2222,27 +2546,52 @@ function clientIpFields(resolved) {
2222
2546
  return established && address !== null ? { client_ip: address, client_ip_source: resolved.source } : { client_ip_source: "unavailable" };
2223
2547
  }
2224
2548
 
2225
- // src/protect/safe-origin.js
2226
- function isSafeOrigin(value) {
2549
+ // src/protect/engine/client.js
2550
+ init_safe_origin();
2551
+
2552
+ // src/protect/engine/response-json.js
2553
+ var MAX_RULE_RESPONSE_BYTES = 5 * 1024 * 1024;
2554
+ async function readBoundedJson(response, maxBytes = MAX_RULE_RESPONSE_BYTES) {
2555
+ const stated2 = Number(response.headers?.get?.("content-length"));
2556
+ if (Number.isFinite(stated2) && stated2 > maxBytes) {
2557
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
2558
+ }
2559
+ const stream = response.body;
2560
+ if (!stream || typeof stream.getReader !== "function") {
2561
+ if (typeof response.text === "function") {
2562
+ const text = await response.text();
2563
+ if (new TextEncoder().encode(text).byteLength > maxBytes) {
2564
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
2565
+ }
2566
+ return JSON.parse(text);
2567
+ }
2568
+ throw new Error("rule response body cannot be read with a size bound");
2569
+ }
2570
+ const reader = stream.getReader();
2571
+ const chunks = [];
2572
+ let total = 0;
2227
2573
  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;
2574
+ for (; ; ) {
2575
+ const { done, value } = await reader.read();
2576
+ if (done) break;
2577
+ total += value.byteLength;
2578
+ if (total > maxBytes) {
2579
+ void reader.cancel().catch(() => {
2580
+ });
2581
+ throw new Error(`rule response exceeds ${maxBytes} bytes`);
2582
+ }
2583
+ chunks.push(value);
2584
+ }
2585
+ } finally {
2586
+ reader.releaseLock();
2233
2587
  }
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);
2588
+ const bytes = new Uint8Array(total);
2589
+ let offset = 0;
2590
+ for (const chunk of chunks) {
2591
+ bytes.set(chunk, offset);
2592
+ offset += chunk.byteLength;
2593
+ }
2594
+ return JSON.parse(new TextDecoder().decode(bytes));
2246
2595
  }
2247
2596
 
2248
2597
  // src/protect/engine/client.js
@@ -2301,7 +2650,7 @@ var PatchstackRuleClient = class {
2301
2650
  whitelist_keys: {}
2302
2651
  };
2303
2652
  }
2304
- const data = await response.json();
2653
+ const data = await readBoundedJson(response);
2305
2654
  if (!data || !Array.isArray(data.firewall)) {
2306
2655
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2307
2656
  }
@@ -2338,6 +2687,22 @@ var PatchstackRuleClient = class {
2338
2687
  }
2339
2688
  };
2340
2689
 
2690
+ // src/protect/engine/own.js
2691
+ function setOwn(target, key, value) {
2692
+ Object.defineProperty(target, key, {
2693
+ configurable: true,
2694
+ enumerable: true,
2695
+ writable: true,
2696
+ value
2697
+ });
2698
+ return value;
2699
+ }
2700
+ function appendOwn(target, key, value) {
2701
+ if (!Object.hasOwn(target, key)) return setOwn(target, key, value);
2702
+ const previous = target[key];
2703
+ return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
2704
+ }
2705
+
2341
2706
  // src/protect/engine/request.js
2342
2707
  var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
2343
2708
  var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
@@ -2456,8 +2821,8 @@ var RequestResolver = class {
2456
2821
  }
2457
2822
  if (key.startsWith("header.")) {
2458
2823
  const name = key.slice("header.".length).toLowerCase();
2459
- const value = (resp.headers ?? {})[name];
2460
- return value !== void 0 ? [value] : [];
2824
+ const headers = resp.headers ?? {};
2825
+ return Object.hasOwn(headers, name) ? [headers[name]] : [];
2461
2826
  }
2462
2827
  return [];
2463
2828
  }
@@ -2552,7 +2917,7 @@ var RequestResolver = class {
2552
2917
  ...this.#resolveWildcard(cookies, key)
2553
2918
  ];
2554
2919
  }
2555
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? cookies[key];
2920
+ const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
2556
2921
  return value !== void 0 ? [value] : [];
2557
2922
  }
2558
2923
  #resolveCookie(key) {
@@ -2560,8 +2925,7 @@ var RequestResolver = class {
2560
2925
  if (key.endsWith("*")) {
2561
2926
  return this.#resolveWildcard(cookies, key);
2562
2927
  }
2563
- const value = cookies[key];
2564
- return value !== void 0 ? [value] : [];
2928
+ return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
2565
2929
  }
2566
2930
  #resolveServer(key) {
2567
2931
  const req = this.#req;
@@ -2737,7 +3101,7 @@ var RequestResolver = class {
2737
3101
  }
2738
3102
  const name = pair.substring(0, eqIndex).trim();
2739
3103
  const value = pair.substring(eqIndex + 1).trim();
2740
- cookies[name] = value;
3104
+ setOwn(cookies, name, value);
2741
3105
  }
2742
3106
  this.#cookies = cookies;
2743
3107
  return this.#cookies;
@@ -2894,30 +3258,113 @@ function normalizeWhitespace(value) {
2894
3258
  result = result.replace(/ {2,}/g, " ");
2895
3259
  return result;
2896
3260
  }
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]";
3261
+ var MAX_RAW_CHARS = 1024 * 1024;
3262
+ var MAX_RAW_NODES = 25e3;
3263
+ var MAX_RAW_DEPTH = 200;
3264
+ var MAX_RAW_ENTRIES = 2e3;
3265
+ function serializeForRawDetection(body) {
3266
+ if (body === null || body === void 0) return "";
3267
+ if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
3268
+ const visited = /* @__PURE__ */ new Set();
3269
+ const output = [];
3270
+ const stack = [{ kind: "value", value: body, depth: 0, root: true }];
3271
+ let chars = 0;
3272
+ let nodes = 0;
3273
+ const append = (text) => {
3274
+ if (chars >= MAX_RAW_CHARS) return;
3275
+ const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
3276
+ output.push(piece);
3277
+ chars += piece.length;
3278
+ };
3279
+ while (stack.length > 0 && chars < MAX_RAW_CHARS) {
3280
+ const task = stack.pop();
3281
+ if (task.kind === "text") {
3282
+ append(task.text);
3283
+ continue;
3284
+ }
3285
+ if (task.kind === "key") {
3286
+ append(boundedJsonString(task.value));
3287
+ append(":");
3288
+ continue;
3289
+ }
3290
+ nodes++;
3291
+ if (nodes > MAX_RAW_NODES) {
3292
+ append('"[NodeLimit]"');
3293
+ continue;
3294
+ }
3295
+ const value = task.value;
3296
+ if (value === null || value === void 0) {
3297
+ append(task.root ? "" : "null");
3298
+ continue;
3299
+ }
3300
+ if (typeof value === "string") {
3301
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
3302
+ continue;
3303
+ }
3304
+ if (typeof value !== "object") {
3305
+ append(String(value).slice(0, MAX_RAW_CHARS));
3306
+ continue;
3307
+ }
3308
+ if (visited.has(value)) {
3309
+ append("[Circular]");
3310
+ continue;
3311
+ }
3312
+ if (task.depth >= MAX_RAW_DEPTH) {
3313
+ append('"[DepthLimit]"');
3314
+ continue;
3315
+ }
3316
+ visited.add(value);
3317
+ if (Array.isArray(value)) {
3318
+ const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
3319
+ const items = [];
3320
+ for (let i = 0; i < length2; i++) {
3321
+ if (i > 0) items.push({ kind: "text", text: "," });
3322
+ items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
3323
+ }
3324
+ if (value.length > length2) {
3325
+ if (length2 > 0) items.push({ kind: "text", text: "," });
3326
+ items.push({ kind: "text", text: '"[EntryLimit]"' });
3327
+ }
3328
+ stack.push({ kind: "text", text: "]" });
3329
+ for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
3330
+ stack.push({ kind: "text", text: "[" });
3331
+ continue;
3332
+ }
3333
+ let keys;
3334
+ try {
3335
+ keys = Object.getOwnPropertyNames(value);
3336
+ } catch {
3337
+ append('"[Unreadable]"');
3338
+ continue;
3339
+ }
3340
+ const length = Math.min(keys.length, MAX_RAW_ENTRIES);
3341
+ const entries = [];
3342
+ for (let i = 0; i < length; i++) {
3343
+ const key = keys[i];
3344
+ if (i > 0) entries.push({ kind: "text", text: "," });
3345
+ entries.push({ kind: "key", value: key });
3346
+ entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
3347
+ }
3348
+ if (keys.length > length) {
3349
+ if (length > 0) entries.push({ kind: "text", text: "," });
3350
+ entries.push({ kind: "text", text: '"[EntryLimit]":null' });
3351
+ }
3352
+ stack.push({ kind: "text", text: "}" });
3353
+ for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
3354
+ stack.push({ kind: "text", text: "{" });
2909
3355
  }
2910
- visited.add(body);
2911
- if (Array.isArray(body)) {
2912
- const items = body.map((item) => serializeForRawDetection(item, visited, false));
2913
- return "[" + items.join(",") + "]";
3356
+ return output.join("");
3357
+ }
3358
+ function boundedJsonString(value) {
3359
+ return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
3360
+ }
3361
+ function readOwnValue(value, key) {
3362
+ try {
3363
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
3364
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
3365
+ } catch {
3366
+ return "[Unreadable]";
2914
3367
  }
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
3368
  }
2922
3369
  var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
2923
3370
  function requestField(req, key) {
@@ -2959,7 +3406,7 @@ function normalizeObject(value, options = {}, depth = 0) {
2959
3406
  if (typeof value === "object" && value !== null) {
2960
3407
  const result = {};
2961
3408
  for (const [key, val] of Object.entries(value)) {
2962
- result[key] = normalizeObject(val, options, depth + 1);
3409
+ setOwn(result, key, normalizeObject(val, options, depth + 1));
2963
3410
  }
2964
3411
  return result;
2965
3412
  }
@@ -2976,6 +3423,111 @@ var REDOS_PATTERNS = [
2976
3423
  new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
2977
3424
  // nested alternation under a quantifier
2978
3425
  ];
3426
+ function hasAdjacentUnbounded(body, flags) {
3427
+ let previousUnboundedAtom = null;
3428
+ for (let i = 0; i < body.length; ) {
3429
+ const start = i;
3430
+ const ch = body[i];
3431
+ let assertion = false;
3432
+ let literal = null;
3433
+ let atom = ch;
3434
+ if (ch === "\\") {
3435
+ const escaped = body[i + 1] ?? "";
3436
+ assertion = /[bBAZz]/.test(escaped);
3437
+ if (!/[bBAZzdswDSWnrtvf0-9pPkKxuc]/.test(escaped)) literal = escaped;
3438
+ i += Math.min(2, body.length - i);
3439
+ atom = body.slice(start, i);
3440
+ } else if (ch === "[") {
3441
+ i++;
3442
+ while (i < body.length) {
3443
+ if (body[i] === "\\") i += 2;
3444
+ else if (body[i++] === "]") break;
3445
+ }
3446
+ atom = body.slice(start, i);
3447
+ } else if (ch === ")") {
3448
+ i++;
3449
+ atom = ".";
3450
+ } else if (ch === "(") {
3451
+ const lookaround = body.startsWith("(?=", i) || body.startsWith("(?!", i) || body.startsWith("(?<=", i) || body.startsWith("(?<!", i);
3452
+ if (lookaround) {
3453
+ i = groupEnd(body, i);
3454
+ continue;
3455
+ }
3456
+ i++;
3457
+ if (body.startsWith("?:", i)) i += 2;
3458
+ else if (body.startsWith("?<", i)) {
3459
+ const nameEnd = body.indexOf(">", i + 2);
3460
+ if (nameEnd !== -1) i = nameEnd + 1;
3461
+ }
3462
+ continue;
3463
+ } else if (ch === "|") {
3464
+ previousUnboundedAtom = null;
3465
+ i++;
3466
+ continue;
3467
+ } else if ("^$".includes(ch)) {
3468
+ i++;
3469
+ continue;
3470
+ } else if ("?*+{}".includes(ch)) {
3471
+ previousUnboundedAtom = null;
3472
+ i++;
3473
+ continue;
3474
+ } else {
3475
+ if (ch !== ".") literal = ch;
3476
+ i++;
3477
+ }
3478
+ if (assertion) continue;
3479
+ let unbounded = false;
3480
+ let mayBeEmpty = false;
3481
+ if (body[i] === "*" || body[i] === "+") {
3482
+ unbounded = true;
3483
+ mayBeEmpty = body[i] === "*";
3484
+ i++;
3485
+ } else if (body[i] === "{") {
3486
+ const quantifier = /^\{(\d+)(?:,(\d*))?\}/.exec(body.slice(i));
3487
+ if (quantifier) {
3488
+ unbounded = quantifier[2] === "";
3489
+ mayBeEmpty = Number(quantifier[1]) === 0;
3490
+ i += quantifier[0].length;
3491
+ }
3492
+ } else if (body[i] === "?") {
3493
+ mayBeEmpty = true;
3494
+ i++;
3495
+ }
3496
+ if (unbounded && (body[i] === "?" || body[i] === "+")) i++;
3497
+ if (unbounded && previousUnboundedAtom !== null) return true;
3498
+ if (unbounded) {
3499
+ previousUnboundedAtom = atom;
3500
+ } else if (previousUnboundedAtom !== null && !mayBeEmpty) {
3501
+ try {
3502
+ const atomFlags = flags.replace(/[^iu]/g, "");
3503
+ 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";
3504
+ const excludedLiteral = literal !== null && !new RegExp(`^(?:${previousUnboundedAtom})$`, atomFlags).test(literal);
3505
+ if (complementaryClass || excludedLiteral) {
3506
+ previousUnboundedAtom = null;
3507
+ }
3508
+ } catch {
3509
+ }
3510
+ }
3511
+ if (i === start) i++;
3512
+ }
3513
+ return false;
3514
+ }
3515
+ function groupEnd(body, start) {
3516
+ let depth = 0;
3517
+ let inClass = false;
3518
+ for (let i = start; i < body.length; i++) {
3519
+ const ch = body[i];
3520
+ if (ch === "\\") {
3521
+ i++;
3522
+ continue;
3523
+ }
3524
+ if (ch === "[") inClass = true;
3525
+ else if (ch === "]") inClass = false;
3526
+ else if (!inClass && ch === "(") depth++;
3527
+ else if (!inClass && ch === ")" && --depth === 0) return i + 1;
3528
+ }
3529
+ return body.length;
3530
+ }
2979
3531
  var warnedRejectedPatterns = /* @__PURE__ */ new Set();
2980
3532
  function warnRejectedPatternOnce(pattern) {
2981
3533
  const key = String(pattern);
@@ -2993,15 +3545,14 @@ function safeRegExp(pattern) {
2993
3545
  if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
2994
3546
  return null;
2995
3547
  }
2996
- for (const dangerous of REDOS_PATTERNS) {
2997
- if (dangerous.test(pattern)) {
2998
- return null;
2999
- }
3000
- }
3001
3548
  const match = pattern.match(/^\/(.+?)\/([gimsuy]*)$/s);
3002
3549
  if (!match) {
3003
3550
  return null;
3004
3551
  }
3552
+ for (const dangerous of REDOS_PATTERNS) {
3553
+ if (dangerous.test(match[1])) return null;
3554
+ }
3555
+ if (hasAdjacentUnbounded(match[1], match[2])) return null;
3005
3556
  try {
3006
3557
  return new RegExp(match[1], match[2]);
3007
3558
  } catch {
@@ -3745,6 +4296,16 @@ function operandShapeProblem(shape, value) {
3745
4296
  const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
3746
4297
  const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
3747
4298
  const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
4299
+ const stringLimit = (v) => typeof v === "string" && v.length > LIMITS.maxValueLength ? `must not be longer than ${LIMITS.maxValueLength} chars` : null;
4300
+ const listLimit = (v) => Array.isArray(v) && v.length > LIMITS.maxOperandItems ? `must not have more than ${LIMITS.maxOperandItems} entries` : null;
4301
+ const tooLong = stringLimit(value);
4302
+ if (tooLong) return tooLong;
4303
+ const tooMany = listLimit(value);
4304
+ if (tooMany) return tooMany;
4305
+ if (Array.isArray(value)) {
4306
+ const longMember = value.find((member) => stringLimit(member) !== null);
4307
+ if (longMember !== void 0) return `contains a string longer than ${LIMITS.maxValueLength} chars`;
4308
+ }
3748
4309
  switch (shape) {
3749
4310
  case "positive-number":
3750
4311
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
@@ -3775,8 +4336,11 @@ function operandShapeProblem(shape, value) {
3775
4336
  if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
3776
4337
  const names = Object.keys(value);
3777
4338
  if (names.length === 0) return "must be an object with at least one entry";
4339
+ if (names.length > LIMITS.maxMapEntries) return `must not have more than ${LIMITS.maxMapEntries} entries`;
3778
4340
  const bad = names.find((name) => !isHeaderName(name));
3779
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4341
+ if (bad !== void 0) return `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4342
+ const badValue = Object.values(value).find((entry) => typeof entry !== "string" || entry.length > LIMITS.maxValueLength);
4343
+ return badValue === void 0 ? null : `values must be strings no longer than ${LIMITS.maxValueLength} chars`;
3780
4344
  }
3781
4345
  case "header-name-list": {
3782
4346
  if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
@@ -3922,9 +4486,16 @@ var LIMITS = Object.freeze({
3922
4486
  maxRules: 5e3,
3923
4487
  maxWhitelists: 2e3,
3924
4488
  maxConditionsPerRule: 250,
4489
+ maxConditionNodesPerRule: 1e3,
4490
+ maxConditionNodesPerBundle: 25e3,
3925
4491
  maxNestingDepth: 12,
3926
4492
  maxRegexLength: 1e3,
3927
- maxValueLength: 8192
4493
+ maxValueLength: 8192,
4494
+ maxParameterLength: 512,
4495
+ maxParameterItems: 64,
4496
+ maxOperandItems: 256,
4497
+ maxMapEntries: 128,
4498
+ maxPathSegments: 64
3928
4499
  });
3929
4500
  var GROUP_PARAMETER = "rules";
3930
4501
  function isGroup(condition) {
@@ -3951,6 +4522,9 @@ function parameterProblem(parameter) {
3951
4522
  if (parameter === void 0 || parameter === null) return null;
3952
4523
  if (Array.isArray(parameter)) {
3953
4524
  if (parameter.length === 0) return "parameter list is empty";
4525
+ if (parameter.length > LIMITS.maxParameterItems) {
4526
+ return `parameter list has more than ${LIMITS.maxParameterItems} entries`;
4527
+ }
3954
4528
  for (const member of parameter) {
3955
4529
  if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
3956
4530
  const problem = parameterProblem(member);
@@ -3959,6 +4533,9 @@ function parameterProblem(parameter) {
3959
4533
  return null;
3960
4534
  }
3961
4535
  if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
4536
+ if (parameter.length > LIMITS.maxParameterLength) {
4537
+ return `parameter is longer than ${LIMITS.maxParameterLength} chars`;
4538
+ }
3962
4539
  if (parameter === GROUP_PARAMETER) {
3963
4540
  return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
3964
4541
  }
@@ -3994,6 +4571,20 @@ function matchProblem(match, parameter) {
3994
4571
  if (!match || typeof match !== "object") return "condition has no match object";
3995
4572
  if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
3996
4573
  if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
4574
+ for (const operand of ["value", "key", "claim"]) {
4575
+ const value = match[operand];
4576
+ if (typeof value === "string" && value.length > LIMITS.maxValueLength) {
4577
+ return `match operand "${operand}" is longer than ${LIMITS.maxValueLength} chars`;
4578
+ }
4579
+ if (Array.isArray(value)) {
4580
+ if (value.length > LIMITS.maxOperandItems) {
4581
+ return `match operand "${operand}" has more than ${LIMITS.maxOperandItems} entries`;
4582
+ }
4583
+ if (value.some((entry) => typeof entry === "string" && entry.length > LIMITS.maxValueLength)) {
4584
+ return `match operand "${operand}" contains a string longer than ${LIMITS.maxValueLength} chars`;
4585
+ }
4586
+ }
4587
+ }
3997
4588
  const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
3998
4589
  if (!parameterless && (parameter === void 0 || parameter === null)) {
3999
4590
  return `match type "${match.type}" needs a parameter`;
@@ -4016,11 +4607,20 @@ function matchProblem(match, parameter) {
4016
4607
  } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
4017
4608
  return `match type "${match.type}" needs a value`;
4018
4609
  }
4610
+ if (match.type === "array_key_value") {
4611
+ const paths = Array.isArray(match.key) ? match.key : [match.key];
4612
+ if (paths.some((path9) => typeof path9 === "string" && path9.split(".").length > LIMITS.maxPathSegments)) {
4613
+ return `match type "array_key_value" key has more than ${LIMITS.maxPathSegments} path segments`;
4614
+ }
4615
+ }
4019
4616
  return null;
4020
4617
  }
4021
4618
  function mutationsProblem(mutations) {
4022
4619
  if (mutations === void 0 || mutations === null) return null;
4023
4620
  if (!Array.isArray(mutations)) return "mutations must be an array";
4621
+ if (mutations.length > LIMITS.maxOperandItems) {
4622
+ return `mutations has more than ${LIMITS.maxOperandItems} entries`;
4623
+ }
4024
4624
  for (const mutation of mutations) {
4025
4625
  if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
4026
4626
  return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
@@ -4040,10 +4640,19 @@ function whenProblem(when) {
4040
4640
  if ("path" in when && !isNonEmptyString(when.path)) {
4041
4641
  return "when.path must be a non-empty string";
4042
4642
  }
4643
+ if (typeof when.path === "string" && when.path.length > LIMITS.maxValueLength) {
4644
+ return `when.path is longer than ${LIMITS.maxValueLength} chars`;
4645
+ }
4043
4646
  if ("method" in when) {
4044
4647
  const methods = Array.isArray(when.method) ? when.method : [when.method];
4045
4648
  if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
4649
+ if (methods.length > LIMITS.maxParameterItems) {
4650
+ return `when.method has more than ${LIMITS.maxParameterItems} entries`;
4651
+ }
4046
4652
  if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
4653
+ if (methods.some((method) => method.length > LIMITS.maxParameterLength)) {
4654
+ return `when.method entries must not be longer than ${LIMITS.maxParameterLength} chars`;
4655
+ }
4047
4656
  }
4048
4657
  return null;
4049
4658
  }
@@ -4067,6 +4676,11 @@ function rulePropertyProblem(rule) {
4067
4676
  const problem = operandShapeProblem(shape, rule[property]);
4068
4677
  if (problem) return `"${property}" ${problem}`;
4069
4678
  }
4679
+ for (const property of ["id", "rule_id", "title", "category", "message", "source_revision"]) {
4680
+ if (typeof rule[property] === "string" && rule[property].length > LIMITS.maxValueLength) {
4681
+ return `"${property}" is longer than ${LIMITS.maxValueLength} chars`;
4682
+ }
4683
+ }
4070
4684
  return null;
4071
4685
  }
4072
4686
  function actionProblem(action, rule) {
@@ -4096,46 +4710,76 @@ var PHASES2 = new Set(PHASES);
4096
4710
  var ACTIONS2 = new Set(ACTIONS);
4097
4711
  function validateBundle(bundle, opts = {}) {
4098
4712
  const rejected = [];
4713
+ let omittedRejections = 0;
4714
+ const reject = (entry) => {
4715
+ if (rejected.length < 100) rejected.push(entry);
4716
+ else omittedRejections++;
4717
+ };
4099
4718
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
4100
4719
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
4720
+ const bundleBudget = { count: 0 };
4101
4721
  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 });
4722
+ for (const rule of inFirewall.slice(0, LIMITS2.maxRules)) {
4723
+ const reason = enforceableRuleProblem(rule, bundleBudget);
4724
+ if (reason) reject({ id: idOf(rule), reason });
4109
4725
  else firewall.push(rule);
4110
4726
  }
4727
+ if (inFirewall.length > LIMITS2.maxRules) {
4728
+ reject({ id: "(bundle)", reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
4729
+ }
4111
4730
  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
- }
4731
+ for (const wl of inWhitelists.slice(0, LIMITS2.maxWhitelists)) {
4117
4732
  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" });
4733
+ reject({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
4119
4734
  continue;
4120
4735
  }
4121
4736
  if (wl && typeof wl === "object" && BUILD_SCOPE_PROPERTY in wl) {
4122
- rejected.push({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
4737
+ reject({ id: idOf(wl), reason: `whitelist may not carry ${BUILD_SCOPE_PROPERTY}` });
4123
4738
  continue;
4124
4739
  }
4125
- const reason = conditionsProblem(wl?.rule_v2);
4126
- if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
4740
+ const reason = conditionsProblem(wl?.rule_v2, 0, { count: 0 }, bundleBudget);
4741
+ if (reason) reject({ id: idOf(wl), reason: `whitelist: ${reason}` });
4127
4742
  else whitelists.push(wl);
4128
4743
  }
4744
+ if (inWhitelists.length > LIMITS2.maxWhitelists) {
4745
+ reject({ id: "(bundle)", reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
4746
+ }
4747
+ let whitelistKeys = {};
4748
+ if (bundle?.whitelist_keys !== void 0) {
4749
+ if (!bundle.whitelist_keys || typeof bundle.whitelist_keys !== "object" || Array.isArray(bundle.whitelist_keys)) {
4750
+ reject({ id: "(bundle)", reason: "whitelist_keys must be an object" });
4751
+ } else {
4752
+ const entries = Object.entries(bundle.whitelist_keys);
4753
+ if (entries.length > LIMITS2.maxMapEntries) {
4754
+ reject({ id: "(bundle)", reason: `whitelist_keys has more than ${LIMITS2.maxMapEntries} entries` });
4755
+ } else {
4756
+ 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));
4757
+ if (invalid) {
4758
+ reject({ id: "(bundle)", reason: `whitelist_keys entry "${invalid[0].slice(0, 64)}" has an unsupported shape` });
4759
+ } else {
4760
+ whitelistKeys = bundle.whitelist_keys;
4761
+ }
4762
+ }
4763
+ }
4764
+ }
4765
+ if (omittedRejections > 0) {
4766
+ rejected.push({ id: "(bundle)", reason: `${omittedRejections} additional rejected entries omitted` });
4767
+ }
4129
4768
  return {
4130
- bundle: { ...bundle, firewall, whitelists },
4769
+ bundle: { ...bundle, firewall, whitelists, whitelist_keys: whitelistKeys },
4131
4770
  rejected
4132
4771
  };
4133
4772
  }
4134
4773
  function idOf(rule) {
4135
4774
  const id = rule?.id ?? rule?.rule_id;
4136
- return id === void 0 || id === null ? "(unidentified)" : String(id);
4775
+ if (id === void 0 || id === null) return "(unidentified)";
4776
+ try {
4777
+ return String(id).slice(0, 128);
4778
+ } catch {
4779
+ return "(unidentified)";
4780
+ }
4137
4781
  }
4138
- function enforceableRuleProblem(rule) {
4782
+ function enforceableRuleProblem(rule, bundleBudget = null) {
4139
4783
  if (!rule || typeof rule !== "object") return "not an object";
4140
4784
  const nullReason = nullPropertyProblem(rule);
4141
4785
  if (nullReason) return nullReason;
@@ -4146,9 +4790,9 @@ function enforceableRuleProblem(rule) {
4146
4790
  if (scopeReason) return scopeReason;
4147
4791
  const propertyReason = rulePropertyProblem(rule);
4148
4792
  if (propertyReason) return propertyReason;
4149
- return conditionsProblem(rule.rule_v2);
4793
+ return conditionsProblem(rule.rule_v2, 0, { count: 0 }, bundleBudget);
4150
4794
  }
4151
- function conditionsProblem(conditions, depth = 0) {
4795
+ function conditionsProblem(conditions, depth = 0, ruleBudget = { count: 0 }, bundleBudget = null) {
4152
4796
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
4153
4797
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
4154
4798
  if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
@@ -4156,10 +4800,20 @@ function conditionsProblem(conditions, depth = 0) {
4156
4800
  return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
4157
4801
  }
4158
4802
  for (const c of conditions) {
4803
+ ruleBudget.count++;
4804
+ if (ruleBudget.count > LIMITS2.maxConditionNodesPerRule) {
4805
+ return `more than ${LIMITS2.maxConditionNodesPerRule} total condition nodes`;
4806
+ }
4807
+ if (bundleBudget) {
4808
+ bundleBudget.count++;
4809
+ if (bundleBudget.count > LIMITS2.maxConditionNodesPerBundle) {
4810
+ return `bundle exceeds ${LIMITS2.maxConditionNodesPerBundle} total condition nodes`;
4811
+ }
4812
+ }
4159
4813
  const shapeReason = conditionShapeProblem(c);
4160
4814
  if (shapeReason) return shapeReason;
4161
4815
  if (isGroup(c)) {
4162
- const nested = conditionsProblem(c.rules, depth + 1);
4816
+ const nested = conditionsProblem(c.rules, depth + 1, ruleBudget, bundleBudget);
4163
4817
  if (nested) return nested;
4164
4818
  continue;
4165
4819
  }
@@ -4173,11 +4827,17 @@ function conditionsProblem(conditions, depth = 0) {
4173
4827
  if (m.type === "regex") {
4174
4828
  if (typeof m.value !== "string") return "regex match.value must be a string";
4175
4829
  if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
4830
+ if (safeRegExp(m.value) === null) return "regex is invalid or has an unsafe repetition shape";
4176
4831
  } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
4177
4832
  return `match.value longer than ${LIMITS2.maxValueLength} chars`;
4178
4833
  }
4179
4834
  if (m.match) {
4180
- const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
4835
+ const nested = conditionsProblem(
4836
+ [{ parameter: c.parameter, match: m.match }],
4837
+ depth + 1,
4838
+ ruleBudget,
4839
+ bundleBudget
4840
+ );
4181
4841
  if (nested) return nested;
4182
4842
  }
4183
4843
  }
@@ -4391,12 +5051,14 @@ function asText(value) {
4391
5051
  }
4392
5052
 
4393
5053
  // src/protect/engine/pulse-client.js
5054
+ init_safe_origin();
4394
5055
  init_pulse_token();
4395
5056
  init_build_id();
4396
5057
  var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
4397
5058
  var DEFAULT_CACHE_TTL2 = 3e5;
4398
5059
  var BUILD_VERDICT_HEADER = "X-Patchstack-Build-Match";
4399
5060
  var BUILD_IDENTITY_HEADER = "X-Patchstack-Build-ID";
5061
+ var PER_RULE_DRY_RUN_HEADER = "X-Patchstack-Per-Rule-Dry-Run";
4400
5062
  var JITTER_FRACTION2 = 0.1;
4401
5063
  var PulseRuleClient = class {
4402
5064
  #siteUuid;
@@ -4441,6 +5103,9 @@ var PulseRuleClient = class {
4441
5103
  if (this.#buildId !== null && typeof auth.Authorization === "string") {
4442
5104
  headers["X-Patchstack-Build"] = this.#buildId;
4443
5105
  }
5106
+ if (typeof auth.Authorization === "string") {
5107
+ headers[PER_RULE_DRY_RUN_HEADER] = "1";
5108
+ }
4444
5109
  if (this.#etag) headers["If-None-Match"] = this.#etag;
4445
5110
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
4446
5111
  if (response.status === 304) {
@@ -4459,7 +5124,7 @@ var PulseRuleClient = class {
4459
5124
  if (!response.ok) {
4460
5125
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
4461
5126
  }
4462
- const data = await response.json();
5127
+ const data = await readBoundedJson(response);
4463
5128
  if (!data || !Array.isArray(data.firewall)) {
4464
5129
  return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
4465
5130
  }
@@ -4520,19 +5185,18 @@ async function fromFetchRequest(request, options = {}) {
4520
5185
  const method = (request.method || "GET").toUpperCase();
4521
5186
  const headers = {};
4522
5187
  request.headers.forEach((value, key) => {
4523
- headers[key.toLowerCase()] = value;
5188
+ setOwn(headers, key.toLowerCase(), value);
4524
5189
  });
4525
5190
  const query = {};
4526
5191
  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
- }
5192
+ appendOwn(query, key, value);
4532
5193
  }
4533
5194
  let rawBody = "";
5195
+ let bodyInspectionSkip = null;
4534
5196
  if (method !== "GET" && method !== "HEAD") {
4535
- rawBody = await readCappedText(request, maxBodyBytes);
5197
+ const read = await readCappedText(request, maxBodyBytes);
5198
+ rawBody = read.text;
5199
+ bodyInspectionSkip = read.skip;
4536
5200
  }
4537
5201
  const contentType = headers["content-type"] || "";
4538
5202
  let body = {};
@@ -4559,7 +5223,8 @@ async function fromFetchRequest(request, options = {}) {
4559
5223
  cookies: parseCookies(headers.cookie),
4560
5224
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
4561
5225
  // drops, so prototype-pollution rules on `raw` are robust.
4562
- _rawBody: rawBody
5226
+ _rawBody: rawBody,
5227
+ ...bodyInspectionSkip === null ? {} : { _bodyInspectionSkip: bodyInspectionSkip }
4563
5228
  };
4564
5229
  }
4565
5230
  async function readCappedText(request, max) {
@@ -4567,7 +5232,7 @@ async function readCappedText(request, max) {
4567
5232
  try {
4568
5233
  clone = request.clone();
4569
5234
  } catch {
4570
- return "";
5235
+ return { text: "", skip: "clone-failed" };
4571
5236
  }
4572
5237
  const body = clone.body;
4573
5238
  if (body && typeof body.getReader === "function") {
@@ -4578,23 +5243,30 @@ async function readCappedText(request, max) {
4578
5243
  for (; ; ) {
4579
5244
  const { done, value } = await reader.read();
4580
5245
  if (done) break;
4581
- if (!value || buffered >= max) continue;
5246
+ if (!value) continue;
4582
5247
  const take = Math.min(value.byteLength, max - buffered);
4583
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
5248
+ if (take > 0) chunks.push(take === value.byteLength ? value : value.subarray(0, take));
4584
5249
  buffered += take;
5250
+ if (buffered >= max) {
5251
+ void reader.cancel().catch(() => {
5252
+ });
5253
+ return { text: decodeChunks(chunks, buffered), skip: "body-cap" };
5254
+ }
4585
5255
  }
4586
5256
  } catch {
4587
- return "";
4588
- }
4589
- try {
4590
- return new TextDecoder().decode(concatChunks(chunks, buffered));
4591
- } catch {
4592
- return "";
5257
+ void reader.cancel().catch(() => {
5258
+ });
5259
+ return { text: "", skip: "read-failed" };
5260
+ } finally {
5261
+ reader.releaseLock();
4593
5262
  }
5263
+ return { text: decodeChunks(chunks, buffered), skip: null };
4594
5264
  }
5265
+ return { text: "", skip: "unreadable-body" };
5266
+ }
5267
+ function decodeChunks(chunks, total) {
4595
5268
  try {
4596
- const text = await clone.text();
4597
- return text.length > max ? text.slice(0, max) : text;
5269
+ return new TextDecoder().decode(concatChunks(chunks, total));
4598
5270
  } catch {
4599
5271
  return "";
4600
5272
  }
@@ -4621,7 +5293,7 @@ function parseBody(rawBody, contentType) {
4621
5293
  }
4622
5294
  if (isForm) {
4623
5295
  const body = {};
4624
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
5296
+ for (const [k, v] of new URLSearchParams(rawBody)) appendOwn(body, k, v);
4625
5297
  return { body, files: void 0 };
4626
5298
  }
4627
5299
  if (isJson || isAmbiguous) {
@@ -4649,9 +5321,9 @@ function parseMultipart(rawBody, boundary) {
4649
5321
  if (filename !== void 0) {
4650
5322
  const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
4651
5323
  const file = { filename, type: partType, content };
4652
- files[name] = name in files ? [].concat(files[name], file) : file;
5324
+ appendOwn(files, name, file);
4653
5325
  } else {
4654
- body[name] = name in body ? [].concat(body[name], content) : content;
5326
+ appendOwn(body, name, content);
4655
5327
  }
4656
5328
  }
4657
5329
  return { body, files };
@@ -4666,7 +5338,7 @@ function parseCookies(header) {
4666
5338
  if (idx === -1) {
4667
5339
  continue;
4668
5340
  }
4669
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
5341
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
4670
5342
  }
4671
5343
  return cookies;
4672
5344
  }
@@ -4676,7 +5348,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
4676
5348
  const method = (req.method || "GET").toUpperCase();
4677
5349
  const headers = {};
4678
5350
  for (const [key, value] of Object.entries(req.headers || {})) {
4679
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
5351
+ setOwn(headers, key.toLowerCase(), Array.isArray(value) ? value.join(", ") : value);
4680
5352
  }
4681
5353
  const host = headers.host || "localhost";
4682
5354
  let url;
@@ -4691,11 +5363,7 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
4691
5363
  }
4692
5364
  const query = {};
4693
5365
  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
- }
5366
+ appendOwn(query, key, value);
4699
5367
  }
4700
5368
  const contentType = headers["content-type"] || "";
4701
5369
  let body = {};
@@ -4736,7 +5404,7 @@ function parseCookies2(header) {
4736
5404
  if (idx === -1) {
4737
5405
  continue;
4738
5406
  }
4739
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
5407
+ setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
4740
5408
  }
4741
5409
  return cookies;
4742
5410
  }
@@ -4792,6 +5460,67 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
4792
5460
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
4793
5461
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
4794
5462
  const MAX_REDIRECTS = 20;
5463
+ const MAX_REPLAY_BODY_BYTES = 1024 * 1024;
5464
+ const captureReplayBody = (request) => {
5465
+ if (request.method === "GET" || request.method === "HEAD" || !request.body) {
5466
+ return { result: Promise.resolve({ body: void 0, tooLarge: false }), cancel() {
5467
+ } };
5468
+ }
5469
+ let reader;
5470
+ try {
5471
+ reader = request.clone().body?.getReader();
5472
+ } catch {
5473
+ reader = null;
5474
+ }
5475
+ if (!reader) {
5476
+ return { result: Promise.resolve({ body: void 0, tooLarge: true }), cancel() {
5477
+ } };
5478
+ }
5479
+ let cancelled = false;
5480
+ const result = (async () => {
5481
+ const chunks = [];
5482
+ let total = 0;
5483
+ try {
5484
+ for (; ; ) {
5485
+ const { done, value } = await reader.read();
5486
+ if (done || cancelled) break;
5487
+ total += value.byteLength;
5488
+ if (total > MAX_REPLAY_BODY_BYTES) {
5489
+ void reader.cancel().catch(() => {
5490
+ });
5491
+ return { body: void 0, tooLarge: true };
5492
+ }
5493
+ chunks.push(value);
5494
+ }
5495
+ if (cancelled) return { body: void 0, tooLarge: false };
5496
+ const body = new Uint8Array(total);
5497
+ let offset = 0;
5498
+ for (const chunk of chunks) {
5499
+ body.set(chunk, offset);
5500
+ offset += chunk.byteLength;
5501
+ }
5502
+ return { body, tooLarge: false };
5503
+ } catch {
5504
+ return { body: void 0, tooLarge: true };
5505
+ } finally {
5506
+ reader.releaseLock();
5507
+ }
5508
+ })();
5509
+ return {
5510
+ result,
5511
+ cancel() {
5512
+ cancelled = true;
5513
+ void reader.cancel().catch(() => {
5514
+ });
5515
+ }
5516
+ };
5517
+ };
5518
+ const discardResponseBody = async (response) => {
5519
+ try {
5520
+ await response?.body?.cancel?.();
5521
+ } catch {
5522
+ }
5523
+ };
4795
5524
  const screenUrl = async (u, method) => {
4796
5525
  let host = null;
4797
5526
  try {
@@ -4817,20 +5546,43 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
4817
5546
  if (callerRedirect !== "follow") return originalFetch(input, init);
4818
5547
  const headers = new Headers(cur.headers);
4819
5548
  const signal = cur.signal;
4820
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
5549
+ const replay = captureReplayBody(cur);
5550
+ let body;
4821
5551
  for (let hop = 0; ; hop++) {
4822
- const resp = await originalFetch(
4823
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
4824
- );
5552
+ let resp;
5553
+ try {
5554
+ resp = await originalFetch(
5555
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
5556
+ );
5557
+ } catch (error) {
5558
+ replay.cancel();
5559
+ throw error;
5560
+ }
4825
5561
  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");
5562
+ if (!location) {
5563
+ replay.cancel();
5564
+ return resp;
5565
+ }
5566
+ await discardResponseBody(resp);
5567
+ if (hop >= MAX_REDIRECTS) {
5568
+ replay.cancel();
5569
+ throw new Error("Patchstack blocked an outbound request: too many redirects");
5570
+ }
4828
5571
  const next = new URL(location, url).href;
4829
5572
  if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
4830
5573
  method = "GET";
4831
5574
  body = void 0;
5575
+ replay.cancel();
4832
5576
  headers.delete("content-type");
4833
5577
  headers.delete("content-length");
5578
+ } else if (body === void 0 && method !== "GET" && method !== "HEAD") {
5579
+ const captured = await replay.result;
5580
+ if (captured.tooLarge) {
5581
+ throw new Error(
5582
+ `Patchstack blocked a redirect that required replaying more than ${MAX_REPLAY_BODY_BYTES} bytes`
5583
+ );
5584
+ }
5585
+ body = captured.body;
4834
5586
  }
4835
5587
  if (new URL(next).origin !== new URL(url).origin) {
4836
5588
  headers.delete("authorization");
@@ -5141,7 +5893,7 @@ var DEFAULT_RESPONSE_RULES = [
5141
5893
  //
5142
5894
  // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
5143
5895
  // 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+\\)/" } }]
5896
+ 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
5897
  },
5146
5898
  {
5147
5899
  id: "resp-sql-error",
@@ -5275,9 +6027,18 @@ async function loadFs() {
5275
6027
  try {
5276
6028
  const [fs, path9] = await Promise.all([import("fs"), import("path")]);
5277
6029
  fsMod = {
6030
+ chmodSync: fs.chmodSync,
6031
+ closeSync: fs.closeSync,
6032
+ constants: fs.constants,
6033
+ fstatSync: fs.fstatSync,
6034
+ fsyncSync: fs.fsyncSync,
6035
+ lstatSync: fs.lstatSync,
6036
+ openSync: fs.openSync,
5278
6037
  readFileSync: fs.readFileSync,
5279
- writeFileSync: fs.writeFileSync,
5280
6038
  mkdirSync: fs.mkdirSync,
6039
+ renameSync: fs.renameSync,
6040
+ unlinkSync: fs.unlinkSync,
6041
+ writeFileSync: fs.writeFileSync,
5281
6042
  join: path9.join
5282
6043
  };
5283
6044
  } catch {
@@ -5330,21 +6091,83 @@ async function cacheWrite(dir, env) {
5330
6091
  if (!dir) return;
5331
6092
  const fs = await loadFs();
5332
6093
  if (!fs) return;
6094
+ const target = fs.join(dir, "patchstack-rules.json");
6095
+ const temporary = fs.join(dir, `.patchstack-rules-${randomSuffix()}.tmp`);
6096
+ let descriptor = null;
5333
6097
  try {
5334
6098
  fs.mkdirSync(dir, { recursive: true });
5335
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
6099
+ const directory = fs.lstatSync(dir);
6100
+ if (directory.isSymbolicLink() || !directory.isDirectory()) return;
6101
+ let mode;
6102
+ try {
6103
+ const current = fs.lstatSync(target);
6104
+ if (current.isSymbolicLink() || !current.isFile()) return;
6105
+ mode = current.mode & 4095;
6106
+ } catch (error) {
6107
+ if (error?.code !== "ENOENT") return;
6108
+ }
6109
+ descriptor = fs.openSync(temporary, "wx", mode ?? 384);
6110
+ fs.writeFileSync(descriptor, JSON.stringify(env), "utf8");
6111
+ fs.fsyncSync(descriptor);
6112
+ fs.closeSync(descriptor);
6113
+ descriptor = null;
6114
+ if (mode !== void 0) fs.chmodSync(temporary, mode);
6115
+ try {
6116
+ fs.renameSync(temporary, target);
6117
+ } catch (error) {
6118
+ if (typeof process === "undefined" || process.platform !== "win32" || error?.code !== "EEXIST" && error?.code !== "EPERM") throw error;
6119
+ try {
6120
+ fs.unlinkSync(target);
6121
+ } catch (unlinkError) {
6122
+ if (unlinkError?.code !== "ENOENT") throw unlinkError;
6123
+ }
6124
+ fs.renameSync(temporary, target);
6125
+ }
5336
6126
  } catch {
6127
+ } finally {
6128
+ if (descriptor !== null) {
6129
+ try {
6130
+ fs.closeSync(descriptor);
6131
+ } catch {
6132
+ }
6133
+ }
6134
+ try {
6135
+ fs.unlinkSync(temporary);
6136
+ } catch {
6137
+ }
5337
6138
  }
5338
6139
  }
5339
6140
  async function cacheRead(dir) {
5340
6141
  if (!dir) return null;
5341
6142
  const fs = await loadFs();
5342
6143
  if (!fs) return null;
6144
+ const target = fs.join(dir, "patchstack-rules.json");
6145
+ let descriptor = null;
5343
6146
  try {
5344
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
6147
+ const entry = fs.lstatSync(target);
6148
+ if (entry.isSymbolicLink() || !entry.isFile() || entry.size > 16 * 1024 * 1024) return null;
6149
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
6150
+ descriptor = fs.openSync(target, flags);
6151
+ const opened = fs.fstatSync(descriptor);
6152
+ if (!opened.isFile() || opened.size > 16 * 1024 * 1024) return null;
6153
+ return toEnvelope(JSON.parse(fs.readFileSync(descriptor, "utf8")));
5345
6154
  } catch {
5346
6155
  return null;
6156
+ } finally {
6157
+ if (descriptor !== null) {
6158
+ try {
6159
+ fs.closeSync(descriptor);
6160
+ } catch {
6161
+ }
6162
+ }
6163
+ }
6164
+ }
6165
+ function randomSuffix() {
6166
+ try {
6167
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
6168
+ } catch {
5347
6169
  }
6170
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
5348
6171
  }
5349
6172
  function toEnvelope(value) {
5350
6173
  if (!value || typeof value !== "object") return null;
@@ -5371,7 +6194,11 @@ init_build_id();
5371
6194
  function liveUpdateRejections(res, options) {
5372
6195
  if (options.acceptPartialBundle) return [];
5373
6196
  const { rejected } = validateBundle(
5374
- { firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
6197
+ {
6198
+ firewall: Array.isArray(res.firewall) ? res.firewall : [],
6199
+ whitelists: Array.isArray(res.whitelists) ? res.whitelists : [],
6200
+ whitelist_keys: res.whitelist_keys
6201
+ },
5375
6202
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
5376
6203
  );
5377
6204
  return rejected;
@@ -5542,7 +6369,8 @@ function normalizeBundle(b, options = {}) {
5542
6369
  const { bundle: checked, rejected } = validateBundle(
5543
6370
  {
5544
6371
  firewall: Array.isArray(b.firewall) ? b.firewall : [],
5545
- whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
6372
+ whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
6373
+ whitelist_keys: b.whitelist_keys
5546
6374
  },
5547
6375
  { allowGlobalWhitelists: options.allowGlobalWhitelists }
5548
6376
  );
@@ -5562,7 +6390,7 @@ function normalizeBundle(b, options = {}) {
5562
6390
  return {
5563
6391
  firewall: checked.firewall,
5564
6392
  whitelists: checked.whitelists,
5565
- whitelist_keys: b.whitelist_keys ?? {},
6393
+ whitelist_keys: checked.whitelist_keys,
5566
6394
  ...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
5567
6395
  };
5568
6396
  }
@@ -5606,18 +6434,23 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
5606
6434
  };
5607
6435
  }
5608
6436
  function makeRefreshHandler(tick, secret) {
6437
+ let inflight2 = null;
6438
+ const runOnce = () => {
6439
+ if (inflight2) return inflight2;
6440
+ let current;
6441
+ current = Promise.resolve().then(() => tick()).finally(() => {
6442
+ if (inflight2 === current) inflight2 = null;
6443
+ });
6444
+ inflight2 = current;
6445
+ return current;
6446
+ };
5609
6447
  return async (request) => {
5610
6448
  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
- }
6449
+ const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
5617
6450
  if (provided !== secret) return new Response("forbidden", { status: 403 });
5618
6451
  let refreshed = true;
5619
6452
  try {
5620
- const status = await tick();
6453
+ const status = await runOnce();
5621
6454
  if (status && status.ok === false) refreshed = false;
5622
6455
  } catch {
5623
6456
  refreshed = false;
@@ -5628,6 +6461,7 @@ function makeRefreshHandler(tick, secret) {
5628
6461
 
5629
6462
  // src/protect/detections.js
5630
6463
  init_pulse_token();
6464
+ init_safe_origin();
5631
6465
  var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
5632
6466
  var DEFAULT_FLUSH_MS = 5e3;
5633
6467
  var MAX_BATCH = 50;
@@ -6365,10 +7199,14 @@ function hardensWithoutBody(rule) {
6365
7199
  }
6366
7200
 
6367
7201
  // src/protect/firewall-log.js
7202
+ init_safe_origin();
7203
+ init_bounded_response();
6368
7204
  var DEFAULT_API_BASE = "https://api.patchstack.com";
6369
7205
  var STOP_BUDGET_MS2 = 5e3;
7206
+ var ATTEMPT_TIMEOUT_MS2 = 1e4;
6370
7207
  var DEFAULT_FLUSH_MS2 = 1e3;
6371
7208
  var MAX_BATCH2 = 50;
7209
+ var MAX_QUEUE2 = 500;
6372
7210
  var TOKEN_SKEW_MS2 = 6e4;
6373
7211
  function parseApiKey(apiKey) {
6374
7212
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -6390,14 +7228,16 @@ function resolveApiBase(pulseOrManifestUrl) {
6390
7228
  }
6391
7229
  if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
6392
7230
  try {
6393
- return new URL(pulseOrManifestUrl).origin;
7231
+ const candidate = new URL(pulseOrManifestUrl).origin;
7232
+ if (isSafeOrigin(candidate)) return candidate;
6394
7233
  } catch {
6395
7234
  }
6396
7235
  }
6397
7236
  const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
6398
7237
  if (typeof endpoint === "string" && endpoint.length > 0) {
6399
7238
  try {
6400
- return new URL(endpoint).origin;
7239
+ const candidate = new URL(endpoint).origin;
7240
+ if (isSafeOrigin(candidate)) return candidate;
6401
7241
  } catch {
6402
7242
  }
6403
7243
  }
@@ -6409,16 +7249,16 @@ function createFirewallLogReporter(opts) {
6409
7249
  return { record() {
6410
7250
  }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
6411
7251
  }
6412
- const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
7252
+ const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
6413
7253
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
6414
7254
  const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
6415
7255
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
6416
7256
  let queue = [];
6417
7257
  let timer = null;
6418
7258
  let stopped = false;
6419
- const outstanding = /* @__PURE__ */ new Set();
7259
+ let inFlight = null;
6420
7260
  let drainPromise = null;
6421
- const lifetime = typeof AbortController === "function" ? new AbortController() : null;
7261
+ let activeController = null;
6422
7262
  let ended = false;
6423
7263
  let cachedToken = null;
6424
7264
  let tokenInflight = null;
@@ -6444,9 +7284,9 @@ function createFirewallLogReporter(opts) {
6444
7284
  })
6445
7285
  });
6446
7286
  if (!res || !res.ok) return null;
6447
- const body = await res.json();
7287
+ const body = await readBoundedJson2(res);
6448
7288
  const token = body?.access_token;
6449
- if (typeof token !== "string" || token.length === 0) return null;
7289
+ if (typeof token !== "string" || token.length === 0 || token.length > 8192 || /[\u0000-\u0020\u007f-\u009f]/.test(token)) return null;
6450
7290
  const expiresIn = Number(body?.expires_in);
6451
7291
  const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
6452
7292
  cachedToken = { token, expiresAt: Date.now() + ttlMs };
@@ -6464,12 +7304,18 @@ function createFirewallLogReporter(opts) {
6464
7304
  clearTimeout(timer);
6465
7305
  timer = null;
6466
7306
  }
6467
- if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
7307
+ if (ended || typeof fetchImpl !== "function") return Promise.resolve();
7308
+ if (inFlight) return inFlight;
7309
+ if (queue.length === 0) return Promise.resolve();
6468
7310
  const batch = queue.splice(0, MAX_BATCH2);
7311
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
7312
+ activeController = controller;
7313
+ const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
7314
+ attemptTimer?.unref?.();
6469
7315
  let entry;
6470
- const send = (async () => {
7316
+ entry = (async () => {
6471
7317
  try {
6472
- const token = await fetchAccessToken(lifetime?.signal);
7318
+ const token = await fetchAccessToken(controller?.signal);
6473
7319
  if (!token || ended) return;
6474
7320
  const body = new URLSearchParams();
6475
7321
  body.set("type", "firewall");
@@ -6484,19 +7330,24 @@ function createFirewallLogReporter(opts) {
6484
7330
  ...sourceHost ? { "Source-Host": sourceHost } : {}
6485
7331
  },
6486
7332
  body,
6487
- // Both phases carry it, so a shutdown that runs out of time can end either one.
6488
- ...lifetime ? { signal: lifetime.signal } : {}
7333
+ // Both phases carry the attempt controller, so slow transports cannot accumulate work.
7334
+ ...controller ? { signal: controller.signal } : {}
6489
7335
  });
6490
7336
  if (p && typeof p.then === "function") await p.catch(() => {
6491
7337
  });
6492
7338
  } catch {
6493
7339
  } finally {
6494
- outstanding.delete(entry);
7340
+ clearTimeout(attemptTimer);
7341
+ if (activeController === controller) activeController = null;
7342
+ if (inFlight === entry) inFlight = null;
7343
+ if (!stopped && !ended && queue.length > 0 && !timer) {
7344
+ timer = setTimeout(flush, queue.length >= MAX_BATCH2 ? 0 : flushMs);
7345
+ timer?.unref?.();
7346
+ }
6495
7347
  }
6496
7348
  })();
6497
- entry = send;
6498
- outstanding.add(send);
6499
- return send;
7349
+ inFlight = entry;
7350
+ return entry;
6500
7351
  };
6501
7352
  return {
6502
7353
  /**
@@ -6512,6 +7363,7 @@ function createFirewallLogReporter(opts) {
6512
7363
  if (stopped) return;
6513
7364
  const fid = event?.rule?.id;
6514
7365
  if (fid === void 0 || fid === null || fid === "") return;
7366
+ if (queue.length >= MAX_QUEUE2) return;
6515
7367
  queue.push({
6516
7368
  fid,
6517
7369
  method: event.method ?? null,
@@ -6543,9 +7395,10 @@ function createFirewallLogReporter(opts) {
6543
7395
  }
6544
7396
  const terminate = () => {
6545
7397
  ended = true;
6546
- lifetime?.abort();
7398
+ activeController?.abort();
7399
+ activeController = null;
6547
7400
  queue = [];
6548
- outstanding.clear();
7401
+ inFlight = null;
6549
7402
  };
6550
7403
  let budget = null;
6551
7404
  const spent = new Promise((resolve) => {
@@ -6557,8 +7410,8 @@ function createFirewallLogReporter(opts) {
6557
7410
  });
6558
7411
  const work = (async () => {
6559
7412
  while (!ended) {
6560
- if (outstanding.size > 0) {
6561
- await Promise.all([...outstanding]);
7413
+ if (inFlight) {
7414
+ await inFlight;
6562
7415
  continue;
6563
7416
  }
6564
7417
  if (queue.length === 0) break;
@@ -6582,10 +7435,107 @@ function telemetryEnabled() {
6582
7435
 
6583
7436
  // src/protect/supabase-guard.js
6584
7437
  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 }) {
7438
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7439
+ var DEFAULT_TIMEOUT_MS = 3e4;
7440
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7441
+ "connection",
7442
+ "cookie",
7443
+ "cookie2",
7444
+ "forwarded",
7445
+ "host",
7446
+ "keep-alive",
7447
+ "proxy-authenticate",
7448
+ "proxy-authorization",
7449
+ "te",
7450
+ "trailer",
7451
+ "transfer-encoding",
7452
+ "upgrade",
7453
+ "x-forwarded-for",
7454
+ "x-forwarded-host",
7455
+ "x-forwarded-port",
7456
+ "x-forwarded-proto",
7457
+ "x-ps-target",
7458
+ "x-real-ip"
7459
+ ]);
7460
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7461
+ "connection",
7462
+ "content-encoding",
7463
+ "content-length",
7464
+ "keep-alive",
7465
+ "proxy-authenticate",
7466
+ "proxy-authorization",
7467
+ "set-cookie",
7468
+ "set-cookie2",
7469
+ "te",
7470
+ "trailer",
7471
+ "transfer-encoding",
7472
+ "upgrade"
7473
+ ]);
7474
+ var BodyTooLargeError = class extends Error {
7475
+ };
7476
+ async function boundedBody(request, maxBytes) {
7477
+ const stated2 = Number(request.headers.get("content-length"));
7478
+ if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7479
+ if (!request.body) return void 0;
7480
+ const reader = request.body.getReader();
7481
+ const chunks = [];
7482
+ let total = 0;
7483
+ try {
7484
+ for (; ; ) {
7485
+ const { done, value } = await reader.read();
7486
+ if (done) break;
7487
+ total += value.byteLength;
7488
+ if (total > maxBytes) {
7489
+ void reader.cancel().catch(() => {
7490
+ });
7491
+ throw new BodyTooLargeError();
7492
+ }
7493
+ chunks.push(value);
7494
+ }
7495
+ } finally {
7496
+ reader.releaseLock();
7497
+ }
7498
+ const body = new Uint8Array(total);
7499
+ let offset = 0;
7500
+ for (const chunk of chunks) {
7501
+ body.set(chunk, offset);
7502
+ offset += chunk.byteLength;
7503
+ }
7504
+ return body;
7505
+ }
7506
+ function requestHeaders(input) {
7507
+ const output = new Headers();
7508
+ const connectionTokens = new Set(
7509
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7510
+ );
7511
+ input.forEach((value, key) => {
7512
+ const lower = key.toLowerCase();
7513
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7514
+ });
7515
+ return output;
7516
+ }
7517
+ function responseHeaders(input) {
7518
+ const output = new Headers();
7519
+ const connectionTokens = new Set(
7520
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7521
+ );
7522
+ input.forEach((value, key) => {
7523
+ const lower = key.toLowerCase();
7524
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7525
+ });
7526
+ return output;
7527
+ }
7528
+ function createSupabaseGuard({
7529
+ protection,
7530
+ supabaseUrl,
7531
+ fetchImpl = fetch,
7532
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7533
+ timeoutMs = DEFAULT_TIMEOUT_MS
7534
+ }) {
6587
7535
  const guard = protection.fetchGuard();
6588
7536
  const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7537
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7538
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
6589
7539
  return async function handleGuardRequest(request) {
6590
7540
  const target = request.headers.get("x-ps-target");
6591
7541
  if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
@@ -6599,29 +7549,45 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
6599
7549
  return new Response("patchstack: target not allowed", { status: 403 });
6600
7550
  }
6601
7551
  const hasBody = request.method !== "GET" && request.method !== "HEAD";
6602
- const bodyText = hasBody ? await request.text() : "";
7552
+ let body;
7553
+ try {
7554
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
7555
+ } catch (error) {
7556
+ if (error instanceof BodyTooLargeError) {
7557
+ return new Response("patchstack: request body too large", { status: 413 });
7558
+ }
7559
+ throw error;
7560
+ }
7561
+ const forwardHeaders = requestHeaders(request.headers);
6603
7562
  const evalReq = new Request(targetUrl.toString(), {
6604
7563
  method: request.method,
7564
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
7565
+ // sanitized at the cross-origin boundary below.
6605
7566
  headers: request.headers,
6606
- body: hasBody ? bodyText : void 0
7567
+ body
6607
7568
  });
6608
7569
  const blocked = await guard(evalReq);
6609
7570
  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);
7571
+ let upstream;
7572
+ try {
7573
+ upstream = await fetchImpl(targetUrl.toString(), {
7574
+ method: request.method,
7575
+ headers: forwardHeaders,
7576
+ body,
7577
+ redirect: "manual",
7578
+ signal: AbortSignal.timeout(upstreamTimeout)
7579
+ });
7580
+ } catch (error) {
7581
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
7582
+ return new Response("patchstack: upstream timed out", { status: 504 });
7583
+ }
7584
+ throw error;
7585
+ }
7586
+ const forwarded = new Response(upstream.body, {
7587
+ status: upstream.status,
7588
+ statusText: upstream.statusText,
7589
+ headers: responseHeaders(upstream.headers)
6622
7590
  });
6623
- const buf = await upstream.arrayBuffer();
6624
- const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
6625
7591
  return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
6626
7592
  };
6627
7593
  }
@@ -6924,9 +7890,13 @@ async function createProtection(options = {}) {
6924
7890
  for (const name of Object.keys(headers)) {
6925
7891
  const value = headers[name];
6926
7892
  if (typeof value === "string") {
6927
- headers[name] = applyRedactors(value, spanRedactors, mask);
7893
+ setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
6928
7894
  } else if (Array.isArray(value)) {
6929
- headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
7895
+ setOwn(
7896
+ headers,
7897
+ name,
7898
+ value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
7899
+ );
6930
7900
  }
6931
7901
  }
6932
7902
  }
@@ -6976,7 +7946,13 @@ async function createProtection(options = {}) {
6976
7946
  let result;
6977
7947
  let shaped;
6978
7948
  try {
6979
- shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
7949
+ shaped = await fromFetchRequest(request, {
7950
+ trustedProxy: options.trustedProxy,
7951
+ maxBodyBytes: options.maxBodyBytes
7952
+ });
7953
+ if (shaped?._bodyInspectionSkip) {
7954
+ recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
7955
+ }
6980
7956
  result = engine.evaluate(shaped);
6981
7957
  } catch (err) {
6982
7958
  notify(onError, err, "onError");
@@ -7066,7 +8042,7 @@ async function createProtection(options = {}) {
7066
8042
  const out = {};
7067
8043
  for (const [name, value] of writeHeadEntries(headers)) {
7068
8044
  const key = name.toLowerCase();
7069
- out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
8045
+ appendOwn(out, key, value);
7070
8046
  }
7071
8047
  return out;
7072
8048
  };
@@ -7602,7 +8578,6 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
7602
8578
  const reader = body.getReader();
7603
8579
  const chunks = [];
7604
8580
  let size = 0;
7605
- let over = false;
7606
8581
  let sniffed = !sniff;
7607
8582
  try {
7608
8583
  for (; ; ) {
@@ -7611,20 +8586,27 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
7611
8586
  if (!value) continue;
7612
8587
  if (!sniffed) {
7613
8588
  sniffed = true;
7614
- if (looksBinary(value)) return { skip: "binary-body" };
8589
+ if (looksBinary(value)) {
8590
+ void reader.cancel().catch(() => {
8591
+ });
8592
+ return { skip: "binary-body" };
8593
+ }
7615
8594
  }
7616
8595
  size += value.byteLength;
7617
- if (over) continue;
7618
- if (size > cap) {
7619
- over = true;
7620
- continue;
8596
+ if (size >= cap) {
8597
+ void reader.cancel().catch(() => {
8598
+ });
8599
+ return { skip: "body-cap" };
7621
8600
  }
7622
8601
  chunks.push(value);
7623
8602
  }
7624
8603
  } catch {
8604
+ void reader.cancel().catch(() => {
8605
+ });
7625
8606
  return { skip: "read-failed" };
8607
+ } finally {
8608
+ reader.releaseLock();
7626
8609
  }
7627
- if (over) return { skip: "body-cap" };
7628
8610
  try {
7629
8611
  return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
7630
8612
  } catch {
@@ -7652,10 +8634,10 @@ function concatBytes(chunks, total) {
7652
8634
  function headerObject(headers) {
7653
8635
  const out = {};
7654
8636
  headers?.forEach?.((v, k) => {
7655
- out[k.toLowerCase()] = v;
8637
+ setOwn(out, k.toLowerCase(), v);
7656
8638
  });
7657
8639
  const setCookies = headers?.getSetCookie?.();
7658
- if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
8640
+ if (setCookies && setCookies.length) setOwn(out, "set-cookie", setCookies);
7659
8641
  return out;
7660
8642
  }
7661
8643
  function hasSpanMutations(rule) {
@@ -7824,7 +8806,7 @@ function isHeaderMutation(action) {
7824
8806
  }
7825
8807
  function applyHeaderMutation(headers, rule) {
7826
8808
  if (rule.action === "remove-header") {
7827
- for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
8809
+ for (const name of rule.remove_headers ?? []) setOwn(headers, String(name).toLowerCase(), null);
7828
8810
  return;
7829
8811
  }
7830
8812
  if (rule.action === "set-header") {
@@ -7833,7 +8815,7 @@ function applyHeaderMutation(headers, rule) {
7833
8815
  const key = String(name).toLowerCase();
7834
8816
  const present2 = headers[key] != null && headers[key] !== "";
7835
8817
  if (ensure && present2) continue;
7836
- headers[key] = String(value);
8818
+ setOwn(headers, key, String(value));
7837
8819
  }
7838
8820
  return;
7839
8821
  }