@patchstack/connect 0.3.10 → 0.3.12

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.
@@ -5,7 +5,9 @@
5
5
  // through handleGuardRequest (registered as request middleware in src/start.ts).
6
6
  // - browser → TanStack server function → Supabase: inspectServerFn checks the server-fn args
7
7
  // (registered as function middleware in src/start.ts) before anything is written.
8
- // Either way Patchstack sees the traffic and runs the same policy.
8
+ // Either way Patchstack sees the traffic and runs the same policy. It also screens the app's own
9
+ // OUTBOUND calls for SSRF (egress: true wraps the global fetch) — blocking requests to internal /
10
+ // cloud-metadata addresses while allowing the app's own Supabase project.
9
11
  //
10
12
  // Always-on by default (blocks). Rules come from the Patchstack API per-site (cached); the
11
13
  // bundled rules.json is only a fallback for before a token is configured. The engine ships
@@ -27,10 +29,19 @@ async function getProtection() {
27
29
  // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only.
28
30
  const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
29
31
  const token = process.env.PATCHSTACK_WAF_TOKEN;
32
+ // Egress SSRF screening: block the app's outbound calls to internal / metadata addresses,
33
+ // but never its own Supabase project.
34
+ let allowHosts: string[] = [];
35
+ try {
36
+ if (process.env.SUPABASE_URL) allowHosts = [new URL(process.env.SUPABASE_URL).host];
37
+ } catch {
38
+ /* ignore a malformed SUPABASE_URL — just don't add an allow entry */
39
+ }
40
+ const common = { mode, egress: true, allowHosts };
30
41
  _protection = await createProtection(
31
42
  token
32
- ? { token, mode, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached)
33
- : { rules: fallbackRules as never, mode }, // demo fallback until a token is set
43
+ ? { ...common, token, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached)
44
+ : { ...common, rules: fallbackRules as never }, // demo fallback until a token is set
34
45
  );
35
46
  }
36
47
  return _protection;
@@ -1,17 +1,44 @@
1
1
  {
2
- "_comment": "Demo fallback rules — used only when PATCHSTACK_WAF_TOKEN is unset. In production the guard fetches per-site rules from the Patchstack API (api.patchstack.com/api/get-rules/3, cached to disk). CVE id is representative until aligned with the real corpus.",
2
+ "_comment": "Starter fallback rules — used ONLY when PATCHSTACK_WAF_TOKEN is unset. The guard blocks by default, so these are deliberately HIGH-PRECISION signatures (structural markers legitimate traffic doesn't carry) to avoid false positives before a token is configured. Once a token is set the guard fetches the per-site, version-scoped corpus from the Patchstack API (api.patchstack.com/api/get-rules/3, cached to disk) and this file is no longer used. For a broad, many-class DEMONSTRATION (SQLi/XSS/XXE/SSRF/PII/…), seed this file from examples/protect/demo-rules.json — see that folder's README.",
3
3
  "firewall": [
4
4
  {
5
- "id": "rm-npm-0001",
6
- "title": "Block stored XSS via vulnerable markdown renderer (marked)",
7
- "vulnerability_id": "CVE-2022-21681",
5
+ "id": "ps-fallback-proto-pollution",
6
+ "title": "Prototype pollution in request body",
7
+ "category": "prototype-pollution",
8
8
  "rule_v2": [
9
+ { "parameter": "raw", "mutations": ["urldecode"], "match": { "type": "contains", "value": "__proto__" } },
9
10
  {
10
- "parameter": "post.title",
11
- "mutations": ["urldecode"],
12
- "match": { "type": "inline_xss" }
11
+ "parameter": "rules",
12
+ "rules": [
13
+ { "parameter": "raw", "mutations": ["urldecode"], "match": { "type": "contains", "value": "constructor" }, "inclusive": true },
14
+ { "parameter": "raw", "mutations": ["urldecode"], "match": { "type": "contains", "value": "prototype" }, "inclusive": true }
15
+ ]
13
16
  }
14
17
  ]
18
+ },
19
+ {
20
+ "id": "ps-fallback-path-traversal",
21
+ "title": "Path traversal in a file/path parameter",
22
+ "category": "lfi",
23
+ "rule_v2": [
24
+ { "parameter": ["get.file", "post.file", "raw.file", "get.path", "post.path"], "mutations": ["urldecode"], "match": { "type": "contains", "value": ".." } }
25
+ ]
26
+ },
27
+ {
28
+ "id": "ps-fallback-ssrf-internal",
29
+ "title": "SSRF via a url parameter pointing at an internal/metadata address",
30
+ "category": "ssrf",
31
+ "rule_v2": [
32
+ { "parameter": ["get.url", "post.url", "raw.url"], "mutations": ["urldecode"], "match": { "type": "regex", "value": "/localhost|127\\.0\\.0\\.1|169\\.254\\.169\\.254|::1|metadata\\.google/i" } }
33
+ ]
34
+ },
35
+ {
36
+ "id": "ps-fallback-xss",
37
+ "title": "Stored/reflected XSS in a text field",
38
+ "category": "xss",
39
+ "rule_v2": [
40
+ { "parameter": ["post.title", "post.comment", "post.body"], "mutations": ["urldecode", "htmlentitydecode"], "match": { "type": "regex", "value": "/<script\\b|onerror\\s*=|javascript:/i" } }
41
+ ]
15
42
  }
16
43
  ],
17
44
  "whitelists": [],
package/dist/protect.cjs CHANGED
@@ -1199,7 +1199,8 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
1199
1199
  if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
1200
1200
  });
