@patchstack/connect 0.3.8 → 0.3.9

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
@@ -146,10 +146,50 @@ var RequestResolver = class {
146
146
  return this.#resolveServer(key);
147
147
  case "files":
148
148
  return this.#resolveFiles(key);
149
+ case "response":
150
+ return this.#resolveResponse(key);
151
+ case "egress":
152
+ return this.#resolveEgress(key);
149
153
  default:
150
154
  return [];
151
155
  }
152
156
  }
157
+ // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
158
+ // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
159
+ #resolveResponse(key) {
160
+ const resp = this.#req._response;
161
+ if (!resp) {
162
+ return [];
163
+ }
164
+ if (key === "status") {
165
+ return resp.status !== void 0 ? [String(resp.status)] : [];
166
+ }
167
+ if (key === "body") {
168
+ return resp.body != null && resp.body !== "" ? [resp.body] : [];
169
+ }
170
+ if (key === "headers") {
171
+ const headers = resp.headers ?? {};
172
+ return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
173
+ }
174
+ if (key.startsWith("header.")) {
175
+ const name = key.slice("header.".length).toLowerCase();
176
+ const value = (resp.headers ?? {})[name];
177
+ return value !== void 0 ? [value] : [];
178
+ }
179
+ return [];
180
+ }
181
+ // Egress-phase sources (req._egress = { url, host, method }). Lets rules inspect an
182
+ // OUTBOUND request the app is about to make — SSRF at the egress boundary.
183
+ #resolveEgress(key) {
184
+ const eg = this.#req._egress;
185
+ if (!eg) {
186
+ return [];
187
+ }
188
+ if (key === "url") return eg.url ? [eg.url] : [];
189
+ if (key === "host") return eg.host ? [eg.host] : [];
190
+ if (key === "method") return eg.method ? [eg.method] : [];
191
+ return [];
192
+ }
153
193
  applyMutations(mutations, value) {
154
194
  if (!mutations || !Array.isArray(mutations)) {
155
195
  return value;
@@ -642,6 +682,24 @@ function warnUnsupportedMatchType(type) {
642
682
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
643
683
  );
644
684
  }
685
+ function isInternalHost(hostname) {
686
+ if (!hostname) return false;
687
+ const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
688
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
689
+ if (host === "metadata.google.internal") return true;
690
+ if (host === "::1" || host === "::") return true;
691
+ if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
692
+ const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
693
+ if (v4) {
694
+ const a = Number(v4[1]);
695
+ const b = Number(v4[2]);
696
+ if (a === 127 || a === 10 || a === 0) return true;
697
+ if (a === 169 && b === 254) return true;
698
+ if (a === 172 && b >= 16 && b <= 31) return true;
699
+ if (a === 192 && b === 168) return true;
700
+ }
701
+ return false;
702
+ }
645
703
  function matchValue(type, value, matchVal, matchObj) {
646
704
  if (value === null || value === void 0) {
647
705
  if (type === "isset") {
@@ -708,6 +766,8 @@ function matchValue(type, value, matchVal, matchObj) {
708
766
  return false;
709
767
  }
710
768
  }
769
+ case "internal_host":
770
+ return isInternalHost(strValue);
711
771
  case "quotes":
712
772
  // engine-php exposes `inline_js_xss` as an alias of `quotes`.
713
773
  case "inline_js_xss":
@@ -819,7 +879,8 @@ var RuleEngine = class {
819
879
  if (parameter === "rules" && Array.isArray(condition.rules)) {
820
880
  return this.#evaluateRule(condition.rules, resolver);
821
881
  }
822
- const values = resolver.resolve(parameter);
882
+ const params = Array.isArray(parameter) ? parameter : [parameter];
883
+ const values = params.flatMap((p) => resolver.resolve(p));
823
884
  if (values.length === 0) {
824
885
  return false;
825
886
  }
@@ -1001,6 +1062,98 @@ function parseCookies2(header) {
1001
1062
  return cookies;
1002
1063
  }
1003
1064
 
1065
+ // src/protect/egress.js
1066
+ function installEgressGuard({ shouldBlock, onBlock } = {}) {
1067
+ const original = globalThis.fetch;
1068
+ if (typeof original !== "function" || original.__patchstackGuarded || typeof shouldBlock !== "function") {
1069
+ return () => {
1070
+ };
1071
+ }
1072
+ const guarded = async (input, init) => {
1073
+ const url = typeof input === "string" ? input : input && input.url || String(input);
1074
+ let host = null;
1075
+ try {
1076
+ host = new URL(url).hostname;
1077
+ } catch {
1078
+ host = null;
1079
+ }
1080
+ const method = init && init.method || input && input.method || "GET";
1081
+ if (shouldBlock(url, host, method)) {
1082
+ onBlock?.({ url, host, method });
1083
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
1084
+ }
1085
+ return original(input, init);
1086
+ };
1087
+ guarded.__patchstackGuarded = true;
1088
+ globalThis.fetch = guarded;
1089
+ return () => {
1090
+ if (globalThis.fetch === guarded) {
1091
+ globalThis.fetch = original;
1092
+ }
1093
+ };
1094
+ }
1095
+
1096
+ // src/protect/defaults.js
1097
+ var DEFAULT_RESPONSE_RULES = [
1098
+ {
1099
+ id: "resp-private-key",
1100
+ title: "Private key in response body",
1101
+ phase: "response",
1102
+ category: "secret-exposure",
1103
+ action: "redact",
1104
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
1105
+ },
1106
+ {
1107
+ id: "resp-aws-access-key",
1108
+ title: "AWS access key id in response body",
1109
+ phase: "response",
1110
+ category: "secret-exposure",
1111
+ action: "redact",
1112
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
1113
+ },
1114
+ {
1115
+ id: "resp-gcp-api-key",
1116
+ title: "Google API key in response body",
1117
+ phase: "response",
1118
+ category: "secret-exposure",
1119
+ action: "redact",
1120
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
1121
+ },
1122
+ {
1123
+ id: "resp-jwt",
1124
+ title: "JWT in response body",
1125
+ phase: "response",
1126
+ category: "secret-exposure",
1127
+ action: "redact",
1128
+ 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/" } }]
1129
+ },
1130
+ {
1131
+ id: "resp-db-connection-string",
1132
+ title: "Database connection string with credentials in response body",
1133
+ phase: "response",
1134
+ category: "secret-exposure",
1135
+ action: "redact",
1136
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
1137
+ },
1138
+ {
1139
+ id: "resp-stack-trace",
1140
+ title: "Node stack trace leaking in response body",
1141
+ phase: "response",
1142
+ category: "info-exposure",
1143
+ action: "redact",
1144
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
1145
+ }
1146
+ ];
1147
+ var DEFAULT_EGRESS_RULES = [
1148
+ {
1149
+ id: "egress-internal-address",
1150
+ title: "Outbound request to an internal / metadata address (SSRF)",
1151
+ phase: "egress",
1152
+ category: "ssrf",
1153
+ rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
1154
+ }
1155
+ ];
1156
+
1004
1157
  // src/protect/runtime.js
1005
1158
  var import_node_fs = require("fs");
1006
1159
  var import_node_path = require("path");
@@ -1079,16 +1232,75 @@ async function createProtection(options = {}) {
1079
1232
  const onError = options.onError;
1080
1233
  const onDetect = options.onDetect ?? defaultOnDetect;
1081
1234
  const bundle = await resolveRules(options);
1082
- const engine = new RuleEngine({ ...bundle, onError });
1083
- const decide = (result, block, allow) => {
1084
- if (!result || !result.blocked) return allow();
1085
- onDetect({ mode, rule: result.rule, message: result.message });
1086
- return mode === "block" ? block() : allow();
1235
+ const incoming = bundle.firewall ?? [];
1236
+ const requestRules = byPhase(incoming, "request");
1237
+ const responseRules = [...options.responseRules ?? DEFAULT_RESPONSE_RULES, ...byPhase(incoming, "response")];
1238
+ const egressRules = [...options.egressRules ?? DEFAULT_EGRESS_RULES, ...byPhase(incoming, "egress")];
1239
+ const engine = new RuleEngine({
1240
+ firewall: requestRules,
1241
+ whitelists: bundle.whitelists,
1242
+ whitelist_keys: bundle.whitelist_keys,
1243
+ onError
1244
+ });
1245
+ const responseRuleSet = responseRules.map((rule) => ({
1246
+ rule,
1247
+ engine: new RuleEngine({ firewall: [rule], onError }),
1248
+ redactors: rule.action === "redact" ? extractRedactors(rule) : null
1249
+ }));
1250
+ const egressEngine = new RuleEngine({ firewall: egressRules, onError });
1251
+ const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
1252
+ const decide = (phase, result, block, allow2) => {
1253
+ if (!result || !result.blocked) return allow2();
1254
+ onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
1255
+ return mode === "block" ? block() : allow2();
1256
+ };
1257
+ const screenFetchResponse = async (response) => {
1258
+ const text = await readTextResponse(response);
1259
+ if (text == null) return response;
1260
+ const meta = { status: response.status, headers: headerObject(response.headers) };
1261
+ let blockRule = null;
1262
+ const redactions = [];
1263
+ for (const { rule, engine: re, redactors } of responseRuleSet) {
1264
+ let result;
1265
+ try {
1266
+ result = re.evaluate({ _response: { ...meta, body: text } });
1267
+ } catch (err) {
1268
+ onError?.(err);
1269
+ continue;
1270
+ }
1271
+ if (!result.blocked) continue;
1272
+ onDetect({ phase: "response", mode, category: rule.category, rule, message: result.message });
1273
+ if (mode !== "block") continue;
1274
+ if (redactors && redactors.length) redactions.push({ rule, redactors });
1275
+ else if (!blockRule) blockRule = rule;
1276
+ }
1277
+ if (mode !== "block") return response;
1278
+ if (blockRule) return leakResponse();
1279
+ if (redactions.length) {
1280
+ let body = text;
1281
+ for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
1282
+ return rebuildResponse(response, body);
1283
+ }
1284
+ return response;
1285
+ };
1286
+ const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
1287
+ const egressShouldBlock = (url, host, method) => {
1288
+ if (host && allow.has(host.toLowerCase())) return false;
1289
+ let result;
1290
+ try {
1291
+ result = egressEngine.evaluate({ _egress: { url, host, method } });
1292
+ } catch (err) {
1293
+ onError?.(err);
1294
+ return false;
1295
+ }
1296
+ if (!result.blocked) return false;
1297
+ onDetect({ phase: "egress", mode, category: result.rule?.category, rule: result.rule, message: result.message });
1298
+ return mode === "block";
1087
1299
  };
1088
1300
  const protection = {
1089
1301
  mode,
1090
- rules: bundle,
1091
- // (request) => Response | null (null = allow, caller proceeds)
1302
+ rules: { request: requestRules, response: responseRules, egress: egressRules },
1303
+ // (request) => Response | null (null = allow, caller proceeds). Request phase only.
1092
1304
  fetchGuard() {
1093
1305
  return async (request) => {
1094
1306
  let result;
@@ -1098,15 +1310,20 @@ async function createProtection(options = {}) {
1098
1310
  onError?.(err);
1099
1311
  return null;
1100
1312
  }
1101
- return decide(result, () => blockResponse(result), () => null);
1313
+ return decide("request", result, () => blockResponse(result), () => null);
1102
1314
  };
1103
1315
  },
1104
- // Wrap a fetch handler: export default { fetch: protection.fetch(app.fetch) }
1316
+ // Wrap a fetch handler: screens the request, then the response (redact/block).
1105
1317
  fetch(handler) {
1106
1318
  const guard = protection.fetchGuard();
1107
- return async (request, ...rest) => await guard(request) ?? handler(request, ...rest);
1319
+ return async (request, ...rest) => {
1320
+ const blocked = await guard(request);
1321
+ if (blocked) return blocked;
1322
+ const response = await handler(request, ...rest);
1323
+ return screenFetchResponse(response);
1324
+ };
1108
1325
  },
1109
- // Express middleware (expects express-parsed req.query/req.body).
1326
+ // Express middleware (request phase; expects express-parsed req.query/req.body).
1110
1327
  express() {
1111
1328
  return (req, res, next) => {
1112
1329
  let result;
@@ -1116,10 +1333,10 @@ async function createProtection(options = {}) {
1116
1333
  onError?.(err);
1117
1334
  return next();
1118
1335
  }
1119
- decide(result, () => res.status(403).json(blockBody(result)), () => next());
1336
+ decide("request", result, () => res.status(403).json(blockBody(result)), () => next());
1120
1337
  };
1121
1338
  },
1122
- // Node / Connect middleware — buffers the body itself (no body-parser needed).
1339
+ // Node / Connect middleware — buffers the body itself (request phase).
1123
1340
  node(nodeOptions = {}) {
1124
1341
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
1125
1342
  return (req, res, next) => {
@@ -1140,28 +1357,104 @@ async function createProtection(options = {}) {
1140
1357
  });
1141
1358
  req.on("end", () => {
1142
1359
  const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
1360
+ let shaped;
1143
1361
  let result;
1144
1362
  try {
1145
- result = engine.evaluate(fromNodeRequest(req, rawBody));
1363
+ shaped = fromNodeRequest(req, rawBody);
1364
+ result = engine.evaluate(shaped);
1146
1365
  } catch (err) {
1147
1366
  onError?.(err);
1148
1367
  return next();
1149
1368
  }
1150
1369
  decide(
1370
+ "request",
1151
1371
  result,
1152
1372
  () => {
1153
1373
  res.statusCode = 403;
1154
1374
  res.setHeader("content-type", "application/json");
1155
1375
  res.end(JSON.stringify(blockBody(result)));
1156
1376
  },
1157
- () => next()
1377
+ () => {
1378
+ if (req.body === void 0) req.body = shaped.body;
1379
+ next();
1380
+ }
1158
1381
  );
1159
1382
  });
1160
1383
  };
1161
1384
  }
1162
1385
  };
1386
+ if (options.egress) {
1387
+ protection.uninstallEgress = installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock });
1388
+ }
1163
1389
  return protection;
1164
1390
  }
1391
+ function byPhase(rules, phase) {
1392
+ return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
1393
+ }
1394
+ async function readTextResponse(response) {
1395
+ if (!response || typeof response.clone !== "function") return null;
1396
+ const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
1397
+ const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
1398
+ if (!isText) return null;
1399
+ const len = Number(response.headers?.get?.("content-length") || 0);
1400
+ if (len && len > 512 * 1024) return null;
1401
+ try {
1402
+ const text = await response.clone().text();
1403
+ return text.length > 512 * 1024 ? null : text;
1404
+ } catch {
1405
+ return null;
1406
+ }
1407
+ }
1408
+ function headerObject(headers) {
1409
+ const out = {};
1410
+ headers?.forEach?.((v, k) => {
1411
+ out[k.toLowerCase()] = v;
1412
+ });
1413
+ return out;
1414
+ }
1415
+ function extractRedactors(rule) {
1416
+ const out = [];
1417
+ const walk = (conds) => {
1418
+ for (const c of conds ?? []) {
1419
+ if (Array.isArray(c.rules)) walk(c.rules);
1420
+ const m = c.match;
1421
+ if (!m) continue;
1422
+ if (m.type === "regex" && typeof m.value === "string") {
1423
+ const parsed = m.value.match(/^\/(.+)\/([a-z]*)$/is);
1424
+ if (parsed) {
1425
+ const flags = parsed[2].includes("g") ? parsed[2] : parsed[2] + "g";
1426
+ try {
1427
+ out.push({ re: new RegExp(parsed[1], flags) });
1428
+ } catch {
1429
+ }
1430
+ }
1431
+ } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
1432
+ out.push({ literal: String(m.value) });
1433
+ }
1434
+ }
1435
+ };
1436
+ walk(rule.rule_v2);
1437
+ return out;
1438
+ }
1439
+ function applyRedactors(body, redactors, mask) {
1440
+ let out = body;
1441
+ for (const r of redactors) {
1442
+ if (r.re) out = out.replace(r.re, mask);
1443
+ else if (r.literal) out = out.split(r.literal).join(mask);
1444
+ }
1445
+ return out;
1446
+ }
1447
+ function rebuildResponse(response, body) {
1448
+ const headers = new Headers(response.headers);
1449
+ headers.delete("content-length");
1450
+ return new Response(body, { status: response.status, statusText: response.statusText, headers });
1451
+ }
1452
+ function leakResponse() {
1453
+ return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
1454
+ status: 500,
1455
+ headers: { "content-type": "application/json" }
1456
+ });
1457
+ }
1165
1458
  async function resolveRules(options) {
1166
1459
  if (options.rules) {
1167
1460
  return normalizeBundle(options.rules);
@@ -1226,9 +1519,9 @@ function blockResponse(result) {
1226
1519
  headers: { "content-type": "application/json" }
1227
1520
  });
1228
1521
  }
1229
- function defaultOnDetect({ mode, rule, message }) {
1522
+ function defaultOnDetect({ phase, mode, category, rule, message }) {
1230
1523
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
1231
- console.warn(`[patchstack] ${tag} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
1524
+ console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
1232
1525
  }
1233
1526
  // Annotate the CommonJS export names for ESM import in node:
1234
1527
  0 && (module.exports = {