@patchstack/connect 0.3.8 → 0.3.10

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
@@ -117,10 +117,50 @@ var RequestResolver = class {
117
117
  return this.#resolveServer(key);
118
118
  case "files":
119
119
  return this.#resolveFiles(key);
120
+ case "response":
121
+ return this.#resolveResponse(key);
122
+ case "egress":
123
+ return this.#resolveEgress(key);
120
124
  default:
121
125
  return [];
122
126
  }
123
127
  }
128
+ // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
129
+ // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
130
+ #resolveResponse(key) {
131
+ const resp = this.#req._response;
132
+ if (!resp) {
133
+ return [];
134
+ }
135
+ if (key === "status") {
136
+ return resp.status !== void 0 ? [String(resp.status)] : [];
137
+ }
138
+ if (key === "body") {
139
+ return resp.body != null && resp.body !== "" ? [resp.body] : [];
140
+ }
141
+ if (key === "headers") {
142
+ const headers = resp.headers ?? {};
143
+ return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
144
+ }
145
+ if (key.startsWith("header.")) {
146
+ const name = key.slice("header.".length).toLowerCase();
147
+ const value = (resp.headers ?? {})[name];
148
+ return value !== void 0 ? [value] : [];
149
+ }
150
+ return [];
151
+ }
152
+ // Egress-phase sources (req._egress = { url, host, method }). Lets rules inspect an
153
+ // OUTBOUND request the app is about to make — SSRF at the egress boundary.
154
+ #resolveEgress(key) {
155
+ const eg = this.#req._egress;
156
+ if (!eg) {
157
+ return [];
158
+ }
159
+ if (key === "url") return eg.url ? [eg.url] : [];
160
+ if (key === "host") return eg.host ? [eg.host] : [];
161
+ if (key === "method") return eg.method ? [eg.method] : [];
162
+ return [];
163
+ }
124
164
  applyMutations(mutations, value) {
125
165
  if (!mutations || !Array.isArray(mutations)) {
126
166
  return value;
@@ -613,6 +653,24 @@ function warnUnsupportedMatchType(type) {
613
653
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
614
654
  );
615
655
  }
656
+ function isInternalHost(hostname) {
657
+ if (!hostname) return false;
658
+ const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
659
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
660
+ if (host === "metadata.google.internal") return true;
661
+ if (host === "::1" || host === "::") return true;
662
+ if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
663
+ const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
664
+ if (v4) {
665
+ const a = Number(v4[1]);
666
+ const b = Number(v4[2]);
667
+ if (a === 127 || a === 10 || a === 0) return true;
668
+ if (a === 169 && b === 254) return true;
669
+ if (a === 172 && b >= 16 && b <= 31) return true;
670
+ if (a === 192 && b === 168) return true;
671
+ }
672
+ return false;
673
+ }
616
674
  function matchValue(type, value, matchVal, matchObj) {
617
675
  if (value === null || value === void 0) {
618
676
  if (type === "isset") {
@@ -679,6 +737,8 @@ function matchValue(type, value, matchVal, matchObj) {
679
737
  return false;
680
738
  }
681
739
  }
740
+ case "internal_host":
741
+ return isInternalHost(strValue);
682
742
  case "quotes":
683
743
  // engine-php exposes `inline_js_xss` as an alias of `quotes`.
684
744
  case "inline_js_xss":
@@ -790,7 +850,8 @@ var RuleEngine = class {
790
850
  if (parameter === "rules" && Array.isArray(condition.rules)) {
791
851
  return this.#evaluateRule(condition.rules, resolver);
792
852
  }
793
- const values = resolver.resolve(parameter);
853
+ const params = Array.isArray(parameter) ? parameter : [parameter];
854
+ const values = params.flatMap((p) => resolver.resolve(p));
794
855
  if (values.length === 0) {
795
856
  return false;
796
857
  }
@@ -972,6 +1033,98 @@ function parseCookies2(header) {
972
1033
  return cookies;
973
1034
  }
974
1035
 
1036
+ // src/protect/egress.js
1037
+ function installEgressGuard({ shouldBlock, onBlock } = {}) {
1038
+ const original = globalThis.fetch;
1039
+ if (typeof original !== "function" || original.__patchstackGuarded || typeof shouldBlock !== "function") {
1040
+ return () => {
1041
+ };
1042
+ }
1043
+ const guarded = async (input, init) => {
1044
+ const url = typeof input === "string" ? input : input && input.url || String(input);
1045
+ let host = null;
1046
+ try {
1047
+ host = new URL(url).hostname;
1048
+ } catch {
1049
+ host = null;
1050
+ }
1051
+ const method = init && init.method || input && input.method || "GET";
1052
+ if (shouldBlock(url, host, method)) {
1053
+ onBlock?.({ url, host, method });
1054
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
1055
+ }
1056
+ return original(input, init);
1057
+ };
1058
+ guarded.__patchstackGuarded = true;
1059
+ globalThis.fetch = guarded;
1060
+ return () => {
1061
+ if (globalThis.fetch === guarded) {
1062
+ globalThis.fetch = original;
1063
+ }
1064
+ };
1065
+ }
1066
+
1067
+ // src/protect/defaults.js
1068
+ var DEFAULT_RESPONSE_RULES = [
1069
+ {
1070
+ id: "resp-private-key",
1071
+ title: "Private key in response body",
1072
+ phase: "response",
1073
+ category: "secret-exposure",
1074
+ action: "redact",
1075
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
1076
+ },
1077
+ {
1078
+ id: "resp-aws-access-key",
1079
+ title: "AWS access key id in response body",
1080
+ phase: "response",
1081
+ category: "secret-exposure",
1082
+ action: "redact",
1083
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
1084
+ },
1085
+ {
1086
+ id: "resp-gcp-api-key",
1087
+ title: "Google API key in response body",
1088
+ phase: "response",
1089
+ category: "secret-exposure",
1090
+ action: "redact",
1091
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
1092
+ },
1093
+ {
1094
+ id: "resp-jwt",
1095
+ title: "JWT in response body",
1096
+ phase: "response",
1097
+ category: "secret-exposure",
1098
+ action: "redact",
1099
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
1100
+ },
1101
+ {
1102
+ id: "resp-db-connection-string",
1103
+ title: "Database connection string with credentials in response body",
1104
+ phase: "response",
1105
+ category: "secret-exposure",
1106
+ action: "redact",
1107
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
1108
+ },
1109
+ {
1110
+ id: "resp-stack-trace",
1111
+ title: "Node stack trace leaking in response body",
1112
+ phase: "response",
1113
+ category: "info-exposure",
1114
+ action: "redact",
1115
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
1116
+ }
1117
+ ];
1118
+ var DEFAULT_EGRESS_RULES = [
1119
+ {
1120
+ id: "egress-internal-address",
1121
+ title: "Outbound request to an internal / metadata address (SSRF)",
1122
+ phase: "egress",
1123
+ category: "ssrf",
1124
+ rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
1125
+ }
1126
+ ];
1127
+
975
1128
  // src/protect/runtime.js
976
1129
  import { readFileSync, writeFileSync, mkdirSync } from "fs";
977
1130
  import { join } from "path";
@@ -1050,16 +1203,75 @@ async function createProtection(options = {}) {
1050
1203
  const onError = options.onError;
1051
1204
  const onDetect = options.onDetect ?? defaultOnDetect;
1052
1205
  const bundle = await resolveRules(options);
1053
- const engine = new RuleEngine({ ...bundle, onError });
1054
- const decide = (result, block, allow) => {
1055
- if (!result || !result.blocked) return allow();
1056
- onDetect({ mode, rule: result.rule, message: result.message });
1057
- return mode === "block" ? block() : allow();
1206
+ const incoming = bundle.firewall ?? [];
1207
+ const requestRules = byPhase(incoming, "request");
1208
+ const responseRules = [...options.responseRules ?? DEFAULT_RESPONSE_RULES, ...byPhase(incoming, "response")];
1209
+ const egressRules = [...options.egressRules ?? DEFAULT_EGRESS_RULES, ...byPhase(incoming, "egress")];
1210
+ const engine = new RuleEngine({
1211
+ firewall: requestRules,
1212
+ whitelists: bundle.whitelists,
1213
+ whitelist_keys: bundle.whitelist_keys,
1214
+ onError
1215
+ });
1216
+ const responseRuleSet = responseRules.map((rule) => ({
1217
+ rule,
1218
+ engine: new RuleEngine({ firewall: [rule], onError }),
1219
+ redactors: rule.action === "redact" ? extractRedactors(rule) : null
1220
+ }));
1221
+ const egressEngine = new RuleEngine({ firewall: egressRules, onError });
1222
+ const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
1223
+ const decide = (phase, result, block, allow2) => {
1224
+ if (!result || !result.blocked) return allow2();
1225
+ onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
1226
+ return mode === "block" ? block() : allow2();
1227
+ };
1228
+ const screenFetchResponse = async (response) => {
1229
+ const text = await readTextResponse(response);
1230
+ if (text == null) return response;
1231
+ const meta = { status: response.status, headers: headerObject(response.headers) };
1232
+ let blockRule = null;
1233
+ const redactions = [];
1234
+ for (const { rule, engine: re, redactors } of responseRuleSet) {
1235
+ let result;
1236
+ try {
1237
+ result = re.evaluate({ _response: { ...meta, body: text } });
1238
+ } catch (err) {
1239
+ onError?.(err);
1240
+ continue;
1241
+ }
1242
+ if (!result.blocked) continue;
1243
+ onDetect({ phase: "response", mode, category: rule.category, rule, message: result.message });
1244
+ if (mode !== "block") continue;
1245
+ if (redactors && redactors.length) redactions.push({ rule, redactors });
1246
+ else if (!blockRule) blockRule = rule;
1247
+ }
1248
+ if (mode !== "block") return response;
1249
+ if (blockRule) return leakResponse();
1250
+ if (redactions.length) {
1251
+ let body = text;
1252
+ for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
1253
+ return rebuildResponse(response, body);
1254
+ }
1255
+ return response;
1256
+ };
1257
+ const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
1258
+ const egressShouldBlock = (url, host, method) => {
1259
+ if (host && allow.has(host.toLowerCase())) return false;
1260
+ let result;
1261
+ try {
1262
+ result = egressEngine.evaluate({ _egress: { url, host, method } });
1263
+ } catch (err) {
1264
+ onError?.(err);
1265
+ return false;
1266
+ }
1267
+ if (!result.blocked) return false;
1268
+ onDetect({ phase: "egress", mode, category: result.rule?.category, rule: result.rule, message: result.message });
1269
+ return mode === "block";
1058
1270
  };
1059
1271
  const protection = {
1060
1272
  mode,
1061
- rules: bundle,
1062
- // (request) => Response | null (null = allow, caller proceeds)
1273
+ rules: { request: requestRules, response: responseRules, egress: egressRules },
1274
+ // (request) => Response | null (null = allow, caller proceeds). Request phase only.
1063
1275
  fetchGuard() {
1064
1276
  return async (request) => {
1065
1277
  let result;
@@ -1069,15 +1281,20 @@ async function createProtection(options = {}) {
1069
1281
  onError?.(err);
1070
1282
  return null;
1071
1283
  }
1072
- return decide(result, () => blockResponse(result), () => null);
1284
+ return decide("request", result, () => blockResponse(result), () => null);
1073
1285
  };
1074
1286
  },
1075
- // Wrap a fetch handler: export default { fetch: protection.fetch(app.fetch) }
1287
+ // Wrap a fetch handler: screens the request, then the response (redact/block).
1076
1288
  fetch(handler) {
1077
1289
  const guard = protection.fetchGuard();
1078
- return async (request, ...rest) => await guard(request) ?? handler(request, ...rest);
1290
+ return async (request, ...rest) => {
1291
+ const blocked = await guard(request);
1292
+ if (blocked) return blocked;
1293
+ const response = await handler(request, ...rest);
1294
+ return screenFetchResponse(response);
1295
+ };
1079
1296
  },
1080
- // Express middleware (expects express-parsed req.query/req.body).
1297
+ // Express middleware (request phase; expects express-parsed req.query/req.body).
1081
1298
  express() {
1082
1299
  return (req, res, next) => {
1083
1300
  let result;
@@ -1087,10 +1304,10 @@ async function createProtection(options = {}) {
1087
1304
  onError?.(err);
1088
1305
  return next();
1089
1306
  }
1090
- decide(result, () => res.status(403).json(blockBody(result)), () => next());
1307
+ decide("request", result, () => res.status(403).json(blockBody(result)), () => next());
1091
1308
  };
1092
1309
  },
1093
- // Node / Connect middleware — buffers the body itself (no body-parser needed).
1310
+ // Node / Connect middleware — buffers the body itself (request phase).
1094
1311
  node(nodeOptions = {}) {
1095
1312
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
1096
1313
  return (req, res, next) => {
@@ -1111,28 +1328,104 @@ async function createProtection(options = {}) {
1111
1328
  });
1112
1329
  req.on("end", () => {
1113
1330
  const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
1331
+ let shaped;
1114
1332
  let result;
1115
1333
  try {
1116
- result = engine.evaluate(fromNodeRequest(req, rawBody));
1334
+ shaped = fromNodeRequest(req, rawBody);
1335
+ result = engine.evaluate(shaped);
1117
1336
  } catch (err) {
1118
1337
  onError?.(err);
1119
1338
  return next();
1120
1339
  }
1121
1340
  decide(
1341
+ "request",
1122
1342
  result,
1123
1343
  () => {
1124
1344
  res.statusCode = 403;
1125
1345
  res.setHeader("content-type", "application/json");
1126
1346
  res.end(JSON.stringify(blockBody(result)));
1127
1347
  },
1128
- () => next()
1348
+ () => {
1349
+ if (req.body === void 0) req.body = shaped.body;
1350
+ next();
1351
+ }
1129
1352
  );
1130
1353
  });
1131
1354
  };
1132
1355
  }
1133
1356
  };
1357
+ if (options.egress) {
1358
+ protection.uninstallEgress = installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock });
1359
+ }
1134
1360
  return protection;
1135
1361
  }
1362
+ function byPhase(rules, phase) {
1363
+ return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
1364
+ }
1365
+ async function readTextResponse(response) {
1366
+ if (!response || typeof response.clone !== "function") return null;
1367
+ const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
1368
+ const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
1369
+ if (!isText) return null;
1370
+ const len = Number(response.headers?.get?.("content-length") || 0);
1371
+ if (len && len > 512 * 1024) return null;
1372
+ try {
1373
+ const text = await response.clone().text();
1374
+ return text.length > 512 * 1024 ? null : text;
1375
+ } catch {
1376
+ return null;
1377
+ }
1378
+ }
1379
+ function headerObject(headers) {
1380
+ const out = {};
1381
+ headers?.forEach?.((v, k) => {
1382
+ out[k.toLowerCase()] = v;
1383
+ });
1384
+ return out;
1385
+ }
1386
+ function extractRedactors(rule) {
1387
+ const out = [];
1388
+ const walk = (conds) => {
1389
+ for (const c of conds ?? []) {
1390
+ if (Array.isArray(c.rules)) walk(c.rules);
1391
+ const m = c.match;
1392
+ if (!m) continue;
1393
+ if (m.type === "regex" && typeof m.value === "string") {
1394
+ const parsed = m.value.match(/^\/(.+)\/([a-z]*)$/is);
1395
+ if (parsed) {
1396
+ const flags = parsed[2].includes("g") ? parsed[2] : parsed[2] + "g";
1397
+ try {
1398
+ out.push({ re: new RegExp(parsed[1], flags) });
1399
+ } catch {
1400
+ }
1401
+ }
1402
+ } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
1403
+ out.push({ literal: String(m.value) });
1404
+ }
1405
+ }
1406
+ };
1407
+ walk(rule.rule_v2);
1408
+ return out;
1409
+ }
1410
+ function applyRedactors(body, redactors, mask) {
1411
+ let out = body;
1412
+ for (const r of redactors) {
1413
+ if (r.re) out = out.replace(r.re, mask);
1414
+ else if (r.literal) out = out.split(r.literal).join(mask);
1415
+ }
1416
+ return out;
1417
+ }
1418
+ function rebuildResponse(response, body) {
1419
+ const headers = new Headers(response.headers);
1420
+ headers.delete("content-length");
1421
+ return new Response(body, { status: response.status, statusText: response.statusText, headers });
1422
+ }
1423
+ function leakResponse() {
1424
+ return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
1425
+ status: 500,
1426
+ headers: { "content-type": "application/json" }
1427
+ });
1428
+ }
1136
1429
  async function resolveRules(options) {
1137
1430
  if (options.rules) {
1138
1431
  return normalizeBundle(options.rules);
@@ -1197,9 +1490,9 @@ function blockResponse(result) {
1197
1490
  headers: { "content-type": "application/json" }
1198
1491
  });
1199
1492
  }
1200
- function defaultOnDetect({ mode, rule, message }) {
1493
+ function defaultOnDetect({ phase, mode, category, rule, message }) {
1201
1494
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
1202
- console.warn(`[patchstack] ${tag} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
1495
+ console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
1203
1496
  }
1204
1497
  export {
1205
1498
  GUARD_PATH,