1201
1201
  const buf = await upstream.arrayBuffer();
1202
- return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
1202
+ const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
1203
+ return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
1203
1204
  };
1204
1205
  }
1205
1206
 
@@ -1254,10 +1255,11 @@ async function createProtection(options = {}) {
1254
1255
  onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
1255
1256
  return mode === "block" ? block() : allow2();
1256
1257
  };
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) };
1258
+ const isTextCT = (ct) => {
1259
+ ct = (ct || "").toLowerCase();
1260
+ return ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
1261
+ };
1262
+ const screenText = (text, meta) => {
1261
1263
  let blockRule = null;
1262
1264
  const redactions = [];
1263
1265
  for (const { rule, engine: re, redactors } of responseRuleSet) {
@@ -1274,15 +1276,92 @@ async function createProtection(options = {}) {
1274
1276
  if (redactors && redactors.length) redactions.push({ rule, redactors });
1275
1277
  else if (!blockRule) blockRule = rule;
1276
1278
  }
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
- }
1279
+ if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
1280
+ if (blockRule) return { verdict: "block" };
1281
+ let body = text;
1282
+ for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
1283
+ return { verdict: "redact", body };
1284
+ };
1285
+ const screenResp = async (response) => {
1286
+ const text = await readTextResponse(response);
1287
+ if (text == null) return response;
1288
+ const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
1289
+ if (r.verdict === "block") return leakResponse();
1290
+ if (r.verdict === "redact") return rebuildResponse(response, r.body);
1284
1291
  return response;
1285
1292
  };
