@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.
package/dist/protect.js CHANGED
@@ -1170,7 +1170,8 @@ function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) {
1170
1170
  if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
1171
1171
  });
1172
1172
  const buf = await upstream.arrayBuffer();
1173
- return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
1173
+ const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
1174
+ return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
1174
1175
  };
1175
1176
  }
1176
1177
 
@@ -1225,10 +1226,11 @@ async function createProtection(options = {}) {
1225
1226
  onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message });
1226
1227
  return mode === "block" ? block() : allow2();
1227
1228
  };
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) };
1229
+ const isTextCT = (ct) => {
1230
+ ct = (ct || "").toLowerCase();
1231
+ return ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
1232
+ };
1233
+ const screenText = (text, meta) => {
1232
1234
  let blockRule = null;
1233
1235
  const redactions = [];
1234
1236
  for (const { rule, engine: re, redactors } of responseRuleSet) {
@@ -1245,15 +1247,92 @@ async function createProtection(options = {}) {
1245
1247
  if (redactors && redactors.length) redactions.push({ rule, redactors });
1246
1248
  else if (!blockRule) blockRule = rule;
1247
1249
  }
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
- }
1250
+ if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
1251
+ if (blockRule) return { verdict: "block" };
1252
+ let body = text;
1253
+ for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
1254
+ return { verdict: "redact", body };
1255
+ };
1256
+ const screenResp = async (response) => {
1257
+ const text = await readTextResponse(response);
1258
+ if (text == null) return response;
1259
+ const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
1260
+ if (r.verdict === "block") return leakResponse();
1261
+ if (r.verdict === "redact") return rebuildResponse(response, r.body);
1255
1262
  return response;
1256
1263
  };
1264
+ const wrapNodeResponse = (res) => {
1265
+ const origWrite = res.write.bind(res);
1266
+ const origEnd = res.end.bind(res);
1267
+ const chunks = [];
1268
+ let size = 0;
1269
+ let overflow = false;
1270
+ const MAX = 512 * 1024;
1271
+ const collect = (chunk, enc) => {
1272
+ if (chunk == null) return;
1273
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
1274
+ size += buf.length;
1275
+ if (size > MAX) {
1276
+ overflow = true;
1277
+ return;
1278
+ }
1279
+ chunks.push(buf);
1280
+ };
1281
+ res.write = function(chunk, enc, cb) {
1282
+ if (overflow) return origWrite(chunk, enc, cb);
1283
+ collect(chunk, enc);
1284
+ if (typeof enc === "function") enc();
1285
+ else if (typeof cb === "function") cb();
1286
+ return true;
1287
+ };
1288
+ res.end = function(chunk, enc, cb) {
1289
+ if (typeof chunk === "function") {
1290
+ cb = chunk;
1291
+ chunk = void 0;
1292
+ enc = void 0;
1293
+ } else if (typeof enc === "function") {
1294
+ cb = enc;
1295
+ enc = void 0;
1296
+ }
1297
+ if (overflow) {
1298
+ if (chunk != null) origWrite(chunk, enc);
1299
+ return origEnd(cb);
1300
+ }
1301
+ collect(chunk, enc);
1302
+ const text = Buffer.concat(chunks).toString("utf8");
1303
+ let ct = res.getHeader ? res.getHeader("content-type") : void 0;
1304
+ if (Array.isArray(ct)) ct = ct[0];
1305
+ if (!isTextCT(ct)) {
1306
+ for (const c of chunks) origWrite(c);
1307
+ return origEnd(cb);
1308
+ }
1309
+ let r;
1310
+ try {
1311
+ r = screenText(text, { status: res.statusCode, headers: {} });
1312
+ } catch (err) {
1313
+ onError?.(err);
1314
+ for (const c of chunks) origWrite(c);
1315
+ return origEnd(cb);
1316
+ }
1317
+ if (r.verdict === "block") {
1318
+ res.statusCode = 500;
1319
+ try {
1320
+ res.setHeader("content-type", "application/json");
1321
+ } catch {
1322
+ }
1323
+ return origEnd(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), cb);
1324
+ }
1325
+ if (r.verdict === "redact") {
1326
+ try {
1327
+ res.removeHeader && res.removeHeader("content-length");
1328
+ } catch {
1329
+ }
1330
+ return origEnd(r.body, cb);
1331
+ }
1332
+ for (const c of chunks) origWrite(c);
1333
+ return origEnd(cb);
1334
+ };
1335
+ };
1257
1336
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
1258
1337
  const egressShouldBlock = (url, host, method) => {
1259
1338
  if (host && allow.has(host.toLowerCase())) return false;
@@ -1271,6 +1350,9 @@ async function createProtection(options = {}) {
1271
1350
  const protection = {
1272
1351
  mode,
1273
1352
  rules: { request: requestRules, response: responseRules, egress: egressRules },
1353
+ // Screen a fetch Response through the response-phase rules (redact/block). Used by
1354
+ // .fetch(), and by the Supabase guard on its forwarded upstream response.
1355
+ screenResponse: (response) => screenResp(response),
1274
1356
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
1275
1357
  fetchGuard() {
1276
1358
  return async (request) => {
@@ -1291,23 +1373,34 @@ async function createProtection(options = {}) {
1291
1373
  const blocked = await guard(request);
1292
1374
  if (blocked) return blocked;
1293
1375
  const response = await handler(request, ...rest);
1294
- return screenFetchResponse(response);
1376
+ return screenResp(response);
1295
1377
  };
1296
1378
  },
1297
1379
  // Express middleware (request phase; expects express-parsed req.query/req.body).
1298
- express() {
1380
+ // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
1381
+ express(exprOptions = {}) {
1299
1382
  return (req, res, next) => {
1300
1383
  let result;
1301
1384
  try {
1302
1385
  result = engine.evaluate(req);
1303
1386
  } catch (err) {
1304
1387
  onError?.(err);
1388
+ if (exprOptions.screenResponses) wrapNodeResponse(res);
1305
1389
  return next();
1306
1390
  }
1307
- decide("request", result, () => res.status(403).json(blockBody(result)), () => next());
1391
+ decide(
1392
+ "request",
1393
+ result,
1394
+ () => res.status(403).json(blockBody(result)),
1395
+ () => {
1396
+ if (exprOptions.screenResponses) wrapNodeResponse(res);
1397
+ next();
1398
+ }
1399
+ );
1308
1400
  };
1309
1401
  },
1310
1402
  // Node / Connect middleware — buffers the body itself (request phase).
1403
+ // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
1311
1404
  node(nodeOptions = {}) {
1312
1405
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
1313
1406
  return (req, res, next) => {
@@ -1347,6 +1440,7 @@ async function createProtection(options = {}) {
1347
1440
  },
1348
1441
  () => {
1349
1442
  if (req.body === void 0) req.body = shaped.body;
1443
+ if (nodeOptions.screenResponses) wrapNodeResponse(res);
1350
1444
  next();
1351
1445
  }
1352
1446
  );