@patchstack/connect 0.3.19 → 0.3.21
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 +33 -18
- package/README.md +28 -11
- package/dist/cli.js +1204 -1071
- package/dist/cli.js.map +1 -1
- package/dist/protect/templates/express-guard.cjs +5 -1
- package/dist/protect/templates/express-guard.js +5 -1
- package/dist/protect/templates/express-guard.ts +5 -1
- package/dist/protect/templates/fastify-plugin.cjs +46 -0
- package/dist/protect/templates/fastify-plugin.js +45 -0
- package/dist/protect/templates/generic-guard.cjs +41 -0
- package/dist/protect/templates/generic-guard.js +42 -0
- package/dist/protect/templates/generic-guard.ts +5 -1
- package/dist/protect/templates/guard.ts +5 -1
- package/dist/protect/templates/nuxt-middleware.ts +43 -0
- package/dist/protect.cjs +1887 -210
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +35 -0
- package/dist/protect.js +689 -193
- package/dist/protect.js.map +1 -1
- package/dist/refresh-manifest-XBOGN446.js +1090 -0
- package/dist/refresh-manifest-XBOGN446.js.map +1 -0
- package/package.json +1 -1
package/dist/protect.js
CHANGED
|
@@ -13,7 +13,7 @@ var PatchstackRuleClient = class {
|
|
|
13
13
|
constructor({ token, baseUrl, cacheTtl, etag } = {}) {
|
|
14
14
|
this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
|
|
15
15
|
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL ?? DEFAULT_BASE_URL;
|
|
16
|
-
this.#cacheTtl = cacheTtl
|
|
16
|
+
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
|
|
17
17
|
this.#etag = etag ?? null;
|
|
18
18
|
if (!this.#token) {
|
|
19
19
|
throw new Error("Patchstack WAF token is required. Pass { token } or set PATCHSTACK_WAF_TOKEN env var.");
|
|
@@ -53,11 +53,14 @@ var PatchstackRuleClient = class {
|
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
const data = await response.json();
|
|
56
|
+
if (!data || !Array.isArray(data.firewall)) {
|
|
57
|
+
return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
|
|
58
|
+
}
|
|
56
59
|
const result = {
|
|
57
60
|
success: true,
|
|
58
61
|
notModified: false,
|
|
59
62
|
etag: response.headers?.get?.("etag") ?? null,
|
|
60
|
-
firewall:
|
|
63
|
+
firewall: data.firewall,
|
|
61
64
|
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
62
65
|
whitelist_keys: data.whitelist_keys ?? {}
|
|
63
66
|
};
|
|
@@ -383,13 +386,14 @@ var RequestResolver = class {
|
|
|
383
386
|
if (typeof obj !== "object" || obj === null) {
|
|
384
387
|
return void 0;
|
|
385
388
|
}
|
|
386
|
-
|
|
389
|
+
const own = (o, k) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, k);
|
|
390
|
+
if (own(obj, key)) {
|
|
387
391
|
return obj[key];
|
|
388
392
|
}
|
|
389
393
|
const parts = key.split(".");
|
|
390
394
|
let current = obj;
|
|
391
395
|
for (const part of parts) {
|
|
392
|
-
if (current
|
|
396
|
+
if (!own(current, part)) {
|
|
393
397
|
return void 0;
|
|
394
398
|
}
|
|
395
399
|
current = current[part];
|
|
@@ -602,11 +606,8 @@ function normalizeObject(value, options = {}) {
|
|
|
602
606
|
|
|
603
607
|
// src/protect/engine/engine.js
|
|
604
608
|
var REDOS_PATTERNS = [
|
|
605
|
-
/\(\
|
|
606
|
-
/\(\
|
|
607
|
-
/\(\w\+\)\*/,
|
|
608
|
-
/\(\w\*\)\*/,
|
|
609
|
-
/\(\w\|\w\)\+/
|
|
609
|
+
/\([^)]*[+*}][^)]*\)\s*[+*]/,
|
|
610
|
+
/\([^)]*\|[^)]*\)\s*[+*]/
|
|
610
611
|
];
|
|
611
612
|
function safeRegExp(pattern) {
|
|
612
613
|
if (!pattern) {
|
|
@@ -634,6 +635,25 @@ function ctypeResult(strValue, isClass, matchVal) {
|
|
|
634
635
|
const expected = typeof matchVal === "boolean" ? matchVal : true;
|
|
635
636
|
return isClass === expected;
|
|
636
637
|
}
|
|
638
|
+
function walkLeaves(node, segments, cb) {
|
|
639
|
+
if (node === null || typeof node !== "object") return;
|
|
640
|
+
if (Array.isArray(node)) {
|
|
641
|
+
for (const el of node) walkLeaves(el, segments, cb);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const [head, ...rest] = segments;
|
|
645
|
+
if (head === void 0 || !Object.prototype.hasOwnProperty.call(node, head)) return;
|
|
646
|
+
if (rest.length === 0) {
|
|
647
|
+
const leaf = node[head];
|
|
648
|
+
if (Array.isArray(leaf)) {
|
|
649
|
+
for (let i = 0; i < leaf.length; i++) cb({ parent: leaf, key: i, value: leaf[i] });
|
|
650
|
+
} else {
|
|
651
|
+
cb({ parent: node, key: head, value: leaf });
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
walkLeaves(node[head], rest, cb);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
637
657
|
function arrayKeyValue(value, matchObj) {
|
|
638
658
|
if (!matchObj || !matchObj.match || value === null || typeof value !== "object") {
|
|
639
659
|
return false;
|
|
@@ -641,23 +661,11 @@ function arrayKeyValue(value, matchObj) {
|
|
|
641
661
|
const keys = Array.isArray(matchObj.key) ? matchObj.key : [matchObj.key];
|
|
642
662
|
const sub = matchObj.match;
|
|
643
663
|
for (const key of keys) {
|
|
644
|
-
let
|
|
645
|
-
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
node = node[part];
|
|
651
|
-
}
|
|
652
|
-
if (node === void 0) {
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
655
|
-
const candidates = Array.isArray(node) ? node : [node];
|
|
656
|
-
for (const candidate of candidates) {
|
|
657
|
-
if (matchValue(sub.type, candidate, sub.value, sub)) {
|
|
658
|
-
return true;
|
|
659
|
-
}
|
|
660
|
-
}
|
|
664
|
+
let matched = false;
|
|
665
|
+
walkLeaves(value, String(key).split("."), ({ value: leaf }) => {
|
|
666
|
+
if (!matched && matchValue(sub.type, leaf, sub.value, sub)) matched = true;
|
|
667
|
+
});
|
|
668
|
+
if (matched) return true;
|
|
661
669
|
}
|
|
662
670
|
return false;
|
|
663
671
|
}
|
|
@@ -679,16 +687,68 @@ function isInternalHost(hostname) {
|
|
|
679
687
|
if (host === "::1" || host === "::") return true;
|
|
680
688
|
if (host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd")) return true;
|
|
681
689
|
const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
682
|
-
if (v4)
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
if (
|
|
688
|
-
if (a === 192 && b === 168) return true;
|
|
690
|
+
if (v4 && isPrivateV4(Number(v4[1]), Number(v4[2]))) return true;
|
|
691
|
+
const mapped = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
692
|
+
if (mapped) {
|
|
693
|
+
const g1 = parseInt(mapped[1], 16);
|
|
694
|
+
const g2 = parseInt(mapped[2], 16);
|
|
695
|
+
if (isPrivateV4(g1 >> 8 & 255, g1 & 255)) return true;
|
|
689
696
|
}
|
|
690
697
|
return false;
|
|
691
698
|
}
|
|
699
|
+
function isPrivateV4(a, b) {
|
|
700
|
+
if (a === 127 || a === 10 || a === 0) return true;
|
|
701
|
+
if (a === 169 && b === 254) return true;
|
|
702
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
703
|
+
if (a === 192 && b === 168) return true;
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
function ruleAppliesTo(when, resolver) {
|
|
707
|
+
try {
|
|
708
|
+
if (when.method) {
|
|
709
|
+
const methods = (Array.isArray(when.method) ? when.method : [when.method]).map((m) => String(m).toUpperCase());
|
|
710
|
+
const actual = String(resolver.resolve("server.REQUEST_METHOD")[0] ?? "GET").toUpperCase();
|
|
711
|
+
if (!methods.includes(actual)) return false;
|
|
712
|
+
}
|
|
713
|
+
if (when.path) {
|
|
714
|
+
const uri = String(resolver.resolve("server.REQUEST_URI")[0] ?? "/");
|
|
715
|
+
if (!pathMatches(String(when.path), uri.split("?")[0])) return false;
|
|
716
|
+
}
|
|
717
|
+
return true;
|
|
718
|
+
} catch {
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function pathMatches(pattern, path) {
|
|
723
|
+
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
|
|
724
|
+
const re = safeRegExp(pattern);
|
|
725
|
+
return re ? re.test(path) : false;
|
|
726
|
+
}
|
|
727
|
+
if (pattern.includes("*")) {
|
|
728
|
+
const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
|
|
729
|
+
return re ? re.test(path) : false;
|
|
730
|
+
}
|
|
731
|
+
return path === pattern;
|
|
732
|
+
}
|
|
733
|
+
function isCrossOrigin(resolver) {
|
|
734
|
+
try {
|
|
735
|
+
const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
|
|
736
|
+
if (!host) return false;
|
|
737
|
+
const src = resolver.resolve("server.HTTP_ORIGIN")[0] ?? resolver.resolve("server.HTTP_REFERER")[0];
|
|
738
|
+
if (!src) return false;
|
|
739
|
+
const srcHost = hostFromUrl(String(src));
|
|
740
|
+
return srcHost !== null && srcHost !== host;
|
|
741
|
+
} catch {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
function hostFromUrl(value) {
|
|
746
|
+
try {
|
|
747
|
+
return new URL(value).host.toLowerCase();
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
692
752
|
function matchValue(type, value, matchVal, matchObj) {
|
|
693
753
|
if (value === null || value === void 0) {
|
|
694
754
|
if (type === "isset") {
|
|
@@ -734,18 +794,19 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
734
794
|
case "isset":
|
|
735
795
|
return true;
|
|
736
796
|
case "in_array": {
|
|
737
|
-
const arr = Array.isArray(matchVal) ? matchVal : [matchVal];
|
|
797
|
+
const arr = (Array.isArray(matchVal) ? matchVal : [matchVal]).map((x) => String(x));
|
|
738
798
|
return arr.includes(strValue);
|
|
739
799
|
}
|
|
740
800
|
case "not_in_array": {
|
|
741
|
-
const arr = Array.isArray(matchVal) ? matchVal : [matchVal];
|
|
801
|
+
const arr = (Array.isArray(matchVal) ? matchVal : [matchVal]).map((x) => String(x));
|
|
742
802
|
return !arr.includes(strValue);
|
|
743
803
|
}
|
|
744
804
|
case "array_in_array": {
|
|
745
805
|
if (!Array.isArray(value) || !Array.isArray(matchVal)) {
|
|
746
806
|
return false;
|
|
747
807
|
}
|
|
748
|
-
|
|
808
|
+
const target = matchVal.map((x) => String(x));
|
|
809
|
+
return value.some((v) => target.includes(String(v)));
|
|
749
810
|
}
|
|
750
811
|
case "hostname": {
|
|
751
812
|
try {
|
|
@@ -822,6 +883,9 @@ var RuleEngine = class {
|
|
|
822
883
|
}
|
|
823
884
|
for (const rule of this.#rules) {
|
|
824
885
|
try {
|
|
886
|
+
if (rule.when && !ruleAppliesTo(rule.when, resolver)) {
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
825
889
|
const conditions = rule.rule_v2;
|
|
826
890
|
if (!Array.isArray(conditions) || conditions.length === 0) {
|
|
827
891
|
continue;
|
|
@@ -868,6 +932,9 @@ var RuleEngine = class {
|
|
|
868
932
|
if (parameter === "rules" && Array.isArray(condition.rules)) {
|
|
869
933
|
return this.#evaluateRule(condition.rules, resolver);
|
|
870
934
|
}
|
|
935
|
+
if (match && match.type === "cross_origin") {
|
|
936
|
+
return isCrossOrigin(resolver);
|
|
937
|
+
}
|
|
871
938
|
const params = Array.isArray(parameter) ? parameter : [parameter];
|
|
872
939
|
const values = params.flatMap((p) => resolver.resolve(p));
|
|
873
940
|
if (values.length === 0) {
|
|
@@ -932,7 +999,7 @@ var PulseRuleClient = class {
|
|
|
932
999
|
constructor({ siteUuid, baseUrl, cacheTtl, etag } = {}) {
|
|
933
1000
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
934
1001
|
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL2;
|
|
935
|
-
this.#cacheTtl = cacheTtl
|
|
1002
|
+
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
936
1003
|
this.#etag = etag ?? null;
|
|
937
1004
|
if (!this.#siteUuid) {
|
|
938
1005
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
@@ -956,11 +1023,14 @@ var PulseRuleClient = class {
|
|
|
956
1023
|
return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
957
1024
|
}
|
|
958
1025
|
const data = await response.json();
|
|
1026
|
+
if (!data || !Array.isArray(data.firewall)) {
|
|
1027
|
+
return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1028
|
+
}
|
|
959
1029
|
const result = {
|
|
960
1030
|
success: true,
|
|
961
1031
|
notModified: false,
|
|
962
1032
|
etag: response.headers?.get?.("etag") ?? null,
|
|
963
|
-
firewall:
|
|
1033
|
+
firewall: data.firewall,
|
|
964
1034
|
whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
|
|
965
1035
|
whitelist_keys: data.whitelist_keys ?? {}
|
|
966
1036
|
};
|
|
@@ -1062,6 +1132,29 @@ async function readCappedText(request, max) {
|
|
|
1062
1132
|
} catch {
|
|
1063
1133
|
return "";
|
|
1064
1134
|
}
|
|
1135
|
+
const body = clone.body;
|
|
1136
|
+
if (body && typeof body.getReader === "function") {
|
|
1137
|
+
const reader = body.getReader();
|
|
1138
|
+
const chunks = [];
|
|
1139
|
+
let buffered = 0;
|
|
1140
|
+
try {
|
|
1141
|
+
for (; ; ) {
|
|
1142
|
+
const { done, value } = await reader.read();
|
|
1143
|
+
if (done) break;
|
|
1144
|
+
if (!value || buffered >= max) continue;
|
|
1145
|
+
const take = Math.min(value.byteLength, max - buffered);
|
|
1146
|
+
chunks.push(take === value.byteLength ? value : value.subarray(0, take));
|
|
1147
|
+
buffered += take;
|
|
1148
|
+
}
|
|
1149
|
+
} catch {
|
|
1150
|
+
return "";
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
return new TextDecoder().decode(concatChunks(chunks, buffered));
|
|
1154
|
+
} catch {
|
|
1155
|
+
return "";
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1065
1158
|
try {
|
|
1066
1159
|
const text = await clone.text();
|
|
1067
1160
|
return text.length > max ? text.slice(0, max) : text;
|
|
@@ -1069,6 +1162,15 @@ async function readCappedText(request, max) {
|
|
|
1069
1162
|
return "";
|
|
1070
1163
|
}
|
|
1071
1164
|
}
|
|
1165
|
+
function concatChunks(chunks, total) {
|
|
1166
|
+
const out = new Uint8Array(total);
|
|
1167
|
+
let offset = 0;
|
|
1168
|
+
for (const c of chunks) {
|
|
1169
|
+
out.set(c, offset);
|
|
1170
|
+
offset += c.byteLength;
|
|
1171
|
+
}
|
|
1172
|
+
return out;
|
|
1173
|
+
}
|
|
1072
1174
|
function parseMultipart(rawBody, boundary) {
|
|
1073
1175
|
const body = {};
|
|
1074
1176
|
const files = {};
|
|
@@ -1113,7 +1215,16 @@ function fromNodeRequest(req, rawBody = "") {
|
|
|
1113
1215
|
headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
|
|
1114
1216
|
}
|
|
1115
1217
|
const host = headers.host || "localhost";
|
|
1116
|
-
|
|
1218
|
+
let url;
|
|
1219
|
+
try {
|
|
1220
|
+
url = new URL(req.url || "/", `http://${host}`);
|
|
1221
|
+
} catch {
|
|
1222
|
+
try {
|
|
1223
|
+
url = new URL(req.url || "/", "http://localhost");
|
|
1224
|
+
} catch {
|
|
1225
|
+
url = new URL("http://localhost/");
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1117
1228
|
const query = {};
|
|
1118
1229
|
for (const [key, value] of url.searchParams) {
|
|
1119
1230
|
if (key in query) {
|
|
@@ -1124,6 +1235,7 @@ function fromNodeRequest(req, rawBody = "") {
|
|
|
1124
1235
|
}
|
|
1125
1236
|
const contentType = headers["content-type"] || "";
|
|
1126
1237
|
let body = {};
|
|
1238
|
+
let files;
|
|
1127
1239
|
if (rawBody) {
|
|
1128
1240
|
if (contentType.includes("application/json")) {
|
|
1129
1241
|
try {
|
|
@@ -1136,6 +1248,13 @@ function fromNodeRequest(req, rawBody = "") {
|
|
|
1136
1248
|
for (const [k, v] of new URLSearchParams(rawBody)) {
|
|
1137
1249
|
body[k] = k in body ? [].concat(body[k], v) : v;
|
|
1138
1250
|
}
|
|
1251
|
+
} else if (contentType.includes("multipart/form-data")) {
|
|
1252
|
+
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
1253
|
+
if (boundary) {
|
|
1254
|
+
const parsed = parseMultipart(rawBody, boundary);
|
|
1255
|
+
body = parsed.body;
|
|
1256
|
+
files = parsed.files;
|
|
1257
|
+
}
|
|
1139
1258
|
}
|
|
1140
1259
|
}
|
|
1141
1260
|
const uri = url.pathname + url.search;
|
|
@@ -1146,6 +1265,7 @@ function fromNodeRequest(req, rawBody = "") {
|
|
|
1146
1265
|
originalUrl: uri,
|
|
1147
1266
|
query,
|
|
1148
1267
|
body,
|
|
1268
|
+
files,
|
|
1149
1269
|
headers,
|
|
1150
1270
|
ip: forwarded.split(",")[0].trim() || req.socket && req.socket.remoteAddress || "",
|
|
1151
1271
|
cookies: parseCookies2(headers.cookie),
|
|
@@ -1169,15 +1289,43 @@ function parseCookies2(header) {
|
|
|
1169
1289
|
}
|
|
1170
1290
|
|
|
1171
1291
|
// src/protect/egress.js
|
|
1172
|
-
async function installEgressGuard({ shouldBlock, onBlock } = {}) {
|
|
1292
|
+
async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
1173
1293
|
const restores = [];
|
|
1174
1294
|
if (typeof shouldBlock !== "function") return () => {
|
|
1175
1295
|
};
|
|
1296
|
+
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
1176
1297
|
const block = (url, host, method) => {
|
|
1177
1298
|
if (!shouldBlock(url, host, method)) return false;
|
|
1178
1299
|
onBlock?.({ url, host, method });
|
|
1179
1300
|
return true;
|
|
1180
1301
|
};
|
|
1302
|
+
let screen = null;
|
|
1303
|
+
if (dnsScreen) {
|
|
1304
|
+
try {
|
|
1305
|
+
const resolveLookup = lookup ?? (await import("dns")).lookup;
|
|
1306
|
+
const { isIP } = await import("net");
|
|
1307
|
+
if (typeof resolveLookup === "function" && typeof isIP === "function") {
|
|
1308
|
+
screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
|
|
1309
|
+
}
|
|
1310
|
+
} catch {
|
|
1311
|
+
screen = null;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
1315
|
+
if (!screen || !host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
|
|
1316
|
+
try {
|
|
1317
|
+
screen.lookup(host, { all: true }, (err, addresses) => {
|
|
1318
|
+
if (err || !Array.isArray(addresses)) return resolve(false);
|
|
1319
|
+
for (const a of addresses) {
|
|
1320
|
+
const ip = a && typeof a === "object" ? a.address : a;
|
|
1321
|
+
if (ip && block(url, ip, method)) return resolve(true);
|
|
1322
|
+
}
|
|
1323
|
+
resolve(false);
|
|
1324
|
+
});
|
|
1325
|
+
} catch {
|
|
1326
|
+
resolve(false);
|
|
1327
|
+
}
|
|
1328
|
+
});
|
|
1181
1329
|
const originalFetch = globalThis.fetch;
|
|
1182
1330
|
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
1183
1331
|
const guarded = async (input, init) => {
|
|
@@ -1189,7 +1337,7 @@ async function installEgressGuard({ shouldBlock, onBlock } = {}) {
|
|
|
1189
1337
|
host = null;
|
|
1190
1338
|
}
|
|
1191
1339
|
const method = init && init.method || input && input.method || "GET";
|
|
1192
|
-
if (block(url, host, method)) {
|
|
1340
|
+
if (block(url, host, method) || await resolvesToDisallowed(url, host, method)) {
|
|
1193
1341
|
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
|
|
1194
1342
|
}
|
|
1195
1343
|
return originalFetch(input, init);
|
|
@@ -1203,7 +1351,7 @@ async function installEgressGuard({ shouldBlock, onBlock } = {}) {
|
|
|
1203
1351
|
for (const moduleName of ["node:http", "node:https"]) {
|
|
1204
1352
|
try {
|
|
1205
1353
|
const mod = await import(moduleName);
|
|
1206
|
-
const restore = patchHttpModule(mod.default ?? mod, block);
|
|
1354
|
+
const restore = patchHttpModule(mod.default ?? mod, block, screen);
|
|
1207
1355
|
if (restore) restores.push(restore);
|
|
1208
1356
|
} catch {
|
|
1209
1357
|
}
|
|
@@ -1241,7 +1389,7 @@ async function installEgressGuard({ shouldBlock, onBlock } = {}) {
|
|
|
1241
1389
|
}
|
|
1242
1390
|
};
|
|
1243
1391
|
}
|
|
1244
|
-
function patchHttpModule(http, block) {
|
|
1392
|
+
function patchHttpModule(http, block, screen) {
|
|
1245
1393
|
if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
|
|
1246
1394
|
const originalRequest = http.request;
|
|
1247
1395
|
const originalGet = http.get;
|
|
@@ -1250,14 +1398,25 @@ function patchHttpModule(http, block) {
|
|
|
1250
1398
|
if (target && block(target.url, target.host, target.method)) {
|
|
1251
1399
|
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
|
|
1252
1400
|
}
|
|
1401
|
+
if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
|
|
1402
|
+
try {
|
|
1403
|
+
args = withScreeningLookup(args, target, block, screen.lookup);
|
|
1404
|
+
} catch {
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1253
1407
|
return original.apply(this, args);
|
|
1254
1408
|
};
|
|
1255
|
-
|
|
1256
|
-
|
|
1409
|
+
const guardedRequest = wrap(originalRequest);
|
|
1410
|
+
http.request = guardedRequest;
|
|
1411
|
+
let guardedGet;
|
|
1412
|
+
if (typeof originalGet === "function") {
|
|
1413
|
+
guardedGet = wrap(originalGet);
|
|
1414
|
+
http.get = guardedGet;
|
|
1415
|
+
}
|
|
1257
1416
|
http.__patchstackGuarded = true;
|
|
1258
1417
|
return () => {
|
|
1259
|
-
http.request = originalRequest;
|
|
1260
|
-
if (
|
|
1418
|
+
if (http.request === guardedRequest) http.request = originalRequest;
|
|
1419
|
+
if (guardedGet && http.get === guardedGet) http.get = originalGet;
|
|
1261
1420
|
delete http.__patchstackGuarded;
|
|
1262
1421
|
};
|
|
1263
1422
|
}
|
|
@@ -1270,7 +1429,7 @@ function extractHttpTarget(args) {
|
|
|
1270
1429
|
return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
|
|
1271
1430
|
}
|
|
1272
1431
|
if (first && typeof first === "object") {
|
|
1273
|
-
const host = normalizeHost(first.hostname || first.host);
|
|
1432
|
+
const host = normalizeHost(first.hostname || first.host) || "localhost";
|
|
1274
1433
|
const protocol = first.protocol || "http:";
|
|
1275
1434
|
const port = first.port ? `:${first.port}` : "";
|
|
1276
1435
|
const path = first.path || "/";
|
|
@@ -1287,6 +1446,61 @@ function normalizeHost(raw) {
|
|
|
1287
1446
|
if ((host.match(/:/g) || []).length > 1) return host;
|
|
1288
1447
|
return host.split(":")[0];
|
|
1289
1448
|
}
|
|
1449
|
+
function screenResolved(addresses, target, block) {
|
|
1450
|
+
for (const a of addresses || []) {
|
|
1451
|
+
const ip = a && typeof a === "object" ? a.address : a;
|
|
1452
|
+
if (ip && block(target.url, ip, target.method)) return ip;
|
|
1453
|
+
}
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
function withScreeningLookup(args, target, block, lookup) {
|
|
1457
|
+
const screeningLookup = (hostname, options, callback) => {
|
|
1458
|
+
let opts = options;
|
|
1459
|
+
let cb = callback;
|
|
1460
|
+
if (typeof opts === "function") {
|
|
1461
|
+
cb = opts;
|
|
1462
|
+
opts = {};
|
|
1463
|
+
}
|
|
1464
|
+
if (!opts || typeof opts !== "object") opts = {};
|
|
1465
|
+
try {
|
|
1466
|
+
lookup(hostname, { ...opts, all: true }, (err, addresses) => {
|
|
1467
|
+
if (err) return cb(err);
|
|
1468
|
+
const list = Array.isArray(addresses) ? addresses : [];
|
|
1469
|
+
const blocked = screenResolved(list, target, block);
|
|
1470
|
+
if (blocked) {
|
|
1471
|
+
return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
|
|
1472
|
+
}
|
|
1473
|
+
if (opts.all) return cb(null, list);
|
|
1474
|
+
const first = list[0];
|
|
1475
|
+
if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
|
|
1476
|
+
return cb(null, first.address, first.family);
|
|
1477
|
+
});
|
|
1478
|
+
} catch {
|
|
1479
|
+
try {
|
|
1480
|
+
lookup(hostname, opts, cb);
|
|
1481
|
+
} catch {
|
|
1482
|
+
cb(new Error(`Patchstack: lookup failed for ${hostname}`));
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
return injectLookupOption(args, screeningLookup);
|
|
1487
|
+
}
|
|
1488
|
+
function injectLookupOption(args, lookup) {
|
|
1489
|
+
const first = args[0];
|
|
1490
|
+
if (first && typeof first === "object" && !(first instanceof URL)) {
|
|
1491
|
+
return [{ ...first, lookup }, ...args.slice(1)];
|
|
1492
|
+
}
|
|
1493
|
+
const rest = args.slice(1);
|
|
1494
|
+
const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
|
|
1495
|
+
if (optIdx !== -1) {
|
|
1496
|
+
const next = [...rest];
|
|
1497
|
+
next[optIdx] = { ...rest[optIdx], lookup };
|
|
1498
|
+
return [first, ...next];
|
|
1499
|
+
}
|
|
1500
|
+
const cbIdx = rest.findIndex((a) => typeof a === "function");
|
|
1501
|
+
if (cbIdx === -1) return [first, { lookup }, ...rest];
|
|
1502
|
+
return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
|
|
1503
|
+
}
|
|
1290
1504
|
|
|
1291
1505
|
// src/protect/defaults.js
|
|
1292
1506
|
var DEFAULT_RESPONSE_RULES = [
|
|
@@ -1451,9 +1665,189 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
|
1451
1665
|
</html>`;
|
|
1452
1666
|
}
|
|
1453
1667
|
|
|
1454
|
-
// src/protect/
|
|
1668
|
+
// src/protect/rules/store.js
|
|
1455
1669
|
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
1456
1670
|
import { join } from "path";
|
|
1671
|
+
function makeStore(options = {}) {
|
|
1672
|
+
let mem = null;
|
|
1673
|
+
const durable = durableTier(options);
|
|
1674
|
+
return {
|
|
1675
|
+
async read() {
|
|
1676
|
+
if (mem) return mem;
|
|
1677
|
+
const env = await durable.read();
|
|
1678
|
+
if (env) mem = env;
|
|
1679
|
+
return env;
|
|
1680
|
+
},
|
|
1681
|
+
async write(env) {
|
|
1682
|
+
if (env) mem = env;
|
|
1683
|
+
await durable.write(env);
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
function durableTier(options) {
|
|
1688
|
+
const adapter = options.ruleCache;
|
|
1689
|
+
if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
|
|
1690
|
+
return {
|
|
1691
|
+
read: async () => {
|
|
1692
|
+
try {
|
|
1693
|
+
return toEnvelope(await adapter.read());
|
|
1694
|
+
} catch {
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
write: async (env) => {
|
|
1699
|
+
try {
|
|
1700
|
+
await adapter.write(env);
|
|
1701
|
+
} catch {
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
const dir = options.cacheDir;
|
|
1707
|
+
return {
|
|
1708
|
+
read: async () => cacheRead(dir),
|
|
1709
|
+
write: async (env) => cacheWrite(dir, env)
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
function cachePath(dir) {
|
|
1713
|
+
return join(dir, "patchstack-rules.json");
|
|
1714
|
+
}
|
|
1715
|
+
function cacheWrite(dir, env) {
|
|
1716
|
+
if (!dir) return;
|
|
1717
|
+
try {
|
|
1718
|
+
mkdirSync(dir, { recursive: true });
|
|
1719
|
+
writeFileSync(cachePath(dir), JSON.stringify(env));
|
|
1720
|
+
} catch {
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
function cacheRead(dir) {
|
|
1724
|
+
if (!dir) return null;
|
|
1725
|
+
try {
|
|
1726
|
+
return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
|
|
1727
|
+
} catch {
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
function toEnvelope(value) {
|
|
1732
|
+
if (!value || typeof value !== "object") return null;
|
|
1733
|
+
if (value.bundle && typeof value.bundle === "object") {
|
|
1734
|
+
return { bundle: value.bundle, etag: value.etag ?? null };
|
|
1735
|
+
}
|
|
1736
|
+
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
1737
|
+
return { bundle: value, etag: null };
|
|
1738
|
+
}
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/protect/rules/source.js
|
|
1743
|
+
async function resolveRules(options, store) {
|
|
1744
|
+
if (options.siteUuid) {
|
|
1745
|
+
const prior = await store.read();
|
|
1746
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
|
|
1747
|
+
const res = await client.getRules();
|
|
1748
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
1749
|
+
if (res.success && !res.notModified) {
|
|
1750
|
+
const bundle = normalizeBundle(res);
|
|
1751
|
+
await store.write({ bundle, etag: res.etag ?? null });
|
|
1752
|
+
return bundle;
|
|
1753
|
+
}
|
|
1754
|
+
if (prior?.bundle) {
|
|
1755
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1756
|
+
return normalizeBundle(prior.bundle);
|
|
1757
|
+
}
|
|
1758
|
+
if (options.rules) {
|
|
1759
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
1760
|
+
return normalizeBundle(options.rules);
|
|
1761
|
+
}
|
|
1762
|
+
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1763
|
+
return emptyBundle();
|
|
1764
|
+
}
|
|
1765
|
+
if (options.token) {
|
|
1766
|
+
const prior = await store.read();
|
|
1767
|
+
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
|
|
1768
|
+
const res = await client.getRules();
|
|
1769
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
1770
|
+
if (res.success && !res.notModified) {
|
|
1771
|
+
const bundle = normalizeBundle(res);
|
|
1772
|
+
await store.write({ bundle, etag: res.etag ?? null });
|
|
1773
|
+
return bundle;
|
|
1774
|
+
}
|
|
1775
|
+
if (prior?.bundle) {
|
|
1776
|
+
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1777
|
+
return normalizeBundle(prior.bundle);
|
|
1778
|
+
}
|
|
1779
|
+
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1780
|
+
return emptyBundle();
|
|
1781
|
+
}
|
|
1782
|
+
if (options.rules) {
|
|
1783
|
+
return normalizeBundle(options.rules);
|
|
1784
|
+
}
|
|
1785
|
+
return emptyBundle();
|
|
1786
|
+
}
|
|
1787
|
+
function normalizeBundle(b) {
|
|
1788
|
+
return {
|
|
1789
|
+
firewall: Array.isArray(b.firewall) ? b.firewall : [],
|
|
1790
|
+
whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
|
|
1791
|
+
whitelist_keys: b.whitelist_keys ?? {}
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
function emptyBundle() {
|
|
1795
|
+
return { firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// src/protect/rules/refresh.js
|
|
1799
|
+
var JITTER_FRACTION3 = 0.1;
|
|
1800
|
+
var MAX_BACKOFF_MULTIPLIER = 8;
|
|
1801
|
+
function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
1802
|
+
let stopped = false;
|
|
1803
|
+
let failures = 0;
|
|
1804
|
+
let timer = null;
|
|
1805
|
+
const schedule = () => {
|
|
1806
|
+
if (stopped) return;
|
|
1807
|
+
const backoff = Math.min(2 ** failures, MAX_BACKOFF_MULTIPLIER);
|
|
1808
|
+
const jitter = 1 - Math.random() * JITTER_FRACTION3;
|
|
1809
|
+
const delay = refreshMs * backoff * jitter;
|
|
1810
|
+
timer = setTimeout(run, delay);
|
|
1811
|
+
timer?.unref?.();
|
|
1812
|
+
};
|
|
1813
|
+
const run = async () => {
|
|
1814
|
+
if (stopped) return;
|
|
1815
|
+
try {
|
|
1816
|
+
await tick();
|
|
1817
|
+
failures = 0;
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
failures++;
|
|
1820
|
+
onError?.(err);
|
|
1821
|
+
}
|
|
1822
|
+
schedule();
|
|
1823
|
+
};
|
|
1824
|
+
schedule();
|
|
1825
|
+
return {
|
|
1826
|
+
stop() {
|
|
1827
|
+
stopped = true;
|
|
1828
|
+
if (timer) clearTimeout(timer);
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
function makeRefreshHandler(tick, secret) {
|
|
1833
|
+
return async (request) => {
|
|
1834
|
+
if (!secret) return new Response("not found", { status: 404 });
|
|
1835
|
+
let provided = null;
|
|
1836
|
+
try {
|
|
1837
|
+
provided = request?.headers?.get?.("x-patchstack-refresh") ?? new URL(request.url).searchParams.get("token");
|
|
1838
|
+
} catch {
|
|
1839
|
+
provided = null;
|
|
1840
|
+
}
|
|
1841
|
+
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
1842
|
+
let refreshed = true;
|
|
1843
|
+
try {
|
|
1844
|
+
await tick();
|
|
1845
|
+
} catch {
|
|
1846
|
+
refreshed = false;
|
|
1847
|
+
}
|
|
1848
|
+
return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1457
1851
|
|
|
1458
1852
|
// src/protect/supabase-guard.js
|
|
1459
1853
|
var GUARD_PATH = "/_patchstack/guard";
|
|
@@ -1529,23 +1923,35 @@ async function createProtection(options = {}) {
|
|
|
1529
1923
|
const mode = options.mode === "block" ? "block" : "dry-run";
|
|
1530
1924
|
const onError = options.onError;
|
|
1531
1925
|
const onDetect = options.onDetect ?? defaultOnDetect;
|
|
1532
|
-
const
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1926
|
+
const store = makeStore(options);
|
|
1927
|
+
const bundle = await resolveRules(options, store);
|
|
1928
|
+
let requestRules;
|
|
1929
|
+
let responseRules;
|
|
1930
|
+
let egressRules;
|
|
1931
|
+
let screenCap;
|
|
1932
|
+
let engine;
|
|
1933
|
+
let responseRuleSet;
|
|
1934
|
+
let egressEngine;
|
|
1935
|
+
const applyBundle = (delivered) => {
|
|
1936
|
+
const incoming = delivered.firewall ?? [];
|
|
1937
|
+
requestRules = byPhase(incoming, "request");
|
|
1938
|
+
responseRules = [...options.responseRules ?? DEFAULT_RESPONSE_RULES, ...byPhase(incoming, "response")];
|
|
1939
|
+
screenCap = responseScreenCap(responseRules);
|
|
1940
|
+
egressRules = [...options.egressRules ?? DEFAULT_EGRESS_RULES, ...byPhase(incoming, "egress")];
|
|
1941
|
+
engine = new RuleEngine({
|
|
1942
|
+
firewall: requestRules,
|
|
1943
|
+
whitelists: delivered.whitelists,
|
|
1944
|
+
whitelist_keys: delivered.whitelist_keys,
|
|
1945
|
+
onError
|
|
1946
|
+
});
|
|
1947
|
+
responseRuleSet = responseRules.map((rule) => ({
|
|
1948
|
+
rule,
|
|
1949
|
+
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
1950
|
+
redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null
|
|
1951
|
+
}));
|
|
1952
|
+
egressEngine = new RuleEngine({ firewall: egressRules, onError });
|
|
1953
|
+
};
|
|
1954
|
+
applyBundle(bundle);
|
|
1549
1955
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
1550
1956
|
const decide = (phase, result, block, allow2) => {
|
|
1551
1957
|
if (!result || !result.blocked) return allow2();
|
|
@@ -1579,20 +1985,26 @@ async function createProtection(options = {}) {
|
|
|
1579
1985
|
const headers = { ...meta.headers || {} };
|
|
1580
1986
|
for (const { rule, redactors } of redactions) {
|
|
1581
1987
|
const mask = maskFn(rule.category);
|
|
1582
|
-
|
|
1988
|
+
const transform = rule.action === "encode" ? htmlEscape : null;
|
|
1989
|
+
const pathRedactors = redactors.filter((r) => r.jsonPath);
|
|
1990
|
+
const spanRedactors = redactors.filter((r) => !r.jsonPath);
|
|
1991
|
+
if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
|
|
1992
|
+
if (!spanRedactors.length) continue;
|
|
1993
|
+
body = applyRedactors(body, spanRedactors, mask, transform);
|
|
1994
|
+
if (transform) continue;
|
|
1583
1995
|
for (const name of Object.keys(headers)) {
|
|
1584
1996
|
const value = headers[name];
|
|
1585
1997
|
if (typeof value === "string") {
|
|
1586
|
-
headers[name] = applyRedactors(value,
|
|
1998
|
+
headers[name] = applyRedactors(value, spanRedactors, mask);
|
|
1587
1999
|
} else if (Array.isArray(value)) {
|
|
1588
|
-
headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item,
|
|
2000
|
+
headers[name] = value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item);
|
|
1589
2001
|
}
|
|
1590
2002
|
}
|
|
1591
2003
|
}
|
|
1592
2004
|
return { verdict: "redact", body, headers };
|
|
1593
2005
|
};
|
|
1594
2006
|
const screenResp = async (response) => {
|
|
1595
|
-
const text = await readTextResponse(response);
|
|
2007
|
+
const text = await readTextResponse(response, screenCap);
|
|
1596
2008
|
if (text == null) return response;
|
|
1597
2009
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
|
|
1598
2010
|
if (r.verdict === "block") return leakResponse();
|
|
@@ -1605,12 +2017,15 @@ async function createProtection(options = {}) {
|
|
|
1605
2017
|
const chunks = [];
|
|
1606
2018
|
let size = 0;
|
|
1607
2019
|
let overflow = false;
|
|
1608
|
-
const MAX =
|
|
2020
|
+
const MAX = screenCap;
|
|
1609
2021
|
const collect = (chunk, enc) => {
|
|
1610
2022
|
if (chunk == null) return;
|
|
1611
2023
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
1612
2024
|
size += buf.length;
|
|
1613
2025
|
if (size > MAX) {
|
|
2026
|
+
for (const c of chunks) origWrite(c);
|
|
2027
|
+
chunks.length = 0;
|
|
2028
|
+
origWrite(buf);
|
|
1614
2029
|
overflow = true;
|
|
1615
2030
|
return;
|
|
1616
2031
|
}
|
|
@@ -1637,6 +2052,7 @@ async function createProtection(options = {}) {
|
|
|
1637
2052
|
return origEnd(cb);
|
|
1638
2053
|
}
|
|
1639
2054
|
collect(chunk, enc);
|
|
2055
|
+
if (overflow) return origEnd(cb);
|
|
1640
2056
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
1641
2057
|
let ct = res.getHeader ? res.getHeader("content-type") : void 0;
|
|
1642
2058
|
if (Array.isArray(ct)) ct = ct[0];
|
|
@@ -1668,6 +2084,7 @@ async function createProtection(options = {}) {
|
|
|
1668
2084
|
if (r.headers && res.setHeader) {
|
|
1669
2085
|
const current = res.getHeaders ? res.getHeaders() : {};
|
|
1670
2086
|
for (const [name, value] of Object.entries(r.headers)) {
|
|
2087
|
+
if (name.toLowerCase() === "content-length") continue;
|
|
1671
2088
|
if (Array.isArray(value)) {
|
|
1672
2089
|
try {
|
|
1673
2090
|
res.setHeader(name, value);
|
|
@@ -1703,7 +2120,9 @@ async function createProtection(options = {}) {
|
|
|
1703
2120
|
};
|
|
1704
2121
|
const protection = {
|
|
1705
2122
|
mode,
|
|
1706
|
-
rules
|
|
2123
|
+
get rules() {
|
|
2124
|
+
return { request: requestRules, response: responseRules, egress: egressRules };
|
|
2125
|
+
},
|
|
1707
2126
|
// Screen a fetch Response through the response-phase rules (redact/block). Used by
|
|
1708
2127
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
1709
2128
|
screenResponse: (response) => screenResp(response),
|
|
@@ -1814,27 +2233,106 @@ async function createProtection(options = {}) {
|
|
|
1814
2233
|
}
|
|
1815
2234
|
};
|
|
1816
2235
|
if (options.egress) {
|
|
1817
|
-
protection.uninstallEgress = await installEgressGuard({
|
|
2236
|
+
protection.uninstallEgress = await installEgressGuard({
|
|
2237
|
+
shouldBlock: egressShouldBlock,
|
|
2238
|
+
onBlock: options.onEgressBlock,
|
|
2239
|
+
dnsScreen: options.screenDns !== false,
|
|
2240
|
+
allowHosts: options.allowHosts
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
const cwd = options.cwd ?? (typeof process !== "undefined" ? process.cwd() : void 0);
|
|
2244
|
+
const live = Boolean(options.siteUuid || options.token);
|
|
2245
|
+
const refreshSecret = options.refreshSecret ?? (typeof process !== "undefined" ? process.env.PATCHSTACK_REFRESH_SECRET : void 0);
|
|
2246
|
+
const refreshable = live && (options.refreshMs > 0 || Boolean(refreshSecret));
|
|
2247
|
+
let reporter = null;
|
|
2248
|
+
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
2249
|
+
try {
|
|
2250
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-XBOGN446.js"));
|
|
2251
|
+
} catch (err) {
|
|
2252
|
+
onError?.(err);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
const runRefreshTick = async () => {
|
|
2256
|
+
if (reporter) {
|
|
2257
|
+
try {
|
|
2258
|
+
await reporter(cwd);
|
|
2259
|
+
} catch (err) {
|
|
2260
|
+
onError?.(err);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
applyBundle(await resolveRules(options, store));
|
|
2264
|
+
};
|
|
2265
|
+
if (live) {
|
|
2266
|
+
protection.refresh = () => runRefreshTick();
|
|
2267
|
+
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
2268
|
+
}
|
|
2269
|
+
if (options.refreshMs > 0 && live) {
|
|
2270
|
+
const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
|
|
2271
|
+
protection.stopRefresh = loop.stop;
|
|
1818
2272
|
}
|
|
1819
2273
|
return protection;
|
|
1820
2274
|
}
|
|
1821
2275
|
function byPhase(rules, phase) {
|
|
1822
2276
|
return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
|
|
1823
2277
|
}
|
|
1824
|
-
async function readTextResponse(response) {
|
|
2278
|
+
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
1825
2279
|
if (!response || typeof response.clone !== "function") return null;
|
|
1826
2280
|
const ct = (response.headers?.get?.("content-type") || "").toLowerCase();
|
|
1827
2281
|
const isText = ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
|
|
1828
2282
|
if (!isText) return null;
|
|
1829
2283
|
const len = Number(response.headers?.get?.("content-length") || 0);
|
|
1830
|
-
if (len && len >
|
|
2284
|
+
if (len && len > cap) return null;
|
|
2285
|
+
let clone;
|
|
2286
|
+
try {
|
|
2287
|
+
clone = response.clone();
|
|
2288
|
+
} catch {
|
|
2289
|
+
return null;
|
|
2290
|
+
}
|
|
2291
|
+
const body = clone.body;
|
|
2292
|
+
if (body && typeof body.getReader === "function") {
|
|
2293
|
+
const reader = body.getReader();
|
|
2294
|
+
const chunks = [];
|
|
2295
|
+
let size = 0;
|
|
2296
|
+
let over = false;
|
|
2297
|
+
try {
|
|
2298
|
+
for (; ; ) {
|
|
2299
|
+
const { done, value } = await reader.read();
|
|
2300
|
+
if (done) break;
|
|
2301
|
+
if (!value) continue;
|
|
2302
|
+
size += value.byteLength;
|
|
2303
|
+
if (over) continue;
|
|
2304
|
+
if (size > cap) {
|
|
2305
|
+
over = true;
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
chunks.push(value);
|
|
2309
|
+
}
|
|
2310
|
+
} catch {
|
|
2311
|
+
return null;
|
|
2312
|
+
}
|
|
2313
|
+
if (over) return null;
|
|
2314
|
+
try {
|
|
2315
|
+
return new TextDecoder().decode(concatBytes(chunks, size));
|
|
2316
|
+
} catch {
|
|
2317
|
+
return null;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
1831
2320
|
try {
|
|
1832
|
-
const text = await
|
|
1833
|
-
return text.length >
|
|
2321
|
+
const text = await clone.text();
|
|
2322
|
+
return text.length > cap ? null : text;
|
|
1834
2323
|
} catch {
|
|
1835
2324
|
return null;
|
|
1836
2325
|
}
|
|
1837
2326
|
}
|
|
2327
|
+
function concatBytes(chunks, total) {
|
|
2328
|
+
const out = new Uint8Array(total);
|
|
2329
|
+
let offset = 0;
|
|
2330
|
+
for (const c of chunks) {
|
|
2331
|
+
out.set(c, offset);
|
|
2332
|
+
offset += c.byteLength;
|
|
2333
|
+
}
|
|
2334
|
+
return out;
|
|
2335
|
+
}
|
|
1838
2336
|
function headerObject(headers) {
|
|
1839
2337
|
const out = {};
|
|
1840
2338
|
headers?.forEach?.((v, k) => {
|
|
@@ -1852,30 +2350,137 @@ function extractRedactors(rule) {
|
|
|
1852
2350
|
const m = c.match;
|
|
1853
2351
|
if (!m) continue;
|
|
1854
2352
|
if (m.type === "regex" && typeof m.value === "string") {
|
|
1855
|
-
const
|
|
1856
|
-
if (
|
|
1857
|
-
const flags =
|
|
2353
|
+
const safe = safeRegExp(m.value);
|
|
2354
|
+
if (safe) {
|
|
2355
|
+
const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
|
|
1858
2356
|
try {
|
|
1859
|
-
out.push({ re: new RegExp(
|
|
2357
|
+
out.push({ re: new RegExp(safe.source, flags) });
|
|
1860
2358
|
} catch {
|
|
1861
2359
|
}
|
|
1862
2360
|
}
|
|
1863
2361
|
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
1864
2362
|
out.push({ literal: String(m.value) });
|
|
2363
|
+
} else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
|
|
2364
|
+
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
2365
|
+
for (const key of keys) {
|
|
2366
|
+
out.push({ jsonPath: String(key).split("."), condition: m.match });
|
|
2367
|
+
}
|
|
1865
2368
|
}
|
|
1866
2369
|
}
|
|
1867
2370
|
};
|
|
1868
2371
|
walk(rule.rule_v2);
|
|
1869
2372
|
return out;
|
|
1870
2373
|
}
|
|
1871
|
-
function
|
|
2374
|
+
function htmlEscape(str) {
|
|
2375
|
+
return String(str).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
2376
|
+
}
|
|
2377
|
+
function applyRedactors(body, redactors, mask, transform) {
|
|
1872
2378
|
let out = body;
|
|
1873
2379
|
for (const r of redactors) {
|
|
1874
|
-
if (r.re) out = out.replace(r.re, mask);
|
|
1875
|
-
else if (r.literal) out = out.split(r.literal).join(mask);
|
|
2380
|
+
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
2381
|
+
else if (r.literal) out = out.split(r.literal).join(transform ? transform(r.literal) : mask);
|
|
1876
2382
|
}
|
|
1877
2383
|
return out;
|
|
1878
2384
|
}
|
|
2385
|
+
function isBodyParam(parameter) {
|
|
2386
|
+
return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
|
|
2387
|
+
}
|
|
2388
|
+
function conditionPredicate(condition) {
|
|
2389
|
+
if (!condition || !condition.type) return () => true;
|
|
2390
|
+
return (value) => {
|
|
2391
|
+
try {
|
|
2392
|
+
return matchValue(condition.type, value, condition.value, condition);
|
|
2393
|
+
} catch {
|
|
2394
|
+
return false;
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
var DEFAULT_SCREEN_CAP = 512 * 1024;
|
|
2399
|
+
function responseScreenCap(rules) {
|
|
2400
|
+
let cap = DEFAULT_SCREEN_CAP;
|
|
2401
|
+
for (const r of rules ?? []) {
|
|
2402
|
+
if (r && r.bypass_limit === true) return Infinity;
|
|
2403
|
+
const n = Number(r && r.max_bytes);
|
|
2404
|
+
if (Number.isFinite(n) && n > cap) cap = n;
|
|
2405
|
+
}
|
|
2406
|
+
return cap;
|
|
2407
|
+
}
|
|
2408
|
+
function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
|
|
2409
|
+
if (!pathRedactors.length || typeof text !== "string" || text.length > cap) return text;
|
|
2410
|
+
const head = text.trimStart()[0];
|
|
2411
|
+
if (head !== "{" && head !== "[") return text;
|
|
2412
|
+
const preserved = preserveBigInts(text);
|
|
2413
|
+
let obj;
|
|
2414
|
+
try {
|
|
2415
|
+
obj = JSON.parse(preserved);
|
|
2416
|
+
} catch {
|
|
2417
|
+
return text;
|
|
2418
|
+
}
|
|
2419
|
+
let changed = false;
|
|
2420
|
+
for (const r of pathRedactors) {
|
|
2421
|
+
const pred = conditionPredicate(r.condition);
|
|
2422
|
+
walkLeaves(obj, r.jsonPath, (loc) => {
|
|
2423
|
+
try {
|
|
2424
|
+
if (pred(loc.value)) {
|
|
2425
|
+
loc.parent[loc.key] = transform ? transform(String(loc.value)) : mask;
|
|
2426
|
+
changed = true;
|
|
2427
|
+
}
|
|
2428
|
+
} catch {
|
|
2429
|
+
}
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
return changed ? restoreBigInts(JSON.stringify(obj)) : text;
|
|
2433
|
+
}
|
|
2434
|
+
var BIGINT_OPEN = "__PSBIGINT_9c2f__";
|
|
2435
|
+
var BIGINT_CLOSE = "__DNEGIB__";
|
|
2436
|
+
function preserveBigInts(text) {
|
|
2437
|
+
let out = "";
|
|
2438
|
+
let inStr = false;
|
|
2439
|
+
for (let i = 0; i < text.length; ) {
|
|
2440
|
+
const ch = text[i];
|
|
2441
|
+
if (inStr) {
|
|
2442
|
+
out += ch;
|
|
2443
|
+
if (ch === "\\") {
|
|
2444
|
+
out += text[i + 1] ?? "";
|
|
2445
|
+
i += 2;
|
|
2446
|
+
continue;
|
|
2447
|
+
}
|
|
2448
|
+
if (ch === '"') inStr = false;
|
|
2449
|
+
i++;
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
if (ch === '"') {
|
|
2453
|
+
inStr = true;
|
|
2454
|
+
out += ch;
|
|
2455
|
+
i++;
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
if (ch === "-" || ch >= "0" && ch <= "9") {
|
|
2459
|
+
let j = ch === "-" ? i + 1 : i;
|
|
2460
|
+
let digits = 0;
|
|
2461
|
+
while (j < text.length && text[j] >= "0" && text[j] <= "9") {
|
|
2462
|
+
digits++;
|
|
2463
|
+
j++;
|
|
2464
|
+
}
|
|
2465
|
+
const next = text[j];
|
|
2466
|
+
const isIntToken = digits > 0 && next !== "." && next !== "e" && next !== "E";
|
|
2467
|
+
if (isIntToken && digits >= 16) {
|
|
2468
|
+
out += `"${BIGINT_OPEN}${text.slice(i, j)}${BIGINT_CLOSE}"`;
|
|
2469
|
+
} else {
|
|
2470
|
+
out += text.slice(i, j || i + 1);
|
|
2471
|
+
}
|
|
2472
|
+
i = j > i ? j : i + 1;
|
|
2473
|
+
continue;
|
|
2474
|
+
}
|
|
2475
|
+
out += ch;
|
|
2476
|
+
i++;
|
|
2477
|
+
}
|
|
2478
|
+
return out;
|
|
2479
|
+
}
|
|
2480
|
+
function restoreBigInts(text) {
|
|
2481
|
+
if (!text.includes(BIGINT_OPEN)) return text;
|
|
2482
|
+
return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
|
|
2483
|
+
}
|
|
1879
2484
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
1880
2485
|
const headers = new Headers(response.headers);
|
|
1881
2486
|
headers.delete("content-length");
|
|
@@ -1905,115 +2510,6 @@ function leakResponse() {
|
|
|
1905
2510
|
headers: { "content-type": "application/json" }
|
|
1906
2511
|
});
|
|
1907
2512
|
}
|
|
1908
|
-
async function resolveRules(options) {
|
|
1909
|
-
const cache = makeCache(options);
|
|
1910
|
-
if (options.siteUuid) {
|
|
1911
|
-
const prior = await cache.read();
|
|
1912
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
|
|
1913
|
-
const res = await client.getRules();
|
|
1914
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
1915
|
-
if (res.success && !res.notModified) {
|
|
1916
|
-
const bundle = normalizeBundle(res);
|
|
1917
|
-
await cache.write({ bundle, etag: res.etag ?? null });
|
|
1918
|
-
return bundle;
|
|
1919
|
-
}
|
|
1920
|
-
if (prior?.bundle) {
|
|
1921
|
-
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1922
|
-
return normalizeBundle(prior.bundle);
|
|
1923
|
-
}
|
|
1924
|
-
if (options.rules) {
|
|
1925
|
-
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
1926
|
-
return normalizeBundle(options.rules);
|
|
1927
|
-
}
|
|
1928
|
-
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1929
|
-
return emptyBundle();
|
|
1930
|
-
}
|
|
1931
|
-
if (options.token) {
|
|
1932
|
-
const prior = await cache.read();
|
|
1933
|
-
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
|
|
1934
|
-
const res = await client.getRules();
|
|
1935
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
1936
|
-
if (res.success && !res.notModified) {
|
|
1937
|
-
const bundle = normalizeBundle(res);
|
|
1938
|
-
await cache.write({ bundle, etag: res.etag ?? null });
|
|
1939
|
-
return bundle;
|
|
1940
|
-
}
|
|
1941
|
-
if (prior?.bundle) {
|
|
1942
|
-
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
1943
|
-
return normalizeBundle(prior.bundle);
|
|
1944
|
-
}
|
|
1945
|
-
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
1946
|
-
return emptyBundle();
|
|
1947
|
-
}
|
|
1948
|
-
if (options.rules) {
|
|
1949
|
-
return normalizeBundle(options.rules);
|
|
1950
|
-
}
|
|
1951
|
-
return emptyBundle();
|
|
1952
|
-
}
|
|
1953
|
-
function normalizeBundle(b) {
|
|
1954
|
-
return {
|
|
1955
|
-
firewall: Array.isArray(b.firewall) ? b.firewall : [],
|
|
1956
|
-
whitelists: Array.isArray(b.whitelists) ? b.whitelists : [],
|
|
1957
|
-
whitelist_keys: b.whitelist_keys ?? {}
|
|
1958
|
-
};
|
|
1959
|
-
}
|
|
1960
|
-
function emptyBundle() {
|
|
1961
|
-
return { firewall: [], whitelists: [], whitelist_keys: {} };
|
|
1962
|
-
}
|
|
1963
|
-
function makeCache(options) {
|
|
1964
|
-
const adapter = options.ruleCache;
|
|
1965
|
-
if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
|
|
1966
|
-
return {
|
|
1967
|
-
read: async () => {
|
|
1968
|
-
try {
|
|
1969
|
-
return toEnvelope(await adapter.read());
|
|
1970
|
-
} catch {
|
|
1971
|
-
return null;
|
|
1972
|
-
}
|
|
1973
|
-
},
|
|
1974
|
-
write: async (env) => {
|
|
1975
|
-
try {
|
|
1976
|
-
await adapter.write(env);
|
|
1977
|
-
} catch {
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
};
|
|
1981
|
-
}
|
|
1982
|
-
const dir = options.cacheDir;
|
|
1983
|
-
return {
|
|
1984
|
-
read: async () => cacheRead(dir),
|
|
1985
|
-
write: async (env) => cacheWrite(dir, env)
|
|
1986
|
-
};
|
|
1987
|
-
}
|
|
1988
|
-
function cachePath(dir) {
|
|
1989
|
-
return join(dir, "patchstack-rules.json");
|
|
1990
|
-
}
|
|
1991
|
-
function cacheWrite(dir, env) {
|
|
1992
|
-
if (!dir) return;
|
|
1993
|
-
try {
|
|
1994
|
-
mkdirSync(dir, { recursive: true });
|
|
1995
|
-
writeFileSync(cachePath(dir), JSON.stringify(env));
|
|
1996
|
-
} catch {
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
function cacheRead(dir) {
|
|
2000
|
-
if (!dir) return null;
|
|
2001
|
-
try {
|
|
2002
|
-
return toEnvelope(JSON.parse(readFileSync(cachePath(dir), "utf8")));
|
|
2003
|
-
} catch {
|
|
2004
|
-
return null;
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
function toEnvelope(value) {
|
|
2008
|
-
if (!value || typeof value !== "object") return null;
|
|
2009
|
-
if (value.bundle && typeof value.bundle === "object") {
|
|
2010
|
-
return { bundle: value.bundle, etag: value.etag ?? null };
|
|
2011
|
-
}
|
|
2012
|
-
if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
|
|
2013
|
-
return { bundle: value, etag: null };
|
|
2014
|
-
}
|
|
2015
|
-
return null;
|
|
2016
|
-
}
|
|
2017
2513
|
var BLOCK_MESSAGE = "This request has been blocked by Patchstack.";
|
|
2018
2514
|
function blockBody(result) {
|
|
2019
2515
|
return { error: BLOCK_MESSAGE, message: BLOCK_MESSAGE, rule: result?.rule?.id };
|