@patchstack/connect 0.3.18 → 0.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protect.js CHANGED
@@ -13,7 +13,7 @@ var PatchstackRuleClient = class {
13
13
  constructor({ token, baseUrl, cacheTtl, etag } = {}) {
14
14
  this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
15
15
  this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL ?? DEFAULT_BASE_URL;
16
- this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL;
16
+ this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
17
17
  this.#etag = etag ?? null;
18
18
  if (!this.#token) {
19
19
  throw new Error("Patchstack WAF token is required. Pass { token } or set PATCHSTACK_WAF_TOKEN env var.");
@@ -53,11 +53,14 @@ var PatchstackRuleClient = class {
53
53
  };
54
54
  }
55
55
  const data = await response.json();
56
+ if (!data || !Array.isArray(data.firewall)) {
57
+ return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
58
+ }
56
59
  const result = {
57
60
  success: true,
58
61
  notModified: false,
59
62
  etag: response.headers?.get?.("etag") ?? null,
60
- firewall: Array.isArray(data.firewall) ? data.firewall : [],
63
+ firewall: data.firewall,
61
64
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
62
65
  whitelist_keys: data.whitelist_keys ?? {}
63
66
  };
@@ -383,13 +386,14 @@ var RequestResolver = class {
383
386
  if (typeof obj !== "object" || obj === null) {
384
387
  return void 0;
385
388
  }
386
- if (key in obj) {
389
+ const own = (o, k) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
390
+ if (own(obj, key)) {
387
391
  return obj[key];
388
392
  }
389
393
  const parts = key.split(".");
390
394
  let current = obj;
391
395
  for (const part of parts) {
392
- if (current === null || current === void 0 || typeof current !== "object") {
396
+ if (!own(current, part)) {
393
397
  return void 0;
394
398
  }
395
399
  current = current[part];
@@ -602,11 +606,8 @@ function normalizeObject(value, options = {}) {
602
606
 
603
607
  // src/protect/engine/engine.js
604
608
  var REDOS_PATTERNS = [
605
- /\(\w\+\)\+/,
606
- /\(\w\*\)\+/,
607
- /\(\w\+\)\*/,
608
- /\(\w\*\)\*/,
609
- /\(\w\|\w\)\+/
609
+ /\([^)]*[+*}][^)]*\)\s*[+*]/,
610
+ /\([^)]*\|[^)]*\)\s*[+*]/
610
611
  ];
611
612
  function safeRegExp(pattern) {
612
613
  if (!pattern) {
@@ -634,6 +635,25 @@ function ctypeResult(strValue, isClass, matchVal) {
634
635
  const expected = typeof matchVal === "boolean" ? matchVal : true;
635
636
  return isClass === expected;
636
637
  }
638
+ function walkLeaves(node, segments, cb) {
639
+ if (node === null || typeof node !== "object") return;
640
+ if (Array.isArray(node)) {
641
+ for (const el of node) walkLeaves(el, segments, cb);
642
+ return;
643
+ }
644
+ const [head, ...rest] = segments;
645
+ if (head === void 0 || !Object.prototype.hasOwnProperty.call(node, head)) return;
646
+ if (rest.length === 0) {
647
+ const leaf = node[head];
648
+ if (Array.isArray(leaf)) {
649
+ for (let i = 0; i < leaf.length; i++) cb({ parent: leaf, key: i, value: leaf[i] });
650
+ } else {
651
+ cb({ parent: node, key: head, value: leaf });
652
+ }
653
+ } else {
654
+ walkLeaves(node[head], rest, cb);
655
+ }
656
+ }
637
657
  function arrayKeyValue(value, matchObj) {
638
658
  if (!matchObj || !matchObj.match || value === null || typeof value !== "object") {
639
659
  return false;
@@ -641,23 +661,11 @@ function arrayKeyValue(value, matchObj) {
641
661
  const keys = Array.isArray(matchObj.key) ? matchObj.key : [matchObj.key];
642
662
  const sub = matchObj.match;
643
663
  for (const key of keys) {
644
- let node = value;
645
- for (const part of String(key).split(".")) {
646
- if (node === null || typeof node !== "object") {
647
- node = void 0;
648
- break;
649
- }
650
- node = node[part];
651
- }
652
- if (node === void 0) {
653
- continue;
654
- }
655
- const candidates = Array.isArray(node) ? node : [node];
656
- for (const candidate of candidates) {
657
- if (matchValue(sub.type, candidate, sub.value, sub)) {
658
- return true;
659
- }
660
- }
664
+ let matched = false;
665
+ walkLeaves(value, String(key).split("."), ({ value: leaf }) => {
666
+ if (!matched && matchValue(sub.type, leaf, sub.value, sub)) matched = true;
667
+ });
668
+ if (matched) return true;
661
669
  }
662
670
  return false;
663
671
  }
@@ -679,16 +687,68 @@ function isInternalHost(hostname) {
679
687
  if (host === "::1" || host === "::") return true;
680
688
  if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
681
689
  const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
682
- if (v4) {
683
- const a = Number(v4[1]);
684
- const b = Number(v4[2]);
685
- if (a === 127 || a === 10 || a === 0) return true;
686
- if (a === 169 && b === 254) return true;
687
- if (a === 172 && b >= 16 && b <= 31) return true;
688
- if (a === 192 && b === 168) return true;
690
+ if (v4 && isPrivateV4(Number(v4[1]), Number(v4[2]))) return true;
691
+ const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
692
+ if (mapped) {
693
+ const g1 = parseInt(mapped[1], 16);
694
+ const g2 = parseInt(mapped[2], 16);
695
+ if (isPrivateV4(g1 >> 8 & 255, g1 & 255)) return true;
689
696
  }
690
697
  return false;
691
698
  }
699
+ function isPrivateV4(a, b) {
700
+ if (a === 127 || a === 10 || a === 0) return true;
701
+ if (a === 169 && b === 254) return true;
702
+ if (a === 172 && b >= 16 && b <= 31) return true;
703
+ if (a === 192 && b === 168) return true;
704
+ return false;
705
+ }
706
+ function ruleAppliesTo(when, resolver) {
707
+ try {
708
+ if (when.method) {
709
+ const methods = (Array.isArray(when.method) ? when.method : [when.method]).map((m) => String(m).toUpperCase());
710
+ const actual = String(resolver.resolve("server.REQUEST_METHOD")[0] ?? "GET").toUpperCase();
711
+ if (!methods.includes(actual)) return false;
712
+ }
713
+ if (when.path) {
714
+ const uri = String(resolver.resolve("server.REQUEST_URI")[0] ?? "/");
715
+ if (!pathMatches(String(when.path), uri.split("?")[0])) return false;
716
+ }
717
+ return true;
718
+ } catch {
719
+ return true;
720
+ }
721
+ }
722
+ function pathMatches(pattern, path) {
723
+ if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
724
+ const re = safeRegExp(pattern);
725
+ return re ? re.test(path) : false;
726
+ }
727
+ if (pattern.includes("*")) {
728
+ const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
729
+ return re ? re.test(path) : false;
730
+ }
731
+ return path === pattern;
732
+ }
733
+ function isCrossOrigin(resolver) {
734
+ try {
735
+ const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
736
+ if (!host) return false;
737
+ const src = resolver.resolve("server.HTTP_ORIGIN")[0] ?? resolver.resolve("server.HTTP_REFERER")[0];
738
+ if (!src) return false;
739
+ const srcHost = hostFromUrl(String(src));
740
+ return srcHost !== null && srcHost !== host;
741
+ } catch {
742
+ return false;
743
+ }
744
+ }
745
+ function hostFromUrl(value) {
746
+ try {
747
+ return new URL(value).host.toLowerCase();
748
+ } catch {
749
+ return null;
750
+ }
751
+ }
692
752
  function matchValue(type, value, matchVal, matchObj) {
693
753
  if (value === null || value === void 0) {
694
754
  if (type === "isset") {
@@ -734,18 +794,19 @@ function matchValue(type, value, matchVal, matchObj) {
734
794
  case "isset":
735
795
  return true;
736
796
  case "in_array": {
737
- const arr = Array.isArray(matchVal) ? matchVal : [matchVal];
797
+ const arr = (Array.isArray(matchVal) ? matchVal : [matchVal]).map((x) => String(x));
738
798
  return arr.includes(strValue);
739
799
  }
740
800
  case "not_in_array": {
741
- const arr = Array.isArray(matchVal) ? matchVal : [matchVal];
801
+ const arr = (Array.isArray(matchVal) ? matchVal : [matchVal]).map((x) => String(x));
742
802
  return !arr.includes(strValue);
743
803
  }
744
804
  case "array_in_array": {
745
805
  if (!Array.isArray(value) || !Array.isArray(matchVal)) {
746
806
  return false;
747
807
  }
748
- return value.some((v) => matchVal.includes(v));
808
+ const target = matchVal.map((x) => String(x));
809
+ return value.some((v) => target.includes(String(v)));
749
810
  }
750
811
  case "hostname": {
751
812
  try {
@@ -822,6 +883,9 @@ var RuleEngine = class {
822
883
  }
823
884
  for (const rule of this.#rules) {
824
885
  try {
886
+ if (rule.when && !ruleAppliesTo(rule.when, resolver)) {
887
+ continue;
888
+ }
825
889
  const conditions = rule.rule_v2;
826
890
  if (!Array.isArray(conditions) || conditions.length === 0) {
827
891
  continue;
@@ -868,6 +932,9 @@ var RuleEngine = class {
868
932
  if (parameter === "rules" && Array.isArray(condition.rules)) {
869
933
  return this.#evaluateRule(condition.rules, resolver);
870
934
  }
935
+ if (match && match.type === "cross_origin") {
936
+ return isCrossOrigin(resolver);
937
+ }
871
938
  const params = Array.isArray(parameter) ? parameter : [parameter];
872
939
  const values = params.flatMap((p) => resolver.resolve(p));
873
940
  if (values.length === 0) {
@@ -932,7 +999,7 @@ var PulseRuleClient = class {
932
999
  constructor({ siteUuid, baseUrl, cacheTtl, etag } = {}) {
933
1000
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
934
1001
  this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL2;
935
- this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL2;
1002
+ this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
936
1003
  this.#etag = etag ?? null;
937
1004
  if (!this.#siteUuid) {
938
1005
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
@@ -956,11 +1023,14 @@ var PulseRuleClient = class {
956
1023
  return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
957
1024
  }
958
1025
  const data = await response.json();
1026
+ if (!data || !Array.isArray(data.firewall)) {
1027
+ return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
1028
+ }
959
1029
  const result = {
960
1030
  success: true,
961
1031
  notModified: false,
962
1032
  etag: response.headers?.get?.("etag") ?? null,
963
- firewall: Array.isArray(data.firewall) ? data.firewall : [],
1033
+ firewall: data.firewall,
964
1034
  whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
965
1035
  whitelist_keys: data.whitelist_keys ?? {}
966
1036
  };
@@ -1062,6 +1132,29 @@ async function readCappedText(request, max) {
1062
1132
  } catch {
1063
1133
  return "";
1064
1134
  }
1135
+ const body = clone.body;
1136
+ if (body && typeof body.getReader === "function") {
1137
+ const reader = body.getReader();
1138
+ const chunks = [];
1139
+ let buffered = 0;
1140
+ try {
1141
+ for (; ; ) {
1142
+ const { done, value } = await reader.read();
1143
+ if (done) break;
1144
+ if (!value || buffered >= max) continue;
1145
+ const take = Math.min(value.byteLength, max - buffered);
1146
+ chunks.push(take === value.byteLength ? value : value.subarray(0, take));
1147
+ buffered += take;
1148
+ }
1149
+ } catch {
1150
+ return "";
1151
+ }
1152
+ try {
1153
+ return new TextDecoder().decode(concatChunks(chunks, buffered));
1154
+ } catch {
1155
+ return "";
1156
+ }
1157
+ }
1065
1158
  try {
1066
1159
  const text = await clone.text();
1067
1160
  return text.length > max ? text.slice(0, max) : text;
@@ -1069,6 +1162,15 @@ async function readCappedText(request, max) {
1069
1162
  return "";
1070
1163
  }
1071
1164
  }
1165
+ function concatChunks(chunks, total) {
1166
+ const out = new Uint8Array(total);
1167
+ let offset = 0;
1168
+ for (const c of chunks) {
1169
+ out.set(c, offset);
1170
+ offset += c.byteLength;
1171
+ }
1172
+ return out;
1173
+ }
1072
1174
  function parseMultipart(rawBody, boundary) {
1073
1175
  const body = {};
1074
1176
  const files = {};
@@ -1113,7 +1215,16 @@ function fromNodeRequest(req, rawBody = "") {
1113
1215
  headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
1114
1216
  }
1115
1217
  const host = headers.host || "localhost";
1116
- const url = new URL(req.url || "/", `http://${host}`);
1218
+ let url;
1219
+ try {
1220
+ url = new URL(req.url || "/", `http://${host}`);
1221
+ } catch {
1222
+ try {
1223
+ url = new URL(req.url || "/", "http://localhost");
1224
+ } catch {
1225
+ url = new URL("http://localhost/");
1226
+ }
1227
+ }
1117
1228
  const query = {};
1118
1229
  for (const [key, value] of url.searchParams) {
1119
1230
  if (key in query) {
@@ -1124,6 +1235,7 @@ function fromNodeRequest(req, rawBody = "") {
1124
1235
  }
1125
1236
  const contentType = headers["content-type"] || "";
1126
1237
  let body = {};
1238
+ let files;
1127
1239
  if (rawBody) {
1128
1240
  if (contentType.includes("application/json")) {
1129
1241
  try {
@@ -1136,6 +1248,13 @@ function fromNodeRequest(req, rawBody = "") {
1136
1248
  for (const [k, v] of new URLSearchParams(rawBody)) {
1137
1249
  body[k] = k in body ? [].concat(body[k], v) : v;
1138
1250
  }
1251
+ } else if (contentType.includes("multipart/form-data")) {
1252
+ const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
1253
+ if (boundary) {
1254
+ const parsed = parseMultipart(rawBody, boundary);
1255
+ body = parsed.body;
1256
+ files = parsed.files;
1257
+ }
1139
1258
  }
1140
1259
  }
1141
1260
  const uri = url.pathname + url.search;
@@ -1146,6 +1265,7 @@ function fromNodeRequest(req, rawBody = "") {
1146
1265
  originalUrl: uri,
1147
1266
  query,
1148
1267
  body,
1268
+ files,
1149
1269
  headers,
1150
1270
  ip: forwarded.split(",")[0].trim() || req.socket && req.socket.remoteAddress || "",
1151
1271
  cookies: parseCookies2(headers.cookie),
@@ -1169,15 +1289,43 @@ function parseCookies2(header) {
1169
1289
  }
1170
1290
 
1171
1291
  // src/protect/egress.js
1172
- async function installEgressGuard({ shouldBlock, onBlock } = {}) {
1292
+ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
1173
1293
  const restores = [];
1174
1294
  if (typeof shouldBlock !== "function") return () => {
1175
1295
  };
1296
+ const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
1176
1297
  const block = (url, host, method) => {
1177
1298
  if (!shouldBlock(url, host, method)) return false;
1178
1299
  onBlock?.({ url, host, method });
1179
1300
  return true;
1180
1301
  };
1302
+ let screen = null;
1303
+ if (dnsScreen) {
1304
+ try {
1305
+ const resolveLookup = lookup ?? (await import("dns")).lookup;
1306
+ const { isIP } = await import("net");
1307
+ if (typeof resolveLookup === "function" && typeof isIP === "function") {
1308
+ screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
1309
+ }
1310
+ } catch {
1311
+ screen = null;
1312
+ }
1313
+ }
1314
+ const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
1315
+ if (!screen || !host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
1316
+ try {
1317
+ screen.lookup(host, { all: true }, (err, addresses) => {
1318
+ if (err || !Array.isArray(addresses)) return resolve(false);
1319
+ for (const a of addresses) {
1320
+ const ip = a && typeof a === "object" ? a.address : a;
1321
+ if (ip && block(url, ip, method)) return resolve(true);
1322
+ }
1323
+ resolve(false);
1324
+ });
1325
+ } catch {
1326
+ resolve(false);
1327
+ }
1328
+ });
1181
1329
  const originalFetch = globalThis.fetch;
1182
1330
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
1183
1331
  const guarded = async (input, init) => {
@@ -1189,7 +1337,7 @@ async function installEgressGuard({ shouldBlock, onBlock } = {}) {
1189
1337
  host = null;
1190
1338
  }
1191
1339
  const method = init && init.method || input && input.method || "GET";
1192
- if (block(url, host, method)) {
1340
+ if (block(url, host, method) || await resolvesToDisallowed(url, host, method)) {
1193
1341
  throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
1194
1342
  }
1195
1343
  return originalFetch(input, init);
@@ -1203,7 +1351,7 @@ async function installEgressGuard({ shouldBlock, onBlock } = {}) {
1203
1351
  for (const moduleName of ["node:http", "node:https"]) {
1204
1352
  try {
1205
1353
  const mod = await import(moduleName);
1206
- const restore = patchHttpModule(mod.default ?? mod, block);
1354
+ const restore = patchHttpModule(mod.default ?? mod, block, screen);
1207
1355
  if (restore) restores.push(restore);
1208
1356
  } catch {
1209
1357
  }
@@ -1241,7 +1389,7 @@ async function installEgressGuard({ shouldBlock, onBlock } = {}) {
1241
1389
  }
1242
1390
  };
1243
1391
  }
1244
- function patchHttpModule(http, block) {
1392
+ function patchHttpModule(http, block, screen) {
1245
1393
  if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
1246
1394
  const originalRequest = http.request;
1247
1395
  const originalGet = http.get;
@@ -1250,14 +1398,25 @@ function patchHttpModule(http, block) {
1250
1398
  if (target && block(target.url, target.host, target.method)) {
1251
1399
  throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
1252
1400
  }
1401
+ if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
1402
+ try {
1403
+ args = withScreeningLookup(args, target, block, screen.lookup);
1404
+ } catch {
1405
+ }
1406
+ }
1253
1407
  return original.apply(this, args);
1254
1408
  };
1255
- http.request = wrap(originalRequest);
1256
- if (typeof originalGet === "function") http.get = wrap(originalGet);
1409
+ const guardedRequest = wrap(originalRequest);
1410
+ http.request = guardedRequest;
1411
+ let guardedGet;
1412
+ if (typeof originalGet === "function") {
1413
+ guardedGet = wrap(originalGet);
1414
+ http.get = guardedGet;
1415
+ }
1257
1416
  http.__patchstackGuarded = true;
1258
1417
  return () => {
1259
- http.request = originalRequest;
1260
- if (typeof originalGet === "function") http.get = originalGet;
1418
+ if (http.request === guardedRequest) http.request = originalRequest;
1419
+ if (guardedGet && http.get === guardedGet) http.get = originalGet;
1261
1420
  delete http.__patchstackGuarded;
1262
1421
  };
1263
1422
  }
@@ -1270,7 +1429,7 @@ function extractHttpTarget(args) {
1270
1429
  return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
1271
1430
  }
1272
1431
  if (first && typeof first === "object") {
1273
- const host = normalizeHost(first.hostname || first.host);
1432
+ const host = normalizeHost(first.hostname || first.host) || "localhost";
1274
1433
  const protocol = first.protocol || "http:";
1275
1434
  const port = first.port ? `:${first.port}` : "";
1276
1435
  const path = first.path || "/";
@@ -1287,6 +1446,61 @@ function normalizeHost(raw) {
1287
1446
  if ((host.match(/:/g) || []).length > 1) return host;
1288
1447
  return host.split(":")[0];
1289
1448
  }
1449
+ function screenResolved(addresses, target, block) {
1450
+ for (const a of addresses || []) {
1451
+ const ip = a && typeof a === "object" ? a.address : a;
1452
+ if (ip && block(target.url, ip, target.method)) return ip;
1453
+ }
1454
+ return null;
1455
+ }
1456
+ function withScreeningLookup(args, target, block, lookup) {
1457
+ const screeningLookup = (hostname, options, callback) => {
1458
+ let opts = options;
1459
+ let cb = callback;
1460
+ if (typeof opts === "function") {
1461
+ cb = opts;
1462
+ opts = {};
1463
+ }
1464
+ if (!opts || typeof opts !== "object") opts = {};
1465
+ try {
1466
+ lookup(hostname, { ...opts, all: true }, (err, addresses) => {
1467
+ if (err) return cb(err);
1468
+ const list = Array.isArray(addresses) ? addresses : [];
1469
+ const blocked = screenResolved(list, target, block);
1470
+ if (blocked) {
1471
+ return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
1472
+ }
1473
+ if (opts.all) return cb(null, list);
1474
+ const first = list[0];
1475
+ if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
1476
+ return cb(null, first.address, first.family);
1477
+ });
1478
+ } catch {
1479
+ try {
1480
+ lookup(hostname, opts, cb);
1481
+ } catch {
1482
+ cb(new Error(`Patchstack: lookup failed for ${hostname}`));
1483
+ }
1484
+ }
1485
+ };
1486
+ return injectLookupOption(args, screeningLookup);
1487
+ }
1488
+ function injectLookupOption(args, lookup) {
1489
+ const first = args[0];
1490
+ if (first && typeof first === "object" && !(first instanceof URL)) {
1491
+ return [{ ...first, lookup }, ...args.slice(1)];
1492
+ }
1493
+ const rest = args.slice(1);
1494
+ const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
1495
+ if (optIdx !== -1) {
1496
+ const next = [...rest];
1497
+ next[optIdx] = { ...rest[optIdx], lookup };
1498
+ return [first, ...next];
1499
+ }
1500
+ const cbIdx = rest.findIndex((a) => typeof a === "function");
1501
+ if (cbIdx === -1) return [first, { lookup }, ...rest];
1502
+ return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
1503
+ }
1290
1504
 
1291
1505
  // src/protect/defaults.js
1292
1506
  var DEFAULT_RESPONSE_RULES = [
@@ -1530,22 +1744,33 @@ async function createProtection(options = {}) {
1530
1744
  const onError = options.onError;
1531
1745
  const onDetect = options.onDetect ?? defaultOnDetect;
1532
1746
  const bundle = await resolveRules(options);
1533
- const incoming = bundle.firewall ?? [];
1534
- const requestRules = byPhase(incoming, "request");
1535
- const responseRules = [...options.responseRules ?? DEFAULT_RESPONSE_RULES, ...byPhase(incoming, "response")];
1536
- const egressRules = [...options.egressRules ?? DEFAULT_EGRESS_RULES, ...byPhase(incoming, "egress")];
1537
- const engine = new RuleEngine({
1538
- firewall: requestRules,
1539
- whitelists: bundle.whitelists,
1540
- whitelist_keys: bundle.whitelist_keys,
1541
- onError
1542
- });
1543
- const responseRuleSet = responseRules.map((rule) => ({
1544
- rule,
1545
- engine: new RuleEngine({ firewall: [rule], onError }),
1546
- redactors: rule.action === "redact" ? extractRedactors(rule) : null
1547
- }));
1548
- const egressEngine = new RuleEngine({ firewall: egressRules, onError });
1747
+ let requestRules;
1748
+ let responseRules;
1749
+ let egressRules;
1750
+ let screenCap;
1751
+ let engine;
1752
+ let responseRuleSet;
1753
+ let egressEngine;
1754
+ const applyBundle = (delivered) => {
1755
+ const incoming = delivered.firewall ?? [];
1756
+ requestRules = byPhase(incoming, "request");
1757
+ responseRules = [...options.responseRules ?? DEFAULT_RESPONSE_RULES, ...byPhase(incoming, "response")];
1758
+ screenCap = responseScreenCap(responseRules);
1759
+ egressRules = [...options.egressRules ?? DEFAULT_EGRESS_RULES, ...byPhase(incoming, "egress")];
1760
+ engine = new RuleEngine({
1761
+ firewall: requestRules,
1762
+ whitelists: delivered.whitelists,
1763
+ whitelist_keys: delivered.whitelist_keys,
1764
+ onError
1765
+ });
1766
+ responseRuleSet = responseRules.map((rule) => ({
1767
+ rule,
1768
+ engine: new RuleEngine({ firewall: [rule], onError }),
1769
+ redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null
1770
+ }));
1771
+ egressEngine = new RuleEngine({ firewall: egressRules, onError });
1772
+ };
1773
+ applyBundle(bundle);
1549
1774
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
1550
1775
  const decide = (phase, result, block, allow2) => {
1551
1776
  if (!result || !result.blocked) return allow2();
@@ -1579,20 +1804,26 @@ async function createProtection(options = {}) {
1579
1804
  const headers = { ...meta.headers || {} };
1580
1805
  for (const { rule, redactors } of redactions) {
1581
1806
  const mask = maskFn(rule.category);
1582
- body = applyRedactors(body, redactors, mask);
1807
+ const transform = rule.action === "encode" ? htmlEscape : null;
1808
+ const pathRedactors = redactors.filter((r) => r.jsonPath);
1809
+ const spanRedactors = redactors.filter((r) => !r.jsonPath);
1810
+ if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
1811
+ if (!spanRedactors.length) continue;
1812
+ body = applyRedactors(body, spanRedactors, mask, transform);
1813
+ if (transform) continue;
1583
1814
  for (const name of Object.keys(headers)) {
1584
1815
  const value = headers[name];
1585
1816
  if (typeof value === "string") {
1586
- headers[name] = applyRedactors(value, redactors, mask);
1817
+ headers[name] = applyRedactors(value, spanRedactors, mask);
1587
1818
  } else if (Array.isArray(value)) {
1588
- headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, redactors, mask) : item);
1819
+ headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
1589
1820
  }
1590
1821
  }
1591
1822
  }
1592
1823
  return { verdict: "redact", body, headers };
1593
1824
  };
1594
1825
  const screenResp = async (response) => {
1595
- const text = await readTextResponse(response);
1826
+ const text = await readTextResponse(response, screenCap);
1596
1827
  if (text == null) return response;
1597
1828
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
1598
1829
  if (r.verdict === "block") return leakResponse();
@@ -1605,12 +1836,15 @@ async function createProtection(options = {}) {
1605
1836
  const chunks = [];
1606
1837
  let size = 0;
1607
1838
  let overflow = false;
1608
- const MAX = 512 * 1024;
1839
+ const MAX = screenCap;
1609
1840
  const collect = (chunk, enc) => {
1610
1841
  if (chunk == null) return;
1611
1842
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
1612
1843
  size += buf.length;
1613
1844
  if (size > MAX) {
1845
+ for (const c of chunks) origWrite(c);
1846
+ chunks.length = 0;
1847
+ origWrite(buf);
1614
1848
  overflow = true;
1615
1849
  return;
1616
1850
  }
@@ -1637,6 +1871,7 @@ async function createProtection(options = {}) {
1637
1871
  return origEnd(cb);
1638
1872
  }
1639
1873
  collect(chunk, enc);
1874
+ if (overflow) return origEnd(cb);
1640
1875
  const text = Buffer.concat(chunks).toString("utf8");
1641
1876
  let ct = res.getHeader ? res.getHeader("content-type") : void 0;
1642
1877
  if (Array.isArray(ct)) ct = ct[0];
@@ -1668,6 +1903,7 @@ async function createProtection(options = {}) {
1668
1903
  if (r.headers && res.setHeader) {
1669
1904
  const current = res.getHeaders ? res.getHeaders() : {};
1670
1905
  for (const [name, value] of Object.entries(r.headers)) {
1906
+ if (name.toLowerCase() === "content-length") continue;
1671
1907
  if (Array.isArray(value)) {
1672
1908
  try {
1673
1909
  res.setHeader(name, value);
@@ -1703,7 +1939,9 @@ async function createProtection(options = {}) {
1703
1939
  };
1704
1940
  const protection = {
1705
1941
  mode,
1706
- rules: { request: requestRules, response: responseRules, egress: egressRules },
1942
+ get rules() {
1943
+ return { request: requestRules, response: responseRules, egress: egressRules };
1944
+ },
1707
1945
  // Screen a fetch Response through the response-phase rules (redact/block). Used by
1708
1946
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
1709
1947
  screenResponse: (response) => screenResp(response),
@@ -1814,27 +2052,101 @@ async function createProtection(options = {}) {
1814
2052
  }
1815
2053
  };
1816
2054
  if (options.egress) {
1817
- protection.uninstallEgress = await installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock });
2055
+ protection.uninstallEgress = await installEgressGuard({
2056
+ shouldBlock: egressShouldBlock,
2057
+ onBlock: options.onEgressBlock,
2058
+ dnsScreen: options.screenDns !== false,
2059
+ allowHosts: options.allowHosts
2060
+ });
2061
+ }
2062
+ if (options.refreshMs > 0 && (options.siteUuid || options.token)) {
2063
+ const cwd = options.cwd ?? (typeof process !== "undefined" ? process.cwd() : void 0);
2064
+ let reportManifest = null;
2065
+ if (options.siteUuid && options.reportManifest !== false && cwd) {
2066
+ try {
2067
+ ({ reportManifest } = await import("./refresh-manifest-XBOGN446.js"));
2068
+ } catch (err) {
2069
+ onError?.(err);
2070
+ }
2071
+ }
2072
+ const timer = setInterval(() => {
2073
+ (async () => {
2074
+ if (reportManifest) {
2075
+ try {
2076
+ await reportManifest(cwd);
2077
+ } catch (err) {
2078
+ onError?.(err);
2079
+ }
2080
+ }
2081
+ applyBundle(await resolveRules(options));
2082
+ })().catch((err) => onError?.(err));
2083
+ }, options.refreshMs);
2084
+ timer.unref?.();
2085
+ protection.stopRefresh = () => clearInterval(timer);
1818
2086
  }
1819
2087
  return protection;
1820
2088
  }
1821
2089
  function byPhase(rules, phase) {
1822
2090
  return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
1823
2091
  }
1824
- async function readTextResponse(response) {
2092
+ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
1825
2093
  if (!response || typeof response.clone !== "function") return null;
1826
2094
  const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
1827
2095
  const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
1828
2096
  if (!isText) return null;
1829
2097
  const len = Number(response.headers?.get?.("content-length") || 0);
1830
- if (len && len > 512 * 1024) return null;
2098
+ if (len && len > cap) return null;
2099
+ let clone;
1831
2100
  try {
1832
- const text = await response.clone().text();
1833
- return text.length > 512 * 1024 ? null : text;
2101
+ clone = response.clone();
2102
+ } catch {
2103
+ return null;
2104
+ }
2105
+ const body = clone.body;
2106
+ if (body && typeof body.getReader === "function") {
2107
+ const reader = body.getReader();
2108
+ const chunks = [];
2109
+ let size = 0;
2110
+ let over = false;
2111
+ try {
2112
+ for (; ; ) {
2113
+ const { done, value } = await reader.read();
2114
+ if (done) break;
2115
+ if (!value) continue;
2116
+ size += value.byteLength;
2117
+ if (over) continue;
2118
+ if (size > cap) {
2119
+ over = true;
2120
+ continue;
2121
+ }
2122
+ chunks.push(value);
2123
+ }
2124
+ } catch {
2125
+ return null;
2126
+ }
2127
+ if (over) return null;
2128
+ try {
2129
+ return new TextDecoder().decode(concatBytes(chunks, size));
2130
+ } catch {
2131
+ return null;
2132
+ }
2133
+ }
2134
+ try {
2135
+ const text = await clone.text();
2136
+ return text.length > cap ? null : text;
1834
2137
  } catch {
1835
2138
  return null;
1836
2139
  }
1837
2140
  }
2141
+ function concatBytes(chunks, total) {
2142
+ const out = new Uint8Array(total);
2143
+ let offset = 0;
2144
+ for (const c of chunks) {
2145
+ out.set(c, offset);
2146
+ offset += c.byteLength;
2147
+ }
2148
+ return out;
2149
+ }
1838
2150
  function headerObject(headers) {
1839
2151
  const out = {};
1840
2152
  headers?.forEach?.((v, k) => {
@@ -1852,30 +2164,137 @@ function extractRedactors(rule) {
1852
2164
  const m = c.match;
1853
2165
  if (!m) continue;
1854
2166
  if (m.type === "regex" && typeof m.value === "string") {
1855
- const parsed = m.value.match(/^\/(.+)\/([a-z]*)$/is);
1856
- if (parsed) {
1857
- const flags = parsed[2].includes("g") ? parsed[2] : parsed[2] + "g";
2167
+ const safe = safeRegExp(m.value);
2168
+ if (safe) {
2169
+ const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
1858
2170
  try {
1859
- out.push({ re: new RegExp(parsed[1], flags) });
2171
+ out.push({ re: new RegExp(safe.source, flags) });
1860
2172
  } catch {
1861
2173
  }
1862
2174
  }
1863
2175
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
1864
2176
  out.push({ literal: String(m.value) });
2177
+ } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
2178
+ const keys = Array.isArray(m.key) ? m.key : [m.key];
2179
+ for (const key of keys) {
2180
+ out.push({ jsonPath: String(key).split("."), condition: m.match });
2181
+ }
1865
2182
  }
1866
2183
  }
1867
2184
  };
1868
2185
  walk(rule.rule_v2);
1869
2186
  return out;
1870
2187
  }
1871
- function applyRedactors(body, redactors, mask) {
2188
+ function htmlEscape(str) {
2189
+ return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2190
+ }
2191
+ function applyRedactors(body, redactors, mask, transform) {
1872
2192
  let out = body;
1873
2193
  for (const r of redactors) {
1874
- if (r.re) out = out.replace(r.re, mask);
1875
- else if (r.literal) out = out.split(r.literal).join(mask);
2194
+ if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
2195
+ else if (r.literal) out = out.split(r.literal).join(transform ? transform(r.literal) : mask);
1876
2196
  }
1877
2197
  return out;
1878
2198
  }
2199
+ function isBodyParam(parameter) {
2200
+ return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
2201
+ }
2202
+ function conditionPredicate(condition) {
2203
+ if (!condition || !condition.type) return () => true;
2204
+ return (value) => {
2205
+ try {
2206
+ return matchValue(condition.type, value, condition.value, condition);
2207
+ } catch {
2208
+ return false;
2209
+ }
2210
+ };
2211
+ }
2212
+ var DEFAULT_SCREEN_CAP = 512 * 1024;
2213
+ function responseScreenCap(rules) {
2214
+ let cap = DEFAULT_SCREEN_CAP;
2215
+ for (const r of rules ?? []) {
2216
+ if (r && r.bypass_limit === true) return Infinity;
2217
+ const n = Number(r && r.max_bytes);
2218
+ if (Number.isFinite(n) && n > cap) cap = n;
2219
+ }
2220
+ return cap;
2221
+ }
2222
+ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
2223
+ if (!pathRedactors.length || typeof text !== "string" || text.length > cap) return text;
2224
+ const head = text.trimStart()[0];
2225
+ if (head !== "{" && head !== "[") return text;
2226
+ const preserved = preserveBigInts(text);
2227
+ let obj;
2228
+ try {
2229
+ obj = JSON.parse(preserved);
2230
+ } catch {
2231
+ return text;
2232
+ }
2233
+ let changed = false;
2234
+ for (const r of pathRedactors) {
2235
+ const pred = conditionPredicate(r.condition);
2236
+ walkLeaves(obj, r.jsonPath, (loc) => {
2237
+ try {
2238
+ if (pred(loc.value)) {
2239
+ loc.parent[loc.key] = transform ? transform(String(loc.value)) : mask;
2240
+ changed = true;
2241
+ }
2242
+ } catch {
2243
+ }
2244
+ });
2245
+ }
2246
+ return changed ? restoreBigInts(JSON.stringify(obj)) : text;
2247
+ }
2248
+ var BIGINT_OPEN = "__PSBIGINT_9c2f__";
2249
+ var BIGINT_CLOSE = "__DNEGIB__";
2250
+ function preserveBigInts(text) {
2251
+ let out = "";
2252
+ let inStr = false;
2253
+ for (let i = 0; i < text.length; ) {
2254
+ const ch = text[i];
2255
+ if (inStr) {
2256
+ out += ch;
2257
+ if (ch === "\\") {
2258
+ out += text[i + 1] ?? "";
2259
+ i += 2;
2260
+ continue;
2261
+ }
2262
+ if (ch === '"') inStr = false;
2263
+ i++;
2264
+ continue;
2265
+ }
2266
+ if (ch === '"') {
2267
+ inStr = true;
2268
+ out += ch;
2269
+ i++;
2270
+ continue;
2271
+ }
2272
+ if (ch === "-" || ch >= "0" && ch <= "9") {
2273
+ let j = ch === "-" ? i + 1 : i;
2274
+ let digits = 0;
2275
+ while (j < text.length && text[j] >= "0" && text[j] <= "9") {
2276
+ digits++;
2277
+ j++;
2278
+ }
2279
+ const next = text[j];
2280
+ const isIntToken = digits > 0 && next !== "." && next !== "e" && next !== "E";
2281
+ if (isIntToken && digits >= 16) {
2282
+ out += `"${BIGINT_OPEN}${text.slice(i, j)}${BIGINT_CLOSE}"`;
2283
+ } else {
2284
+ out += text.slice(i, j || i + 1);
2285
+ }
2286
+ i = j > i ? j : i + 1;
2287
+ continue;
2288
+ }
2289
+ out += ch;
2290
+ i++;
2291
+ }
2292
+ return out;
2293
+ }
2294
+ function restoreBigInts(text) {
2295
+ if (!text.includes(BIGINT_OPEN)) return text;
2296
+ return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
2297
+ }
1879
2298
  function rebuildResponse(response, body, redactedHeaders) {
1880
2299
  const headers = new Headers(response.headers);
1881
2300
  headers.delete("content-length");