1293
+ const wrapNodeResponse = (res) => {
1294
+ const origWrite = res.write.bind(res);
1295
+ const origEnd = res.end.bind(res);
1296
+ const chunks = [];
1297
+ let size = 0;
1298
+ let overflow = false;
1299
+ const MAX = 512 * 1024;
1300
+ const collect = (chunk, enc) => {
1301
+ if (chunk == null) return;
1302
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
1303
+ size += buf.length;
1304
+ if (size > MAX) {
1305
+ overflow = true;
1306
+ return;
1307
+ }
1308
+ chunks.push(buf);
1309
+ };
1310
+ res.write = function(chunk, enc, cb) {
1311
+ if (overflow) return origWrite(chunk, enc, cb);
1312
+ collect(chunk, enc);
1313
+ if (typeof enc === "function") enc();
1314
+ else if (typeof cb === "function") cb();
1315
+ return true;
1316
+ };
1317
+ res.end = function(chunk, enc, cb) {
1318
+ if (typeof chunk === "function") {
1319
+ cb = chunk;
1320
+ chunk = void 0;
1321
+ enc = void 0;
1322
+ } else if (typeof enc === "function") {
1323
+ cb = enc;
1324
+ enc = void 0;
1325
+ }
1326
+ if (overflow) {
1327
+ if (chunk != null) origWrite(chunk, enc);
1328
+ return origEnd(cb);
1329
+ }
1330
+ collect(chunk, enc);
1331
+ const text = Buffer.concat(chunks).toString("utf8");
1332
+ let ct = res.getHeader ? res.getHeader("content-type") : void 0;
1333
+ if (Array.isArray(ct)) ct = ct[0];
1334
+ if (!isTextCT(ct)) {
1335
+ for (const c of chunks) origWrite(c);
1336
+ return origEnd(cb);
1337
+ }
1338
+ let r;
1339
+ try {
1340
+ r = screenText(text, { status: res.statusCode, headers: {} });
1341
+ } catch (err) {
1342
+ onError?.(err);
1343
+ for (const c of chunks) origWrite(c);
1344
+ return origEnd(cb);
1345
+ }
1346
+ if (r.verdict === "block") {
1347
+ res.statusCode = 500;
1348
+ try {
1349
+ res.setHeader("content-type", "application/json");
1350
+ } catch {
1351
+ }
1352
+ return origEnd(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), cb);
1353
+ }
1354
+ if (r.verdict === "redact") {
1355
+ try {
1356
+ res.removeHeader && res.removeHeader("content-length");
1357
+ } catch {
1358
+ }
1359
+ return origEnd(r.body, cb);
1360
+ }
1361
+ for (const c of chunks) origWrite(c);
1362
+ return origEnd(cb);
1363
+ };
1364
+ };
1286
1365
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
1287
1366
  const egressShouldBlock = (url, host, method) => {
1288
1367
  if (host && allow.has(host.toLowerCase())) return false;
@@ -1300,6 +1379,9 @@ async function createProtection(options = {}) {
1300
1379
  const protection = {
1301
1380
  mode,
1302
1381
  rules: { request: requestRules, response: responseRules, egress: egressRules },
1382
+ // Screen a fetch Response through the response-phase rules (redact/block). Used by
1383
+ // .fetch(), and by the Supabase guard on its forwarded upstream response.
1384
+ screenResponse: (response) => screenResp(response),
1303
1385
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
1304
1386
  fetchGuard() {
1305
1387
  return async (request) => {
@@ -1320,23 +1402,34 @@ async function createProtection(options = {}) {
1320
1402
  const blocked = await guard(request);
1321
1403
  if (blocked) return blocked;
1322
1404
  const response = await handler(request, ...rest);
1323
- return screenFetchResponse(response);
1405
+ return screenResp(response);
1324
1406
  };
1325
1407
  },
1326
1408
  // Express middleware (request phase; expects express-parsed req.query/req.body).
1327
- express() {
1409
+ // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
1410
+ express(exprOptions = {}) {
1328
1411
  return (req, res, next) => {
1329
1412
  let result;
1330
1413
  try {
1331
1414
  result = engine.evaluate(req);
1332
1415
  } catch (err) {
1333
1416
  onError?.(err);
1417
+ if (exprOptions.screenResponses) wrapNodeResponse(res);
1334
1418
  return next();
1335
1419
  }
1336
- decide("request", result, () => res.status(403).json(blockBody(result)), () => next());
1420
+ decide(
1421
+ "request",
1422
+ result,
1423
+ () => res.status(403).json(blockBody(result)),
1424
+ () => {
1425
+ if (exprOptions.screenResponses) wrapNodeResponse(res);
1426
+ next();
1427
+ }
1428
+ );
1337
1429
  };
1338
1430
  },
1339
1431
  // Node / Connect middleware — buffers the body itself (request phase).
1432
+ // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
1340
1433
  node(nodeOptions = {}) {
1341
1434
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
1342
1435
  return (req, res, next) => {
@@ -1376,6 +1469,7 @@ async function createProtection(options = {}) {
1376
1469
  },
1377
1470
  () => {
1378
1471
  if (req.body === void 0) req.body = shaped.body;
1472
+ if (nodeOptions.screenResponses) wrapNodeResponse(res);
1379
1473
  next();
1380
1474
  }
1381
1475
  );