@patchstack/connect 0.3.10 → 0.3.11

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
@@ -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
  );