@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/AGENT-INSTALL.md +14 -1
- package/README.md +2 -2
- package/dist/cli.js +0 -1
- package/dist/cli.js.map +1 -1
- package/dist/protect/templates/guard.ts +14 -3
- package/dist/protect/templates/rules.json +34 -7
- package/dist/protect.cjs +109 -15
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +4 -2
- package/dist/protect.js +109 -15
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
? {
|
|
33
|
-
: { rules: fallbackRules as never
|
|
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": "
|
|
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": "
|
|
6
|
-
"title": "
|
|
7
|
-
"
|
|
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": "
|
|
11
|
-
"
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
|
1278
|
-
if (blockRule) return
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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
|
|
1405
|
+
return screenResp(response);
|
|
1324
1406
|
};
|
|
1325
1407
|
},
|
|
1326
1408
|
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
1327
|
-
|
|
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(
|
|
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
|
);
|